@unoverse-platform/studio 0.1.4 → 0.1.6

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.
@@ -1,19 +1,54 @@
1
1
  /**
2
2
  * <UnoverseComponent> — reads a definition by URI from the Unoverse MCP server
3
- * (via ./core) and renders it natively with the given data.
3
+ * (via ./core) and renders it natively.
4
+ *
5
+ * ONE COMPONENT, TWO DATA SOURCES. A definition cannot tell which is rendering it, so the
6
+ * SDK stopped pretending they were different things:
7
+ *
8
+ * data source give it state lives in
9
+ * a plain object `uri` + `data` a local slice, here
10
+ * the live stream `store` + `chatId` + `nodeId` the store's slice
11
+ *
12
+ * The split used to be two exported components, and a host had to know which one it was
13
+ * holding. It never mattered to the component: the same `renderNode` walks the same
14
+ * definition, and `setValue` writes the same instance either way.
4
15
  */
5
- import { useEffect, useMemo, useState, type CSSProperties } from "react";
6
- import type { ResolvedTheme, UnoverseClient, UnoverseDefinition } from "./core";
16
+ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore, type CSSProperties } from "react";
17
+ import { dispatchAction, type ComponentStore, type ResolvedTheme, type UnoverseClient, type UnoverseDefinition } from "./core";
7
18
  import { renderNode, keyframesCss, type ActionHandler } from "./render";
8
19
  import { useUnoverseTheme, ensureFontStylesheets } from "./theme";
9
20
  import { IsolatedRoot } from "./isolate";
10
21
 
