@unoverse-platform/studio 0.1.0
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/bin/studio.mjs +124 -0
- package/index.html +12 -0
- package/local/catalog.ts +105 -0
- package/local/keys.ts +118 -0
- package/local/nodes.ts +77 -0
- package/local/plugin.ts +401 -0
- package/local/scaffold.d.mts +13 -0
- package/local/scaffold.mjs +85 -0
- package/package.json +43 -0
- package/public/logo.png +0 -0
- package/screens/AtomsView.tsx +206 -0
- package/screens/ComponentsView.tsx +551 -0
- package/screens/ConfigForm.tsx +217 -0
- package/screens/DesignSystemView.tsx +324 -0
- package/screens/IdentityHeader.tsx +88 -0
- package/screens/PromptBlocksView.tsx +137 -0
- package/screens/SkillsView.tsx +167 -0
- package/screens/TemplatesView.tsx +1005 -0
- package/screens/index.ts +26 -0
- package/screens/states.ts +44 -0
- package/src/App.tsx +252 -0
- package/src/ConnectUniverse.tsx +232 -0
- package/src/NewProject.tsx +89 -0
- package/src/NodeKeys.tsx +98 -0
- package/src/NodesView.tsx +443 -0
- package/src/PublishDialog.tsx +309 -0
- package/src/UniverseSession.tsx +121 -0
- package/src/host.ts +165 -0
- package/src/index.css +7 -0
- package/src/main.tsx +11 -0
- package/src/universes.ts +210 -0
- package/vendor/sdk/appEngine.tsx +219 -0
- package/vendor/sdk/lib/UnoverseComponent.tsx +120 -0
- package/vendor/sdk/lib/connection.tsx +302 -0
- package/vendor/sdk/lib/core/actions.ts +84 -0
- package/vendor/sdk/lib/core/client.ts +134 -0
- package/vendor/sdk/lib/core/conditions.ts +43 -0
- package/vendor/sdk/lib/core/connection.ts +142 -0
- package/vendor/sdk/lib/core/index.ts +32 -0
- package/vendor/sdk/lib/core/store.ts +455 -0
- package/vendor/sdk/lib/core/types.ts +194 -0
- package/vendor/sdk/lib/index.ts +59 -0
- package/vendor/sdk/lib/isolate.tsx +70 -0
- package/vendor/sdk/lib/primitives.tsx +453 -0
- package/vendor/sdk/lib/realtime/audioUtils.ts +40 -0
- package/vendor/sdk/lib/realtime/types.ts +45 -0
- package/vendor/sdk/lib/realtime/useAudioCapture.ts +237 -0
- package/vendor/sdk/lib/realtime/useAudioPlayback.ts +145 -0
- package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +164 -0
- package/vendor/sdk/lib/render.tsx +178 -0
- package/vendor/sdk/lib/streamed.tsx +65 -0
- package/vendor/sdk/lib/style.ts +227 -0
- package/vendor/sdk/lib/template.tsx +521 -0
- package/vendor/sdk/lib/theme.ts +55 -0
- package/vendor/sdk/lib/voice.tsx +311 -0
- package/vendor/sdk/main.tsx +96 -0
- package/vite.config.ts +70 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <StreamedUnoverseTemplate> — renders a TEMPLATE definition (the layout) and
|
|
3
|
+
* fills its ComponentSlots from the store's timeline.
|
|
4
|
+
*
|
|
5
|
+
* A template owns nothing (UNOVERSE_SPEC.md §2e-0): it reads the shared state
|
|
6
|
+
* and arranges what it finds. This component is that arrangement —
|
|
7
|
+
* - reads the template recipe by URI (resources/read, via the client),
|
|
8
|
+
* - subscribes to the store so it re-renders as components stream in,
|
|
9
|
+
* - resolves each ComponentSlot against the timeline and renders the matched
|
|
10
|
+
* leaves with the SAME StreamedUnoverseComponent used everywhere else.
|
|
11
|
+
*
|
|
12
|
+
* This slice handles ComponentSlot + Skeleton (Tier-1, e.g. KeyService). The
|
|
13
|
+
* chat primitives (Conversation/Input/…) come in later slices.
|
|
14
|
+
*/
|
|
15
|
+
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from "react";
|
|
16
|
+
import { dispatchAction, type ComponentStore, type ResolvedTheme, type UnoverseClient, type UnoverseDefinition, type UnoverseNode } from "./core";
|
|
17
|
+
import { renderNode, styleToCss, keyframesCss, cssWidth, type ActionHandler, type SlotResolver } from "./render";
|
|
18
|
+
import { StreamedUnoverseComponent } from "./streamed";
|
|
19
|
+
import { useUnoverseTheme, ensureFontStylesheets } from "./theme";
|
|
20
|
+
import { IsolatedRoot } from "./isolate";
|
|
21
|
+
import { useVoiceService, type UseVoiceServiceConfig } from "./voice";
|
|
22
|
+
|
|
23
|
+
export interface StreamedUnoverseTemplateProps {
|
|
24
|
+
client: UnoverseClient;
|
|
25
|
+
store: ComponentStore;
|
|
26
|
+
/** e.g. "unoverse://templates/KeyService" */
|
|
27
|
+
uri: string;
|
|
28
|
+
onAction?: ActionHandler;
|
|
29
|
+
theme?: ResolvedTheme;
|
|
30
|
+
/** Channel overrides for the template's declared props (e.g. per-tenant logoUrl/brandName).
|
|
31
|
+
* Merged OVER the definition's own `props` defaults into the root data scope. */
|
|
32
|
+
props?: Record<string, unknown>;
|
|
33
|
+
/** Render inside a Shadow DOM for full CSS isolation (default true). See UnoverseComponent. */
|
|
34
|
+
isolate?: boolean;
|
|
35
|
+
/** The `voice` NATIVE SERVICE config (UNOVERSE_SPEC §2e-1). Present ONLY for a
|
|
36
|
+
* `service: "voice"` app — the host supplies the connection identity (apiUrl/wsUrl/
|
|
37
|
+
* token/session) it already holds; the renderer OWNS the service so every host is
|
|
38
|
+
* identical. Absent → the service is inert (no mic/WS ever opens). This is the ONE
|
|
39
|
+
* shared home for the voice buttons: `startCall`/`endCall`/`toggleMute` are answered
|
|
40
|
+
* HERE, not hand-wired per host (that drift is what broke the demo's Start Call). */
|
|
41
|
+
voice?: UseVoiceServiceConfig;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Inert voice config for non-voice templates — the hook is called unconditionally
|
|
45
|
+
* (rules of hooks) but nothing opens until a `startCall` this template never fires.
|
|
46
|
+
* No `store` → not a producer, so it writes no callState into ordinary templates. */
|
|
47
|
+
const INERT_VOICE: UseVoiceServiceConfig = {
|
|
48
|
+
apiUrl: "",
|
|
49
|
+
session: { userId: "", conversationId: "", workflowId: "", targetTriggerNode: "" },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** The author's declared prop defaults (def.props[k].default) → a flat data object. Content, not policy. */
|
|
53
|
+
function propDefaults(props?: Record<string, unknown>): Record<string, unknown> {
|
|
54
|
+
const out: Record<string, unknown> = {};
|
|
55
|
+
for (const [k, v] of Object.entries(props ?? {})) {
|
|
56
|
+
if (v && typeof v === "object" && "default" in (v as Record<string, unknown>)) {
|
|
57
|
+
out[k] = (v as { default: unknown }).default;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Format a turn timestamp as relative copy ("just now" / "5 min ago"). Neutral
|
|
64
|
+
* projection of a model value (like `text`), not UX policy. Mirrors legacy
|
|
65
|
+
* ChatHistoryItem.formatRelativeTime. Empty string when unstamped. */
|
|
66
|
+
function formatRelative(ts?: number): string {
|
|
67
|
+
if (!ts) return "";
|
|
68
|
+
const diff = Math.floor((Date.now() - ts) / 1000);
|
|
69
|
+
if (diff < 60) return "just now";
|
|
70
|
+
if (diff < 3600) return `${Math.floor(diff / 60)} min ago`;
|
|
71
|
+
if (diff < 86400) return `${Math.floor(diff / 3600)} hr ago`;
|
|
72
|
+
return new Date(ts).toLocaleDateString();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Walk a template tree and collect every VIEW a reaction surface claims — the `eq`
|
|
77
|
+
* value of each `ComponentSlot.select.where { field: "defaultState" }`. State-selected
|
|
78
|
+
* UI (STATE_MODEL §5b): a component instance renders in exactly ONE placeholder. If a
|
|
79
|
+
* surface shows a given view, the conversation flow (the `inline` placeholder) must NOT
|
|
80
|
+
* also render instances in that view — else the same instance paints twice. A view no
|
|
81
|
+
* surface claims is absent from this set, so it stays in the flow (the fallback).
|
|
82
|
+
* Exported for testing.
|
|
83
|
+
*/
|
|
84
|
+
export function collectSurfacedViews(node: unknown, out: Set<string> = new Set()): Set<string> {
|
|
85
|
+
if (Array.isArray(node)) {
|
|
86
|
+
for (const n of node) collectSurfacedViews(n, out);
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
if (!node || typeof node !== "object") return out;
|
|
90
|
+
const n = node as Record<string, unknown>;
|
|
91
|
+
const sel = n.select as { where?: { field?: string; eq?: unknown } } | undefined;
|
|
92
|
+
if (n.type === "ComponentSlot" && sel?.where?.field === "defaultState" && typeof sel.where.eq === "string") {
|
|
93
|
+
out.add(sel.where.eq);
|
|
94
|
+
}
|
|
95
|
+
for (const v of Object.values(n)) if (v && typeof v === "object") collectSurfacedViews(v, out);
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* STATE-OWNED APP WIDTH (docs/design/05, layouts era): the app's outer width is
|
|
101
|
+
* declared by its LAYOUT's data, never the manifest. Two declaration points, both
|
|
102
|
+
* the neutral `appWidth` key (a named app size resolved via theme.appSize):
|
|
103
|
+
* - a plain node's `appWidth` = an always-open panel (the core column) — the
|
|
104
|
+
* layout's constant term.
|
|
105
|
+
* - a ComponentSlot surface's `appWidth` = its panel width, counted only while
|
|
106
|
+
* the surface is OCCUPIED (its view is the active state).
|
|
107
|
+
* The caller passes the ACTIVE layout (resolveActiveLayout) — layout swaps are
|
|
108
|
+
* whole-arrangement swaps, so the width is always the active layout's total.
|
|
109
|
+
* Activation is DERIVED, never stored. No declared width → undefined: the host
|
|
110
|
+
* keeps its own default, the sizing twin of "unmatched view falls back to inline".
|
|
111
|
+
* There is no other path.
|
|
112
|
+
*/
|
|
113
|
+
export function computeAppWidth(root: unknown, store: ComponentStore, appSize?: Record<string, string>): string | undefined {
|
|
114
|
+
// Resolve a declared width: a NAMED SIZE via theme.appSize (like any token), else raw
|
|
115
|
+
// CSS — guarded so an unresolvable name can never emit invalid CSS.
|
|
116
|
+
const rw = (w: unknown): string | undefined => cssWidth(typeof w === "string" ? (appSize?.[w] ?? w) : undefined);
|
|
117
|
+
const r = root as Record<string, unknown> | null | undefined;
|
|
118
|
+
const widths: string[] = [];
|
|
119
|
+
// Gather the tree's width-bearing nodes: always-open core panels and the reaction
|
|
120
|
+
// SURFACES (view → panel width).
|
|
121
|
+
const surfaces = new Map<string, string | undefined>();
|
|
122
|
+
const walk = (node: unknown): void => {
|
|
123
|
+
if (Array.isArray(node)) {
|
|
124
|
+
for (const c of node) walk(c);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (!node || typeof node !== "object") return;
|
|
128
|
+
const n = node as Record<string, unknown> & { appWidth?: string; type?: string; select?: { where?: { field?: string; eq?: unknown } } };
|
|
129
|
+
if (n !== r) {
|
|
130
|
+
if (n.type === "ComponentSlot") {
|
|
131
|
+
const claim = n.select?.where;
|
|
132
|
+
if (claim?.field === "defaultState" && typeof claim.eq === "string") surfaces.set(claim.eq, rw(n.appWidth));
|
|
133
|
+
} else if (n.appWidth !== undefined) {
|
|
134
|
+
const w = rw(n.appWidth);
|
|
135
|
+
if (w) widths.push(w); // a plain panel (the core column) is ALWAYS open
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const v of Object.values(n)) if (v && typeof v === "object") walk(v);
|
|
139
|
+
};
|
|
140
|
+
walk(r);
|
|
141
|
+
// ONE STATE AT A TIME (docs/design/04): the app = core + the ACTIVE state's panel —
|
|
142
|
+
// the most recently surfaced view. Panel combinations cannot exist, so the width is
|
|
143
|
+
// always one of a small known set, bounded by construction.
|
|
144
|
+
let active = "";
|
|
145
|
+
let activeAt = -1;
|
|
146
|
+
for (const turn of store.getResponses())
|
|
147
|
+
for (const p of turn.components) {
|
|
148
|
+
const { chatId, nodeId } = store.split(p);
|
|
149
|
+
const view = (store.get(chatId, nodeId) as Record<string, unknown>)?.defaultState;
|
|
150
|
+
if (typeof view === "string" && surfaces.has(view) && store.touchOf(p) > activeAt) {
|
|
151
|
+
activeAt = store.touchOf(p);
|
|
152
|
+
active = view;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const activePanel = active ? surfaces.get(active) : undefined;
|
|
156
|
+
if (activePanel) widths.push(activePanel);
|
|
157
|
+
if (!widths.length) return undefined;
|
|
158
|
+
return widths.length === 1 ? widths[0] : `calc(${widths.join(" + ")})`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* TEMPLATE LAYOUTS — name-sync (docs/design/05). A template with `layouts` is a set
|
|
163
|
+
* of full arrangements, the template-tier twin of a component's faces: the ACTIVE
|
|
164
|
+
* layout is the one whose NAME matches the latest surfaced component view, falling
|
|
165
|
+
* back to `defaultLayout` when nothing (or nothing matching) is surfaced. Same
|
|
166
|
+
* derivation as the active state — read off the store, never stored. Single-layout
|
|
167
|
+
* templates (`layouts` absent) resolve to `root`: the classic behavior, untouched.
|
|
168
|
+
*/
|
|
169
|
+
export function resolveActiveLayout(def: UnoverseDefinition | null | undefined, store: ComponentStore): UnoverseNode | undefined {
|
|
170
|
+
if (!def) return undefined;
|
|
171
|
+
const layouts = def.layouts;
|
|
172
|
+
if (!layouts) return def.root;
|
|
173
|
+
const { view } = resolveActiveView(def, store, new Set(Object.keys(layouts)));
|
|
174
|
+
return layouts[view] ?? layouts[def.defaultLayout ?? "main"] ?? def.root;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* THE ACTIVE VIEW — the ONE derivation every consumer shares (docs/design/04 + the
|
|
179
|
+
* guest-control law). The latest surfaced component view among `validViews`, with
|
|
180
|
+
* PINNED enforcement: while a pinned layout's view is surfaced, a background arrival
|
|
181
|
+
* cannot displace it — only a guest write anywhere can. Layout choice and surface
|
|
182
|
+
* gating MUST both read this function: two loops that each pick "the latest view"
|
|
183
|
+
* disagree the moment pin overrides one of them — the layout renders view A while
|
|
184
|
+
* every surface inside it gates against view B, and the whole panel goes blank.
|
|
185
|
+
*/
|
|
186
|
+
export function resolveActiveView(
|
|
187
|
+
def: UnoverseDefinition | null | undefined,
|
|
188
|
+
store: ComponentStore,
|
|
189
|
+
validViews: Set<string>,
|
|
190
|
+
): { view: string; byGuest: boolean } {
|
|
191
|
+
let view = "";
|
|
192
|
+
let at = -1;
|
|
193
|
+
let byGuest = false;
|
|
194
|
+
let pinnedView = "";
|
|
195
|
+
let pinnedAt = -1;
|
|
196
|
+
for (const turn of store.getResponses())
|
|
197
|
+
for (const p of turn.components) {
|
|
198
|
+
const { chatId, nodeId } = store.split(p);
|
|
199
|
+
const v = (store.get(chatId, nodeId) as Record<string, unknown>)?.defaultState;
|
|
200
|
+
if (typeof v !== "string" || !validViews.has(v)) continue;
|
|
201
|
+
const t = store.touchOf(p);
|
|
202
|
+
if (t > at) {
|
|
203
|
+
at = t;
|
|
204
|
+
view = v;
|
|
205
|
+
byGuest = store.interactionTouchOf(p) >= t;
|
|
206
|
+
}
|
|
207
|
+
if ((def?.layouts?.[v] as { pinned?: boolean } | undefined)?.pinned && t > pinnedAt) {
|
|
208
|
+
pinnedAt = t;
|
|
209
|
+
pinnedView = v;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (pinnedView && view !== pinnedView && !byGuest) return { view: pinnedView, byGuest: false };
|
|
213
|
+
return { view, byGuest };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Resolve a ComponentSlot's `select` against the store → ordered pointers. Exported for testing. */
|
|
217
|
+
export function selectPointers(store: ComponentStore, node: UnoverseNode): string[] {
|
|
218
|
+
const sel = node.select ?? {};
|
|
219
|
+
let pointers =
|
|
220
|
+
sel.from === "all"
|
|
221
|
+
? store.getResponses().flatMap((r) => r.components)
|
|
222
|
+
: (store.latestResponse()?.components ?? []);
|
|
223
|
+
|
|
224
|
+
// `where` — select by COMPONENT STATE (the reaction contract, STATE_MODEL §5b): any
|
|
225
|
+
// component whose own slice matches, ordered newest state-write first so `limit: 1`
|
|
226
|
+
// = "most recent wins". The generic replacement for type-pinned surfaces — a
|
|
227
|
+
// template reacts to whatever component enters the state, never to a named type.
|
|
228
|
+
if (sel.where) {
|
|
229
|
+
const { field, eq, ne } = sel.where;
|
|
230
|
+
pointers = pointers.filter((p) => {
|
|
231
|
+
const { chatId, nodeId } = store.split(p);
|
|
232
|
+
const v = (store.get(chatId, nodeId) as Record<string, unknown>)[field];
|
|
233
|
+
return ne !== undefined ? v !== ne : v === eq;
|
|
234
|
+
});
|
|
235
|
+
pointers = [...pointers].sort((a, b) => store.touchOf(b) - store.touchOf(a));
|
|
236
|
+
}
|
|
237
|
+
if (sel.limit != null) pointers = pointers.slice(0, sel.limit);
|
|
238
|
+
return pointers;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme: themeProp, props, isolate = true, voice: voiceConfig }: StreamedUnoverseTemplateProps) {
|
|
242
|
+
const [def, setDef] = useState<UnoverseDefinition | null>(null);
|
|
243
|
+
const [error, setError] = useState<string | null>(null);
|
|
244
|
+
// The voice native service lives HERE (shared), not in each host. Called every render
|
|
245
|
+
// (rules of hooks); inert until a `startCall` only a voice template has a button for.
|
|
246
|
+
// The audio WS lane travels WITH the SDK: derive it from the client's own server origin
|
|
247
|
+
// (same server that hosts /mcp + /stream + /ws/gravity), NEVER from the host — a host's
|
|
248
|
+
// `location.origin` is "null" in a sandboxed srcdoc iframe. Overrides any host wsUrl.
|
|
249
|
+
const voice = useVoiceService(
|
|
250
|
+
voiceConfig ? { ...voiceConfig, wsUrl: client.serverOrigin.replace(/^http/, "ws") } : INERT_VOICE,
|
|
251
|
+
);
|
|
252
|
+
// No theme passed → fetch from the server (the SDK owns zero token values). The
|
|
253
|
+
// theme REF comes from the definition itself: a template belongs to a client org,
|
|
254
|
+
// and that org names its theme — `<org>/light`. Wait for the def before fetching
|
|
255
|
+
// (null = no fetch) so the wrong theme never flashes.
|
|
256
|
+
const fetched = useUnoverseTheme(client, themeProp ? null : def ? (def.org ? `${def.org}/light` : "light") : null);
|
|
257
|
+
const theme = themeProp ?? fetched;
|
|
258
|
+
// Register the theme's webfonts (served DATA) at document level — see ensureFontStylesheets.
|
|
259
|
+
useEffect(() => ensureFontStylesheets(theme), [theme]);
|
|
260
|
+
|
|
261
|
+
// Re-render on STRUCTURE changes only (turns placed/completed, template state,
|
|
262
|
+
// lifecycle). COMPONENT_DATA merges don't move the timeline — the leaves subscribe
|
|
263
|
+
// to their own slices — so streaming chunks never re-walk the whole template tree.
|
|
264
|
+
// Stable subscribe fn: an inline arrow would tear down + re-create the subscription
|
|
265
|
+
// every render.
|
|
266
|
+
useSyncExternalStore(
|
|
267
|
+
useCallback((cb) => store.subscribe(cb), [store]),
|
|
268
|
+
useCallback(() => store.getStructureVersion(), [store]),
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
let alive = true;
|
|
273
|
+
setDef(null);
|
|
274
|
+
setError(null);
|
|
275
|
+
client
|
|
276
|
+
.readDefinition(uri)
|
|
277
|
+
.then((d) => alive && setDef(d))
|
|
278
|
+
.catch((e) => alive && setError(String(e?.message ?? e)));
|
|
279
|
+
return () => {
|
|
280
|
+
alive = false;
|
|
281
|
+
};
|
|
282
|
+
}, [client, uri]);
|
|
283
|
+
|
|
284
|
+
// NAME-SYNC: the tree this template presents — the layout matching the latest
|
|
285
|
+
// surfaced component view (multi-layout templates), else the classic root. The
|
|
286
|
+
// resolved node is a stable per-def object, so downstream memos key off it.
|
|
287
|
+
const activeRoot = def ? resolveActiveLayout(def, store) : undefined;
|
|
288
|
+
|
|
289
|
+
// The set of VIEWS this template's reaction surfaces claim — computed per active
|
|
290
|
+
// layout: layout NAMES claim their view (name-sync) alongside the surfaces inside.
|
|
291
|
+
// The timeline uses it to keep a claimed instance out of the flow (one instance → one
|
|
292
|
+
// placeholder).
|
|
293
|
+
const surfacedViews = useMemo(() => {
|
|
294
|
+
const views = activeRoot ? collectSurfacedViews(activeRoot) : new Set<string>();
|
|
295
|
+
for (const name of Object.keys(def?.layouts ?? {})) views.add(name);
|
|
296
|
+
return views;
|
|
297
|
+
}, [activeRoot, def]);
|
|
298
|
+
|
|
299
|
+
// Loading/error hints carry NO styling — the SDK owns zero styles.
|
|
300
|
+
if (error) return <div>Unoverse error: {error}</div>;
|
|
301
|
+
if (!def || !theme) return <div>Loading {uri}…</div>;
|
|
302
|
+
|
|
303
|
+
// ONE action interpreter for the whole tree: template chrome runs the same
|
|
304
|
+
// `dispatchAction` (core/actions.ts) every leaf runs — `setTemplateValue` writes
|
|
305
|
+
// template state, `then` chains, anything else routes to the channel. Template
|
|
306
|
+
// chrome has NO component slice, so `setValue` here is an authoring error (it
|
|
307
|
+
// belongs on the component); the empty ids make that write inert.
|
|
308
|
+
const dispatch: ActionHandler = (action, data, meta) => {
|
|
309
|
+
// VOICE NATIVE-SERVICE actions are answered by the shared service (§2e-1) — never
|
|
310
|
+
// forwarded to the host/workflow. This is the single wiring point that makes every
|
|
311
|
+
// host behave identically; audio rides the /ws/gravity lane, not the component stream.
|
|
312
|
+
if (typeof action === "string") {
|
|
313
|
+
if (action === "startCall") return void voice.startCall();
|
|
314
|
+
if (action === "endCall") return void voice.endCall();
|
|
315
|
+
if (action === "toggleMute") return void voice.toggleMute();
|
|
316
|
+
// "back to chat" leaves the voice surface — tear the call down first, then let the
|
|
317
|
+
// host handle any navigation it owns (falls through).
|
|
318
|
+
if (action === "backToChat") voice.endCall();
|
|
319
|
+
}
|
|
320
|
+
// GUEST CLOSE from TEMPLATE CHROME: a ✕ on the layout itself (a rail/panel header —
|
|
321
|
+
// not inside a single card) fires the CloseButton atom's `setValue defaultState:"inline"`.
|
|
322
|
+
// Template chrome has no card slice to write, so that write would be inert; instead it
|
|
323
|
+
// means "un-surface the current view" → the store retracts every surface to base (the
|
|
324
|
+
// same reset a new turn runs). A card that closes ITSELF keeps using the plain component
|
|
325
|
+
// write (real ids), untouched. `then` still chains via dispatchAction below.
|
|
326
|
+
if (
|
|
327
|
+
action && typeof action === "object" &&
|
|
328
|
+
(action as { type?: string }).type === "setValue" &&
|
|
329
|
+
(action as { values?: { key?: string; value?: unknown }[] }).values?.some(
|
|
330
|
+
(v) => v.key === "defaultState" && v.value === "inline",
|
|
331
|
+
)
|
|
332
|
+
) {
|
|
333
|
+
store.closeSurfaces();
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
dispatchAction(action as Parameters<typeof dispatchAction>[0], (data ?? {}) as Record<string, unknown>, {
|
|
337
|
+
store,
|
|
338
|
+
chatId: "",
|
|
339
|
+
nodeId: "",
|
|
340
|
+
sendToServer: onAction ? (t, payload) => onAction(t, payload, meta) : undefined,
|
|
341
|
+
});
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const leaf = (pointer: string, extraData?: Record<string, unknown>, fill?: boolean) => {
|
|
345
|
+
const { chatId, nodeId } = store.split(pointer);
|
|
346
|
+
return <StreamedUnoverseComponent key={pointer} client={client} store={store} chatId={chatId} nodeId={nodeId} onAction={dispatch} theme={theme} extraData={extraData} fill={fill} />;
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
// THE ACTIVE STATE (docs/design/04): a template is in exactly ONE state at a time —
|
|
350
|
+
// the most recently surfaced view ("" = the conversation/welcome base). DERIVED off
|
|
351
|
+
// the store, never stored. Exactly one reaction surface renders: the active state's.
|
|
352
|
+
//
|
|
353
|
+
// PINNED LAYOUTS — the guest-control law: a layout authored `"pinned": true` changes
|
|
354
|
+
// ONLY by the guest's hand. While a pinned view is surfaced, background arrivals
|
|
355
|
+
// (cards from later searches, streamed renders) cannot displace it (observed live:
|
|
356
|
+
// the panel flapped between a card rail and the composing page). It yields to a
|
|
357
|
+
// GUEST write anywhere (tap-to-focus, its own ✕), to its component leaving the view,
|
|
358
|
+
// or to a template swap. Authored in the layout file — the SDK only enforces it.
|
|
359
|
+
// SAME derivation as resolveActiveLayout (resolveActiveView) — the layout on screen
|
|
360
|
+
// and the state its surfaces gate against must NEVER come from two different loops.
|
|
361
|
+
const surfacedView = resolveActiveView(def, store, surfacedViews).view;
|
|
362
|
+
|
|
363
|
+
// ComponentSlot — filter the timeline to matching component pointers (KeyService).
|
|
364
|
+
const resolveSlot = (node: UnoverseNode, key?: React.Key): ReactNode => {
|
|
365
|
+
// ONE STATE AT A TIME: a reaction surface renders ONLY while its view IS the
|
|
366
|
+
// template's active state. Everything else is not part of this interface — its
|
|
367
|
+
// data is untouched and re-presents the moment its state is active again.
|
|
368
|
+
// Surfaces never stack; panel combinations cannot exist.
|
|
369
|
+
const claim = node.select?.where;
|
|
370
|
+
if (claim?.field === "defaultState" && typeof claim.eq === "string" && claim.eq !== surfacedView) {
|
|
371
|
+
return node.fallback ? renderNode(node.fallback, {}, dispatch, theme, key ?? "fallback") : null;
|
|
372
|
+
}
|
|
373
|
+
const pointers = selectPointers(store, node);
|
|
374
|
+
if (pointers.length === 0) {
|
|
375
|
+
return node.fallback ? renderNode(node.fallback, {}, dispatch, theme, key ?? "fallback") : null;
|
|
376
|
+
}
|
|
377
|
+
// `frame` — chrome that exists ONLY because the slot matched (fallback's twin).
|
|
378
|
+
// The frame subtree renders with a slot resolver that serves the outer slot's
|
|
379
|
+
// matches to any bare nested ComponentSlot — so a focus overlay is BORN when a
|
|
380
|
+
// component enters the state and DIES when it leaves (§5b: derived, not stored).
|
|
381
|
+
if (node.frame) {
|
|
382
|
+
// A SURFACE'S SINGLE OCCUPANT FILLS THE SURFACE (docs/design/05): a limit-1
|
|
383
|
+
// surface (detail panel, focus overlay) gives its occupant the frame's full
|
|
384
|
+
// height — the component's `height: "full"` finally has a definite container,
|
|
385
|
+
// with zero per-face styling. Multi-occupant surfaces (a rail) stay content-sized.
|
|
386
|
+
const fill = node.select?.limit === 1;
|
|
387
|
+
const innerSlot: SlotResolver = () => pointers.map((p) => leaf(p, undefined, fill));
|
|
388
|
+
// A panel declares its width ONCE (docs/design/05): `appWidth` grows the app
|
|
389
|
+
// (computeAppWidth) AND sizes the frame box here — the two can never disagree.
|
|
390
|
+
// Named sizes resolve via theme.appSize like any token; unresolvable → no width.
|
|
391
|
+
// `flex: 0 0 auto` — a panel is its declared width ALWAYS. A container that
|
|
392
|
+
// can't fit the sum clips at the edge; panels never compress into garbage.
|
|
393
|
+
const declared = (node as { appWidth?: string }).appWidth;
|
|
394
|
+
const appWidth = cssWidth(typeof declared === "string" ? (theme.appSize?.[declared] ?? declared) : undefined);
|
|
395
|
+
const frame = appWidth
|
|
396
|
+
? ({ ...node.frame, style: { ...(node.frame.style ?? {}), width: appWidth, flex: "0 0 auto" } } as UnoverseNode)
|
|
397
|
+
: node.frame;
|
|
398
|
+
return renderNode(frame, store.getTemplateState() as Record<string, unknown>, dispatch, theme, key ?? "frame", innerSlot);
|
|
399
|
+
}
|
|
400
|
+
return pointers.map((p) => leaf(p));
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
// Timeline — GENERIC iterator. Walks the timeline and renders the `user` / `assistant`
|
|
404
|
+
// DATA sub-tree per turn. Zero chat UX in the SDK: bubbles, alignment, structure all live
|
|
405
|
+
// in those sub-trees (rx/templates/chatlayout/*.json). The assistant sub-tree's ComponentSlot
|
|
406
|
+
// is scoped to THAT turn's components.
|
|
407
|
+
const resolveTimeline = (node: UnoverseNode, key?: React.Key): ReactNode => {
|
|
408
|
+
return (
|
|
409
|
+
<div key={key} style={styleToCss(node.style, theme)}>
|
|
410
|
+
{store.getTimeline().map((turn) => {
|
|
411
|
+
const sub = turn.role === "user" ? node.user : node.assistant;
|
|
412
|
+
if (!sub) return null;
|
|
413
|
+
// One instance → one placeholder (STATE_MODEL §5b). An instance whose active
|
|
414
|
+
// VIEW a reaction surface is showing is CLAIMED — the flow (the `inline`
|
|
415
|
+
// placeholder) must not render it too, or the same card paints twice. Instances
|
|
416
|
+
// in a view no surface claims stay here (the fallback). This is what makes "a
|
|
417
|
+
// component lifts into its surface" true instead of double-rendering it inline.
|
|
418
|
+
const shown =
|
|
419
|
+
turn.role === "assistant"
|
|
420
|
+
? turn.components.filter((p) => {
|
|
421
|
+
const { chatId, nodeId } = store.split(p);
|
|
422
|
+
const view = (store.get(chatId, nodeId) as Record<string, unknown> | undefined)?.defaultState;
|
|
423
|
+
return typeof view !== "string" || !surfacedViews.has(view);
|
|
424
|
+
})
|
|
425
|
+
: [];
|
|
426
|
+
// Slot scoped to this turn's UNCLAIMED components (assistant turns only).
|
|
427
|
+
const turnSlot: SlotResolver = (slotNode, k) => {
|
|
428
|
+
let ptrs = [...shown];
|
|
429
|
+
if (slotNode.select?.limit != null) ptrs = ptrs.slice(0, slotNode.select.limit);
|
|
430
|
+
if (ptrs.length === 0) return slotNode.fallback ? renderNode(slotNode.fallback, {}, dispatch, theme, k ?? "fb") : null;
|
|
431
|
+
// Pass the turn's streaming state into each component's scope so a streamed
|
|
432
|
+
// component (e.g. StreamingText) can stop its own loading dots when complete.
|
|
433
|
+
const streaming = turn.role === "assistant" && turn.streamingState === "streaming";
|
|
434
|
+
return ptrs.map((p) => leaf(p, { streaming }));
|
|
435
|
+
};
|
|
436
|
+
// Data scope per turn = author's static data (avatarUrl, thinkingText…)
|
|
437
|
+
// + NEUTRAL projections of the timeline model. The SDK exposes facts, never
|
|
438
|
+
// UX policy: `text` (user message), `streaming` (the turn's streamingState),
|
|
439
|
+
// `empty` (no components yet). It does NOT decide "show a thinking indicator" —
|
|
440
|
+
// the definition composes that from `streaming` + `empty` (nested visibleWhen =
|
|
441
|
+
// AND). Static author data is forwarded verbatim; the SDK interprets none of it.
|
|
442
|
+
const time = formatRelative(turn.createdAt);
|
|
443
|
+
const data =
|
|
444
|
+
turn.role === "user"
|
|
445
|
+
? { ...(node.userData ?? {}), text: turn.text, time }
|
|
446
|
+
: {
|
|
447
|
+
...(node.assistantData ?? {}),
|
|
448
|
+
time,
|
|
449
|
+
streaming: turn.streamingState === "streaming",
|
|
450
|
+
// `empty`/`active` count only UNCLAIMED components: a turn whose sole card
|
|
451
|
+
// lifted into a surface goes inactive (the definition hides it) rather than
|
|
452
|
+
// leaving an empty avatar behind; a still-streaming turn stays active.
|
|
453
|
+
empty: shown.length === 0,
|
|
454
|
+
// `active` = in progress OR has visible content. A completed-empty turn is
|
|
455
|
+
// inactive → the definition hides it (legacy ChatHistoryItem null-returns).
|
|
456
|
+
active: turn.streamingState === "streaming" || shown.length > 0,
|
|
457
|
+
};
|
|
458
|
+
return renderNode(sub, data, dispatch, theme, turn.id, turnSlot);
|
|
459
|
+
})}
|
|
460
|
+
</div>
|
|
461
|
+
);
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
// One resolver for all store-backed template primitives.
|
|
465
|
+
const resolve: SlotResolver = (node, key) =>
|
|
466
|
+
node.type === "Timeline" ? resolveTimeline(node, key) : resolveSlot(node, key);
|
|
467
|
+
|
|
468
|
+
// Root data scope = the author's declared prop defaults (logoUrl, brandName, suggestions…)
|
|
469
|
+
// + channel overrides + NEUTRAL conversation-level facts derived from the store (isEmpty/
|
|
470
|
+
// hasMessages). This is what lets the page AROUND the message list (header, welcome screen,
|
|
471
|
+
// suggestion cards) bind + show/hide — the whole-template counterpart of the per-turn scope
|
|
472
|
+
// Timeline injects. The SDK adds no UX policy: just author content + model projections.
|
|
473
|
+
const isEmpty = store.getTimeline().length === 0;
|
|
474
|
+
const rootData: Record<string, unknown> = {
|
|
475
|
+
...propDefaults(def.props),
|
|
476
|
+
...((def as { state?: Record<string, unknown> }).state ?? {}),
|
|
477
|
+
...(props ?? {}),
|
|
478
|
+
// The active template's OWN state — one generic bag of the DEV's chosen keys (draft,
|
|
479
|
+
// openPanel, suggestions data, focusMode, voice call state, …). No per-feature
|
|
480
|
+
// projections; the engine knows no key names (UNOVERSE_STATE_MODEL §2).
|
|
481
|
+
...store.getTemplateState(),
|
|
482
|
+
// Conversation-derived facts — READ OFF the timeline / lifecycle, never stored.
|
|
483
|
+
isEmpty,
|
|
484
|
+
hasMessages: !isEmpty,
|
|
485
|
+
surfacedView,
|
|
486
|
+
lifecycle: store.getLifecycle(),
|
|
487
|
+
isThinking: store.getLifecycle() === "thinking",
|
|
488
|
+
isStreaming: store.getLifecycle() === "thinking" || store.getLifecycle() === "streaming",
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
// Inject the served keyframes ONCE for this render root so any `style.animation` in a
|
|
492
|
+
// definition resolves (the SDK animates generically — no per-animation primitive).
|
|
493
|
+
// The wrapper applies the SERVED base render settings (theme.root — font-smoothing, etc.):
|
|
494
|
+
// `display: contents` keeps it out of layout while its inherited props cascade to the tree.
|
|
495
|
+
// The SDK authors only `display: contents` (structure); every value comes from theme.root.
|
|
496
|
+
// Isolated → theme.root on the shadow-root container (reliable cascade); else → display:contents.
|
|
497
|
+
const keyframes = <style>{keyframesCss(theme)}</style>;
|
|
498
|
+
// A layout root never carries `appWidth` (guard-enforced) — panels inside it do.
|
|
499
|
+
const rootNode = (activeRoot ?? def.root) as UnoverseNode;
|
|
500
|
+
const tree = renderNode(rootNode, rootData, dispatch, theme, undefined, resolve);
|
|
501
|
+
// A template is a SURFACE: its render-root fills the box the host gives it, so a root
|
|
502
|
+
// that declares `height: "full"` (DATA) can resolve. This only fills when the host's box
|
|
503
|
+
// is definite-height; when the host sizes to content the 100% resolves to content — so a
|
|
504
|
+
// content-sized app (fluidHeight:false) still wraps its component. The SDK adds no layout
|
|
505
|
+
// policy beyond "a template fills its container"; the template DATA owns everything else.
|
|
506
|
+
const surfaceRoot = { ...(theme.root as CSSProperties), height: "100%" };
|
|
507
|
+
if (isolate) {
|
|
508
|
+
return (
|
|
509
|
+
<IsolatedRoot rootStyle={surfaceRoot}>
|
|
510
|
+
{keyframes}
|
|
511
|
+
{tree}
|
|
512
|
+
</IsolatedRoot>
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
return (
|
|
516
|
+
<>
|
|
517
|
+
{keyframes}
|
|
518
|
+
<div style={{ display: "contents", ...surfaceRoot }}>{tree}</div>
|
|
519
|
+
</>
|
|
520
|
+
);
|
|
521
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useUnoverseTheme — fetch the RESOLVED theme from the MCP server by name.
|
|
3
|
+
*
|
|
4
|
+
* The SDK owns ZERO token values. The server owns the visual (apps/unoverse/
|
|
5
|
+
* rx/styles, resolved live) and serves it at `unoverse://theme/{name}`; this hook
|
|
6
|
+
* fetches it (cached in the client). A brand change in rx/styles is refresh-only
|
|
7
|
+
* — there is no baked theme in this bundle to rebuild (UNOVERSE_SPEC §2d-1).
|
|
8
|
+
*/
|
|
9
|
+
import { useEffect, useState } from "react";
|
|
10
|
+
import type { ResolvedTheme, UnoverseClient } from "./core";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Ensure the theme's webfont stylesheets are loaded ONCE in the document head.
|
|
14
|
+
* `theme.fonts` is SERVED DATA (`fonts.stylesheets` in an org's styles) — the SDK
|
|
15
|
+
* authors no font choices, it just registers what the server serves. Fonts must live
|
|
16
|
+
* at document level: a shadow root cannot register @font-face, but faces declared in
|
|
17
|
+
* the document apply inside shadow trees via the font cascade.
|
|
18
|
+
*/
|
|
19
|
+
export function ensureFontStylesheets(theme: ResolvedTheme | null): void {
|
|
20
|
+
if (!theme?.fonts?.length || typeof document === "undefined") return;
|
|
21
|
+
for (const href of theme.fonts) {
|
|
22
|
+
if (document.head.querySelector(`link[data-unoverse-font="${CSS.escape(href)}"]`)) continue;
|
|
23
|
+
const link = document.createElement("link");
|
|
24
|
+
link.rel = "stylesheet";
|
|
25
|
+
link.href = href;
|
|
26
|
+
link.setAttribute("data-unoverse-font", href);
|
|
27
|
+
document.head.appendChild(link);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Returns the resolved theme (null until the first fetch resolves).
|
|
32
|
+
* `name: null` = don't fetch yet (the caller is still deriving the name — e.g. waiting
|
|
33
|
+
* for the definition whose `org` names its pack's theme). An unknown name falls back to
|
|
34
|
+
* the neutral `light` so a pack without a theme still renders. */
|
|
35
|
+
export function useUnoverseTheme(client: UnoverseClient, name: string | null = "light"): ResolvedTheme | null {
|
|
36
|
+
const [theme, setTheme] = useState<ResolvedTheme | null>(null);
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!name) return;
|
|
39
|
+
let alive = true;
|
|
40
|
+
client
|
|
41
|
+
.readTheme(name)
|
|
42
|
+
.then((t) => alive && setTheme(t))
|
|
43
|
+
.catch(() => {
|
|
44
|
+
if (!alive || name === "light") return;
|
|
45
|
+
client
|
|
46
|
+
.readTheme("light")
|
|
47
|
+
.then((t) => alive && setTheme(t))
|
|
48
|
+
.catch(() => {});
|
|
49
|
+
});
|
|
50
|
+
return () => {
|
|
51
|
+
alive = false;
|
|
52
|
+
};
|
|
53
|
+
}, [client, name]);
|
|
54
|
+
return theme;
|
|
55
|
+
}
|