22
+ /** Subscribe to an instance in the store, re-rendering when a merge lands. Snapshots THIS
23
+ * instance's slice (merge() gives it a fresh identity), so chunks for other components bail
24
+ * out in React instead of re-rendering everyone. Tolerates no store: the static path calls
25
+ * the same hook, because hooks cannot be conditional. */
26
+ export function useUnoverseInstance(store?: ComponentStore, chatId = "", nodeId = "") {
27
+ const subscribe = useCallback((cb: () => void) => (store ? store.subscribe(cb) : () => {}), [store]);
28
+ const data = useSyncExternalStore(
29
+ subscribe,
30
+ useCallback(() => (store ? store.get(chatId, nodeId) : undefined), [store, chatId, nodeId]),
31
+ );
32
+ const type = useSyncExternalStore(
33
+ subscribe,
34
+ useCallback(() => (store ? store.getType(chatId, nodeId) : undefined), [store, chatId, nodeId]),
35
+ );
36
+ return { type, data };
37
+ }
38
+
11
39
  export interface UnoverseComponentProps {
12
40
  client: UnoverseClient;
13
- /** e.g. "unoverse://components/Card" */
14
- uri: string;
15
- /** instance data bound into the definition (the streamed props) */
41
+ /** STATIC source: the definition to render, e.g. "unoverse://components/Card". */
42
+ uri?: string;
43
+ /** STATIC source: instance data bound into the definition. */
16
44
  data?: Record<string, unknown>;
45
+ /** LIVE source: the merge-state store. With it, `uri` and `data` come from the instance. */
46
+ store?: ComponentStore;
47
+ chatId?: string;
48
+ nodeId?: string;
49
+ /** Neutral turn state merged into the data scope (e.g. `streaming`), so a component can
50
+ * reflect it. Live source only. */
51
+ extraData?: Record<string, unknown>;
17
52
  onAction?: ActionHandler;
18
53
  /**
19
54
  * Resolved theme. Optional — if omitted, the SDK FETCHES it from the server
@@ -36,9 +71,20 @@ export interface UnoverseComponentProps {
36
71
  fill?: boolean;
37
72
  }
38
73
 
39
- export function UnoverseComponent({ client, uri, data, onAction, theme, isolate = true, fill = false }: UnoverseComponentProps) {
74
+ export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, extraData, onAction, theme, isolate = true, fill = false }: UnoverseComponentProps) {
40
75
  const [def, setDef] = useState<UnoverseDefinition | null>(null);
41
76
  const [error, setError] = useState<string | null>(null);
77
+ // A component's OWN view state (a tab, a step, an expanded face) moves by writing its own
78
+ // slice. That write is not a host concern: a tab click should work wherever a component
79
+ // renders, without every surface wiring a handler for it. So the renderer keeps the slice
80
+ // and applies `setValue` to it, and STILL forwards every action to `onAction` for hosts
81
+ // that care (a channel persisting state, a workflow answering a submit).
82
+ const [localSlice, setLocalSlice] = useState<Record<string, unknown>>({});
83
+ // LIVE SOURCE. The hook runs either way (hooks cannot be conditional) and no-ops without a
84
+ // store, so one component serves both paths without a wrapper deciding for it.
85
+ const live = useUnoverseInstance(store, chatId, nodeId);
86
+ const resolvedUri = store ? (live.type ? `unoverse://components/${live.type}` : null) : (uri ?? null);
87
+ const sourceData = store ? (extraData ? { ...(live.data ?? {}), ...extraData } : live.data) : data;
42
88
  // No theme passed → fetch it from the server (the SDK owns zero token values). The
43
89
  // theme REF comes from the definition itself: a template's `org` names its client's
44
90
  // theme (`<org>/light`); a universal component previews under the default set
@@ -53,14 +99,16 @@ export function UnoverseComponent({ client, uri, data, onAction, theme, isolate
53
99
  let alive = true;
54
100
  setDef(null);
55
101
  setError(null);
102
+ setLocalSlice({});
103
+ if (!resolvedUri) return;
56
104
  client
57
- .readDefinition(uri)
105
+ .readDefinition(resolvedUri)
58
106
  .then((d) => alive && setDef(d))
59
107
  .catch((e) => alive && setError(String(e?.message ?? e)));
60
108
  return () => {
61
109
  alive = false;
62
110
  };
63
- }, [client, uri]);
111
+ }, [client, resolvedUri]);
64
112
 
65
113
  // Fill props the instance didn't provide from the definition's defaults; instance `data`
66
114
  // overrides. This is what lets a partially-emitted component still render its declared
@@ -77,20 +125,61 @@ export function UnoverseComponent({ client, uri, data, onAction, theme, isolate
77
125
  [def],
78
126
  );
79
127
 
128
+ // ONE ACTION RULE, TWO OWNERS OF THE SLICE. `setValue` writes THIS instance's keys; where
129
+ // they land is the only difference between the two sources, and the component never knows.
130
+ const handleAction = useMemo<ActionHandler>(
131
+ () =>
132
+ store
133
+ ? (action, scope) =>
134
+ dispatchAction(action, scope, {
135
+ store,
136
+ chatId: chatId ?? "",
137
+ nodeId: nodeId ?? "",
138
+ // Server actions carry the INSTANCE context (which component + its current
139
+ // slice) so the channel can forward the submitted state with the action.
140
+ sendToServer: onAction
141
+ ? (t, payload) =>
142
+ onAction(t, payload, {
143
+ chatId,
144
+ nodeId,
145
+ type: live.type,
146
+ state: store.get(chatId ?? "", nodeId ?? ""),
147
+ })
148
+ : undefined,
149
+ })
150
+ : (action, payload, meta) => {
151
+ // No store, so the slice is local. This is what makes a tab work in a preview,
152
+ // a content panel or an email-shaped host with no state code in between.
153
+ if (action && typeof action === "object" && (action as { type?: string }).type === "setValue") {
154
+ const values = ((action as { values?: { key: string; value: unknown }[] }).values ?? []).filter(
155
+ (v) => v && typeof v.key === "string",
156
+ );
157
+ if (values.length)
158
+ setLocalSlice((prev) => ({ ...prev, ...Object.fromEntries(values.map((v) => [v.key, v.value])) }));
159
+ }
160
+ onAction?.(action, payload, meta);
161
+ },
162
+ [store, chatId, nodeId, onAction, live.type],
163
+ );
164
+
80
165
  // Loading/error hints carry NO styling — the SDK owns zero styles (tokens or hints).
81
166
  if (error) return <div>Unoverse error: {error}</div>;
82
- if (!def || !activeTheme) return <div>Loading {uri}…</div>;
167
+ if (store && !live.type) return <div>waiting for COMPONENT_INIT…</div>;
168
+ if (!def || !activeTheme) return <div>Loading {resolvedUri ?? "component"}…</div>;
83
169
  // Internal state initial values (def.state) sit beneath the live slice — the
84
170
  // microapp's own keys (step/phase/…) start at their authored values and move only
85
171
  // when its actions write them. Props stay the EXTERNAL contract.
86
- const merged = { ...propDefaults, ...(def?.state ?? {}), ...(data ?? {}) };
172
+ // The local slice sits ON TOP: a value the component wrote itself is the current one.
173
+ const merged = store
174
+ ? { ...propDefaults, ...(def?.state ?? {}), ...(sourceData ?? {}) }
175
+ : { ...propDefaults, ...(def?.state ?? {}), ...(sourceData ?? {}), ...localSlice };
87
176
  // Inject the served keyframes + apply the SERVED base render-root (theme.root — DS typography
88
177
  // + font-smoothing). When ISOLATED, theme.root is applied to the shadow-root container inside
89
178
  // IsolatedRoot (a REAL box — reliable font cascade in the shadow tree). When NOT isolated the
90
179
  // host owns isolation, so apply it here on a `display:contents` wrapper (out of layout while
91
180
  // its inherited props cascade) — the design-system baseline either way.
92
181
  const keyframes = <style>{keyframesCss(activeTheme)}</style>;
93
- const tree = renderNode(def.root, merged, onAction, activeTheme);
182
+ const tree = renderNode(def.root, merged, handleAction, activeTheme);
94
183
  // The isolation container MIRRORS the component root's declared sizing intent (§4a:
95
184
  // the component owns its size; the host responds). A root that declares
96
185
  // `height: "full"` needs a definite chain — the container passes the host's height
@@ -53,6 +53,12 @@ export function dispatchAction(
53
53
  // Structure-bumping write (not the deferred COMPONENT_DATA path): templates re-resolve
54
54
  // their `select.where` slots the moment a component changes its own state (§5b).
55
55
  ctx.store.mergeComponentState(ctx.chatId, ctx.nodeId, patch);
56
+ // A CHANGED LAYOUT IS NEWS. `defaultState` is the view a component is showing, so a
57
+ // write to it means this instance just entered a different face — the moment an
58
+ // `onEnterView` hook exists for. Reported on the channel path every other server
59
+ // action already uses; the server decides whether any hook cares. Nothing to author:
60
+ // the setValue already carries the fact, so no component repeats it in a `then`.
61
+ if (ctx.nodeId && "defaultState" in patch) ctx.sendToServer?.("viewEntered", { layout: patch.defaultState });
56
62
  } else if (spec.type === "setTemplateValue") {
57
63
  // Write the dev's chosen keys into TEMPLATE state — the template-state twin of setValue.
58
64
  // No component scope. The dev builds disclosure / focus / etc. from a key THEY name
@@ -79,18 +79,46 @@ export class UnoverseClient {
79
79
  return this.connecting;
80
80
  }
81
81
 
82
+
83
+ /**
84
+ * A SERVER RESTART kills every session it was holding, and the client cannot know: it
85
+ * keeps a live-looking transport whose session id the server has never heard of, so every
86
+ * later call returns "No valid session" until the page is reloaded. One dropped session
87
+ * should not require a reload of every open tab.
88
+ *
89
+ * So a call that fails that way reconnects ONCE and runs again. Anything else rethrows,
90
+ * because a retry loop around a real error hides it.
91
+ */
92
+ private async withSession<T>(run: () => Promise<T>): Promise<T> {
93
+ await this.connect();
94
+ try {
95
+ return await run();
96
+ } catch (err) {
97
+ if (!/No valid session|session not found/i.test(String((err as Error)?.message ?? err))) throw err;
98
+ this.connected = false;
99
+ this.connecting = null;
100
+ // The SAME construction as the constructor, validator included: that validator is
101
+ // deliberate (a CSP that forbids `new Function` breaks the default one), and a
102
+ // reconnect that quietly dropped it would fail only on the strictest host.
103
+ this.client = new Client(
104
+ { name: "unoverse-core", version: "0.0.1" },
105
+ { jsonSchemaValidator: new CfWorkerJsonSchemaValidator() },
106
+ );
107
+ await this.connect();
108
+ return run();
109
+ }
110
+ }
111
+
82
112
  /** Generic tools/call passthrough (e.g. `user_action` component submissions). */
83
113
  async callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
84
- await this.connect();
85
- return this.client.callTool({ name, arguments: args });
114
+ return this.withSession(() => this.client.callTool({ name, arguments: args }));
86
115
  }
87
116
 
88
117
  /** resources/read a definition by URI (e.g. unoverse://components/Card). Cached. */
89
118
  async readDefinition(uri: string): Promise<UnoverseDefinition> {
90
119
  const cached = this.cache.get(uri);
91
120
  if (cached) return cached;
92
- await this.connect();
93
- const res = await this.client.readResource({ uri });
121
+ const res = await this.withSession(() => this.client.readResource({ uri }));
94
122
  const first = res.contents[0] as { text?: string };
95
123
  if (!first?.text) throw new Error(`No definition content for ${uri}`);
96
124
  const def = JSON.parse(first.text) as UnoverseDefinition;
@@ -105,8 +133,7 @@ export class UnoverseClient {
105
133
  async readTheme(name = "light"): Promise<ResolvedTheme> {
106
134
  const cached = this.themeCache.get(name);
107
135
  if (cached) return cached;
108
- await this.connect();
109
- const res = await this.client.readResource({ uri: `unoverse://theme/${name}` });
136
+ const res = await this.withSession(() => this.client.readResource({ uri: `unoverse://theme/${name}` }));
110
137
  const first = res.contents[0] as { text?: string };
111
138
  if (!first?.text) throw new Error(`No theme content for ${name}`);
112
139
  const theme = JSON.parse(first.text) as ResolvedTheme;
@@ -71,6 +71,11 @@ export interface UnoverseNode {
71
71
  userData?: Record<string, unknown>;
72
72
  /** Icon: the served glyph NAME (literal) — or bind a data field via `bind.name`. */
73
73
  icon?: string;
74
+ /** Icon: the same literal under the key a `Ref`'s `with` writes. An atom binds `name`, and
75
+ * `applyLiterals` replaces that bind with a literal of the SAME key — so a Ref-supplied
76
+ * glyph arrives as `name`, never as `icon`. Declared because the renderer reads it; it was
77
+ * read without being declared, which typechecked nowhere until Studio's config was fixed. */
78
+ name?: string;
74
79
  /** Disable the element (Input/Button) when this data field is truthy; the node's
75
80
  * `style.disabled` is merged while disabled. */
76
81
  disabledWhen?: string;
@@ -112,12 +112,30 @@ function nodeChrome(node: UnoverseNode, data: Record<string, unknown>, theme: Re
112
112
 
113
113
  /** `Box`/`Stack`/`Row`/`Column` — the generic container (`<div>`). Children are
114
114
  * pre-rendered by the dispatcher; layout/look come entirely from node.style. */
115
+ /**
116
+ * INSPECTABILITY: a node carrying `$id` stamps it on the element it renders.
117
+ *
118
+ * Nothing in the SDK reads it back and nothing looks different without it. It exists so a
119
+ * TOOL can ask "which element did this node produce" — an authoring surface boxing the node
120
+ * under the cursor, for instance. The renderer is the only place that can answer, because
121
+ * `Switch`, `Each` and `visibleWhen` mean the rendered tree is not the definition tree, so
122
+ * the link is made while rendering or not at all.
123
+ *
124
+ * `$id` is assigned by whoever supplies the definition, never by an author. This is a
125
+ * generic behaviour, not a style and not a UX shape: no primitive is added, no layout
126
+ * decision is taken, and a definition without `$id` renders byte-identically.
127
+ */
128
+ const inspectId = (node: UnoverseNode): string | undefined => {
129
+ const id = (node as { $id?: unknown }).$id;
130
+ return typeof id === "string" ? id : undefined;
131
+ };
132
+
115
133
  export function BoxView({ node, data, theme, children }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme; children?: ReactNode }): ReactNode {
116
134
  const { className, styleEls, style } = nodeChrome(node, data, theme);
117
135
  const scrollRef = useAutoScrollToEnd(node.autoScroll === true);
118
136
  const revealRef = useRevealWhenStuck(node.revealOnStick === true);
119
137
  return (
120
- <div ref={scrollRef ?? revealRef} className={className} style={style}>
138
+ <div ref={scrollRef ?? revealRef} className={className} style={style} data-unoverse-id={inspectId(node)}>
121
139
  {styleEls}
122
140
  {children}
123
141
  </div>
@@ -203,12 +221,12 @@ function useRevealWhenStuck(enabled: boolean) {
203
221
  export function TextView({ node, data, theme }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme }): ReactNode {
204
222
  // Literal `value` fallback: components are microapps — static copy is HARDCODED in the
205
223
  // def, props exist only for state (UNOVERSE_AUTHORING.md). bind wins when present.
206
- return <span style={styleToCss(node.style, theme, data)}>{bound(node.bind?.value, data) ?? (node.value as ReactNode)}</span>;
224
+ return <span style={styleToCss(node.style, theme, data)} data-unoverse-id={inspectId(node)}>{bound(node.bind?.value, data) ?? (node.value as ReactNode)}</span>;
207
225
  }
208
226
 
209
227
  /** `Image` — a bound OR literal src (`<img>`). */
210
228
  export function ImageView({ node, data, theme }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme }): ReactNode {
211
- return <img style={styleToCss(node.style, theme, data)} src={bound(node.bind?.src, data) ?? node.src} alt={bound(node.bind?.alt, data) ?? node.alt ?? ""} />;
229
+ return <img data-unoverse-id={inspectId(node)} style={styleToCss(node.style, theme, data)} src={bound(node.bind?.src, data) ?? node.src} alt={bound(node.bind?.alt, data) ?? node.alt ?? ""} />;
212
230
  }
213
231
 
214
232
  /** `Button` — fires `onAction(node.action, data)`. Content is a bound label OR composed
@@ -216,7 +234,7 @@ export function ImageView({ node, data, theme }: { node: UnoverseNode; data: Rec
216
234
  export function ButtonView({ node, data, theme, onAction, children }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme; onAction?: ActionHandler; children?: ReactNode }): ReactNode {
217
235
  const { className, styleEls, style, disabled } = nodeChrome(node, data, theme);
218
236
  return (
219
- <button className={className} style={style} disabled={disabled} onClick={() => onAction?.(node.action ?? "click", data)}>
237
+ <button className={className} style={style} disabled={disabled} data-unoverse-id={inspectId(node)} onClick={() => onAction?.(node.action ?? "click", data)}>
220
238
  {styleEls}
221
239
  {node.children?.length ? children : bound(node.bind?.label, data)}
222
240
  </button>
@@ -418,6 +436,17 @@ function buildMarkdownOverrides(theme: ResolvedTheme, inheritLinkColor = false)
418
436
  a: { component: ExternalLink },
419
437
  table: { component: ScrollableTable },
420
438
  img: { component: MarkdownImage },
439
+ // INLINE MARKS and BLOCKS. Authored copy uses all of these (a bolded access code, a
440
+ // rule between sections, a quoted term), and an element absent from this map renders
441
+ // at the browser's default — the one place a definition's look escapes the token
442
+ // layer. Each maps to a served recipe key; the VALUES live in rx/styles/semantic/prose.
443
+ strong: { props: { style: s("strong") } },
444
+ em: { props: { style: s("emphasis") } },
445
+ del: { props: { style: s("strikethrough") } },
446
+ code: { props: { style: s("code") } },
447
+ pre: { props: { style: s("codeBlock") } },
448
+ blockquote: { props: { style: s("blockquote") } },
449
+ hr: { props: { style: s("rule") } },
421
450
  th: { props: { style: { ...s("cell"), ...s("headerCell") } } },
422
451
  td: { props: { style: s("cell") } },
423
452
  ul: { props: { style: { ...s("list"), listStyleType: p.bulletStyle as string } } },
@@ -50,6 +50,8 @@ export { styleToCss, keyframesCss } from "./style";
50
50
  export interface ActionMeta {
51
51
  chatId?: string;
52
52
  nodeId?: string;
53
+ /** Which component this instance is — so the server needs no registry to find out. */
54
+ type?: string;
53
55
  state?: Record<string, unknown>;
54
56
  }
55
57
 
@@ -111,7 +113,12 @@ export function renderNode(
111
113
 
112
114
  // Store-backed / leaf primitives → dispatch to ./primitives.
113
115
  if (node.type === "Skeleton") return <SkeletonView key={key} variant={node.variant} theme={theme} />;
114
- if (node.type === "Icon") return <IconView key={key} name={node.bind?.name ? (data[node.bind.name] as string) : node.icon} style={node.style} theme={theme} />;
116
+ // Icon: DATA path reads `bind.name`; LITERAL path reads the `icon` attribute. `name` is
117
+ // read as a literal too, because an atom that binds `name` (the only spelling the data
118
+ // path reads) has that bind turned INTO a `name` attribute when a Ref supplies the glyph
119
+ // through `with` — so without this an atom can be data-driven or literal, never both.
120
+ if (node.type === "Icon")
121
+ return <IconView key={key} name={node.bind?.name ? (data[node.bind.name] as string) : (node.icon ?? node.name)} style={node.style} theme={theme} />;
115
122
  if (node.type === "Input") return <InputView key={key} node={node} data={data} theme={theme} onAction={onAction} />;
116
123
  if (node.type === "Markdown") return <MarkdownView key={key} node={node} data={data} theme={theme} />;
117
124
  if (node.type === "ComponentSlot" || node.type === "Timeline") return slot ? slot(node, key) : null;
@@ -136,9 +143,21 @@ export function renderNode(
136
143
  const src = node.bind?.items ? data[node.bind.items] : node.items;
137
144
  const items = Array.isArray(src) ? src : [];
138
145
  const tpl = node.template;
139
- const scopeOf = (item: unknown): Record<string, unknown> =>
140
- item != null && typeof item === "object" ? (item as Record<string, unknown>) : { value: item };
141
- const rendered = tpl ? items.map((item, i) => renderNode(tpl, scopeOf(item), onAction, theme, i, slot)) : null;
146
+ // WHERE AN ITEM SITS is something only the renderer knows, and a numbered list cannot
147
+ // be authored without it: the data carries no ordinal, and a template cannot count.
148
+ // `index` is 0-based for logic, `position` 1-based because that is what a reader sees.
149
+ // The item's own fields always win, so data that carries either name is untouched.
150
+ // AN ARRAY ITEM IS THE VALUE. `typeof [] === "object"`, so an array took the spread
151
+ // branch and its INDICES became scope keys ({0: "London", 1: "+44…"}) while `value` was
152
+ // never set. A nested Each bound to `value` — a table's row of cells — then found
153
+ // nothing, and every table rendered its headers over empty rows.
154
+ const scopeOf = (item: unknown, i: number): Record<string, unknown> => {
155
+ const base = { index: i, position: i + 1 };
156
+ return item != null && typeof item === "object" && !Array.isArray(item)
157
+ ? { ...base, ...(item as Record<string, unknown>) }
158
+ : { ...base, value: item };
159
+ };
160
+ const rendered = tpl ? items.map((item, i) => renderNode(tpl, scopeOf(item, i), onAction, theme, i, slot)) : null;
142
161
  return (
143
162
  <BoxView key={key} node={node} data={data} theme={theme}>
144
163
  {rendered}
@@ -1,27 +1,15 @@
1
1
  /**
2
- * Streaming binding: subscribe to the merge-state store and render the
3
- * component instance, re-rendering when COMPONENT_INIT/COMPONENT_DATA merge in.
2
+ * DEPRECATED ALIAS. `UnoverseComponent` now renders from either a plain `data` object or a
3
+ * live store, so the streamed variant is the same component with the store props filled in.
4
+ *
5
+ * Kept so existing channels keep working while their call sites move over. Nothing here
6
+ * decides anything: it forwards.
4
7
  */
5
- import { useCallback, useMemo, useSyncExternalStore } from "react";
6
- import { dispatchAction, type ComponentStore, type ResolvedTheme, type UnoverseClient } from "./core";
7
- import { UnoverseComponent } from "./UnoverseComponent";
8
+ import { UnoverseComponent, useUnoverseInstance } from "./UnoverseComponent";
9
+ import type { ComponentStore, ResolvedTheme, UnoverseClient } from "./core";
8
10
  import type { ActionHandler } from "./render";
9
11
 
10
- /** Subscribe to a component instance in the store (re-renders on merge).
11
- * Snapshots THIS instance's slice (merge() gives it a fresh identity per merge),
12
- * so chunks for other components bail out in React instead of re-rendering everyone. */
13
- export function useUnoverseInstance(store: ComponentStore, chatId: string, nodeId: string) {
14
- const subscribe = useCallback((cb: () => void) => store.subscribe(cb), [store]);
15
- const data = useSyncExternalStore(
16
- subscribe,
17
- useCallback(() => store.get(chatId, nodeId), [store, chatId, nodeId]),
18
- );
19
- const type = useSyncExternalStore(
20
- subscribe,
21
- useCallback(() => store.getType(chatId, nodeId), [store, chatId, nodeId]),
22
- );
23
- return { type, data };
24
- }
12
+ export { useUnoverseInstance };
25
13
 
26
14
  export interface StreamedUnoverseComponentProps {
27
15
  client: UnoverseClient;
@@ -30,36 +18,13 @@ export interface StreamedUnoverseComponentProps {
30
18
  nodeId: string;
31
19
  onAction?: ActionHandler;
32
20
  theme?: ResolvedTheme;
33
- /** Neutral turn state merged into the component's data scope (e.g. `streaming`), so a
34
- * component can reflect it — like legacy passing `streamingState` to StreamingText. */
21
+ /** Neutral turn state merged into the component's data scope (e.g. `streaming`). */
35
22
  extraData?: Record<string, unknown>;
36
23
  /** A surface's single occupant fills the surface — see UnoverseComponent.fill. */
37
24
  fill?: boolean;
38
25
  }
39
26
 
40
- /** Resolves `type` from the store → its definition → renders with merged data. */
41
- export function StreamedUnoverseComponent({ client, store, chatId, nodeId, onAction, theme, extraData, fill }: StreamedUnoverseComponentProps) {
42
- const { type, data } = useUnoverseInstance(store, chatId, nodeId);
43
- // Interpret the action envelope (§2e-3): `setValue` writes THIS instance's slice
44
- // locally (the ported `updateData`); any other type routes to the channel handler
45
- // (the workflow). `then` chains. The component never sees the store directly.
46
- const handleAction = useMemo<ActionHandler>(
47
- () => (action, scope) =>
48
- dispatchAction(action, scope, {
49
- store,
50
- chatId,
51
- nodeId,
52
- // Server actions carry the INSTANCE context (which component + its current slice)
53
- // so the channel can forward the submitted state — e.g. a wizard's collected
54
- // answers — with the action. Transport metadata only; nothing is interpreted here.
55
- sendToServer: onAction ? (t, payload) => onAction(t, payload, { chatId, nodeId, state: store.get(chatId, nodeId) }) : undefined,
56
- }),
57
- [store, chatId, nodeId, onAction],
58
- );
59
- if (!type) return <div>waiting for COMPONENT_INIT…</div>;
60
- // No focus/displayState injection: a component's inline/focused look is its OWN state,
61
- // written via the `setValue` action into this slice and read via `visibleWhen` — the SDK
62
- // hardcodes no focus concept (UNOVERSE_STATE_MODEL §2). It's just another data field.
63
- const merged = extraData ? { ...data, ...extraData } : data;
64
- return <UnoverseComponent client={client} uri={`unoverse://components/${type}`} data={merged} onAction={handleAction} theme={theme} fill={fill} />;
27
+ /** @deprecated Render `UnoverseComponent` with `store`, `chatId` and `nodeId`. */
28
+ export function StreamedUnoverseComponent(props: StreamedUnoverseComponentProps) {
29
+ return <UnoverseComponent {...props} />;
65
30
  }
@@ -7,7 +7,7 @@
7
7
  * - reads the template recipe by URI (resources/read, via the client),
8
8
  * - subscribes to the store so it re-renders as components stream in,
9
9
  * - resolves each ComponentSlot against the timeline and renders the matched
10
- * leaves with the SAME StreamedUnoverseComponent used everywhere else.
10
+ * leaves with the SAME UnoverseComponent used everywhere else (store-backed).
11
11
  *
12
12
  * This slice handles ComponentSlot + Skeleton (Tier-1, e.g. KeyService). The
13
13
  * chat primitives (Conversation/Input/…) come in later slices.
@@ -15,7 +15,7 @@
15
15
  import { useCallback, useEffect, useMemo, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from "react";
16
16
  import { dispatchAction, type ComponentStore, type ResolvedTheme, type UnoverseClient, type UnoverseDefinition, type UnoverseNode } from "./core";
17
17
  import { renderNode, styleToCss, keyframesCss, cssWidth, type ActionHandler, type SlotResolver } from "./render";
18
- import { StreamedUnoverseComponent } from "./streamed";
18
+ import { UnoverseComponent } from "./UnoverseComponent";
19
19
  import { useUnoverseTheme, ensureFontStylesheets } from "./theme";
20
20
  import { IsolatedRoot } from "./isolate";
21
21
  import { useVoiceService, type UseVoiceServiceConfig } from "./voice";
@@ -351,7 +351,7 @@ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme:
351
351
 
352
352
  const leaf = (pointer: string, extraData?: Record<string, unknown>, fill?: boolean) => {
353
353
  const { chatId, nodeId } = store.split(pointer);
354
- return <StreamedUnoverseComponent key={pointer} client={client} store={store} chatId={chatId} nodeId={nodeId} onAction={dispatch} theme={theme} extraData={extraData} fill={fill} />;
354
+ return <UnoverseComponent key={pointer} client={client} store={store} chatId={chatId} nodeId={nodeId} onAction={dispatch} theme={theme} extraData={extraData} fill={fill} />;
355
355
  };
356
356
 
357
357
  // THE ACTIVE STATE (docs/design/04): a template is in exactly ONE state at a time —