@unoverse-platform/studio 0.1.22 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unoverse-platform/studio",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "type": "module",
5
5
  "description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
6
6
  "scripts": {
@@ -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 } from "./states";
11
+ import { stateActivation, faceOf, switchLayers } from "./states";
12
12
  import { usePersistentState, useRegistryToggles, ToggleSwitch } from "studio-host";
13
13
 
14
14
  const CHAT_ID = "demo";
@@ -198,28 +198,11 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
198
198
  // keys it declares in `state` — that is what the toolbar is allowed to write.
199
199
  const subStatesOf = (faceName: string): { on: string; name: string }[] => {
200
200
  if (!current?.root || !hasFaces || faceName === "inline") return [];
201
- let face: unknown;
202
- const findFace = (n: any): void => {
203
- if (face || !n || typeof n !== "object") return;
204
- if (Array.isArray(n)) { n.forEach(findFace); return; }
205
- if (n.type === "Switch" && n.on === "defaultState" && n.cases?.[faceName]) { face = n.cases[faceName]; return; }
206
- Object.values(n).forEach(findFace);
207
- };
208
- findFace(current.root);
209
201
  // The component's OWN state keys — everything else in the subtree belongs to an
210
- // inlined atom or shared component, or is plain data.
202
+ // inlined atom or shared component, or is plain data. The derivation itself is
203
+ // the SHARED layer resolver (states.ts, UNOVERSE_LAYERS.md §3c).
211
204
  const ownKeys = new Set(Object.keys((current.state ?? {}) as Record<string, unknown>));
212
- const out: { on: string; name: string }[] = [];
213
- const walk = (n: any): void => {
214
- if (!n || typeof n !== "object") return;
215
- if (Array.isArray(n)) { n.forEach(walk); return; }
216
- if (n.type === "Switch" && typeof n.on === "string" && n.on !== "defaultState" && ownKeys.has(n.on) && n.cases && typeof n.cases === "object") {
217
- for (const k of Object.keys(n.cases)) if (k !== "default" && !out.some((s) => s.name === k)) out.push({ on: n.on, name: k });
218
- }
219
- Object.values(n).forEach(walk);
220
- };
221
- walk(face);
222
- return out;
205
+ return switchLayers(faceOf(current.root, faceName), ownKeys);
223
206
  };
224
207
  const subStates = subStatesOf(masterState);
225
208
 
@@ -1,4 +1,16 @@
1
1
  /**
2
+ * ============================================================================
3
+ * ⛔ THIS FILE IS NOT TO BE EDITED. ⛔
4
+ *
5
+ * Every single change in this file — and any change made on behalf of a
6
+ * component or template that seems to need one here — MUST be confirmed by
7
+ * Gavin first. No exceptions: not bug fixes, not one-liners, not reverts.
8
+ * State the intended diff and wait for an explicit yes.
9
+ *
10
+ * Components and templates are DATA (rx/). If a template seems to need code
11
+ * here, the answer is almost always to express it as data — or to ask.
12
+ * ============================================================================
13
+ *
2
14
  * Templates view — renders a TEMPLATE definition (layout) and fills its
3
15
  * ComponentSlots from a mock conversation seeded into the store.
4
16
  *
@@ -11,8 +23,9 @@ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from
11
23
  import { useAuth } from "react-oidc-context";
12
24
  import { usePersistentState, useRegistryToggles, ToggleSwitch } from "studio-host";
13
25
  import { ComponentStore } from "@unoverse/sdk";
14
- import { StreamedUnoverseTemplate, useUnoverseConnection, computeAppWidth, resolveActiveLayout, type ResolvedTheme, type UseVoiceServiceConfig } from "@unoverse/sdk";
26
+ import { StreamedUnoverseTemplate, useUnoverseConnection, computeAppWidth, resolveActiveLayout, visible, type ResolvedTheme, type UseVoiceServiceConfig } from "@unoverse/sdk";
15
27
  import { NativeAppHost } from "./NativeAppHost";
28
+ import { inArrangement } from "./states";
16
29
  import { client, authedFetch, hasAuth, UNOVERSE_BASE } from "studio-host";
17
30
 
18
31
  // Per-APP test conversation: each app chats in its own conversation id, so testing
@@ -28,16 +41,18 @@ const chatIdFor = (app: string) => `demo-${app.toLowerCase()}-${SESSION_NONCE}`;
28
41
  // it opens the Gravity data plane (WS component stream + REST execute) against the
29
42
  // configured server/workflow/node; in MOCK mode it seeds a fake conversation.
30
43
  // These are editable in the toolbar (LIVE mode) and persisted across refreshes.
31
- // Dev workbench serves /api + /ws/gravity + /stream on the merged uWS at :4205
32
- // (the retired gateway was :4100). Prod uses the page origin.
33
- const CONN_DEFAULTS = { server: "localhost:4205", workflowId: "wf-r4jzo7", triggerNode: "inputtrigger1", token: "" };
44
+ // The dev universe serves /mcp + /api + /ws/gravity + /stream on the merged uWS at
45
+ // :4105 — since the workbench retired, uWS binds the public port DIRECTLY in dev, same
46
+ // as prod (the retired workbench was :4205, the retired gateway :4100). Prod uses the
47
+ // page origin.
48
+ const CONN_DEFAULTS = { server: "localhost:4105", workflowId: "wf-r4jzo7", triggerNode: "inputtrigger1", token: "" };
34
49
  const CONN_KEY = "unoverse.workbench.conn";
35
50
 
36
51
  function loadConn(): typeof CONN_DEFAULTS {
37
52
  try {
38
53
  const saved = JSON.parse(localStorage.getItem(CONN_KEY) ?? "{}");
39
- // Migration: drop the retired gateway port so the new default applies.
40
- if (saved.server === "localhost:4100") delete saved.server;
54
+ // Migration: drop retired ports (gateway :4100, workbench :4205) so the new default applies.
55
+ if (saved.server === "localhost:4100" || saved.server === "localhost:4205") delete saved.server;
41
56
  return { ...CONN_DEFAULTS, ...saved };
42
57
  } catch {
43
58
  return { ...CONN_DEFAULTS };
@@ -46,6 +61,10 @@ function loadConn(): typeof CONN_DEFAULTS {
46
61
 
47
62
  interface PropDef {
48
63
  default?: unknown;
64
+ // Briefed pages declare their Studio mock as `preview` (ComponentsView reads the
65
+ // same pair) — the mock is `preview ?? default`, per the authoring contract.
66
+ preview?: unknown;
67
+ type?: string;
49
68
  }
50
69
  interface Def {
51
70
  name: string;
@@ -96,7 +115,9 @@ interface Def {
96
115
  // Per-STATE mock seeding: `{ "<state>": ["coursecard", "coursecard"] }` — the
97
116
  // author decides what each state's preview shows (a repeated name = several
98
117
  // instances). Supersedes the legacy flat `previewComponents`.
99
- preview?: Record<string, string[]>;
118
+ // Per-state mock: an ARRAY seeds components; an OBJECT is authored template-state
119
+ // data (what the workflow would have echoed) merged verbatim on pick.
120
+ preview?: Record<string, string[] | Record<string, unknown>>;
100
121
  previewComponents?: string[];
101
122
  fluidHeight?: boolean;
102
123
  // The app's named default state on load (the single authored field, OPEN name):
@@ -147,6 +168,13 @@ export function TemplatesView({
147
168
  // null = the default layout. Independent of the local-state axis — any layout can
148
169
  // combine with any local state (a voice List layout while agent-speaking).
149
170
  const [activeLayout, setActiveLayout] = useState<string | null>(null);
171
+ // A STATE BELONGS TO THE LAYOUTS THAT DRAW IT (UNOVERSE_LAYERS.md §3c) — same shared
172
+ // layer resolver ComponentsView uses for a face's steps (states.ts): a state is in
173
+ // scope when the ACTIVE layout's tree carries its selector. States shared by all
174
+ // layouts (a chat core's welcome) match everywhere; a rail-only state matches only
175
+ // its own arrangement, like Detail under Course.
176
+ const stateInLayout = (s: { when?: unknown; where?: unknown }, t: { layouts?: Record<string, unknown>; defaultLayout?: string; root?: unknown }): boolean =>
177
+ !t.layouts || inArrangement(t.layouts[activeLayout ?? t.defaultLayout ?? "main"] ?? t.root, s);
150
178
  useEffect(() => {
151
179
  const t = templates.find((x) => x.name === selected);
152
180
  setActiveState(t?.states?.filter((s) => !(t.layouts && s.name in t.layouts!))[0] ?? null);
@@ -229,8 +257,13 @@ export function TemplatesView({
229
257
  const liveStateName = useMemo(() => {
230
258
  void storeVersion; // recompute on every store write
231
259
  const states = selectedDef?.states ?? [];
260
+ // This name lights the LOCAL-state rail, so layouts (which have their own pill row
261
+ // and their own `activeLayout` highlight) are not candidates: a claimed `review`
262
+ // view must not blank the rail while a local discriminant is active beside it —
263
+ // the two axes compose (a review arrangement in its gathering state).
232
264
  const claims = new Map<string, string>(); // view -> state name
233
265
  for (const s of states) {
266
+ if (selectedDef?.layouts && s.name in selectedDef.layouts) continue;
234
267
  const eq = (s.where as { eq?: unknown } | undefined)?.eq;
235
268
  if (typeof eq === "string") claims.set(eq, s.name);
236
269
  }
@@ -246,6 +279,13 @@ export function TemplatesView({
246
279
  }
247
280
  }
248
281
  if (view) return claims.get(view) ?? null;
282
+ // LOCAL states carry their own selector (`when`, a condition on template state —
283
+ // railState/callState-style discriminants). Evaluate it with the RENDERER'S OWN
284
+ // predicate (`visible`, one condition semantics), against the template-state bag —
285
+ // so a pill pick, a component's write, or a live workflow echo all light the same.
286
+ const ts = store.getTemplateState() as Record<string, unknown>;
287
+ const local = states.find((s) => s.when && typeof s.when === "object" && visible(s.when as Parameters<typeof visible>[0], ts));
288
+ if (local) return local.name;
249
289
  const base = store.getTimeline().length === 0 ? "isEmpty" : "hasMessages";
250
290
  return states.find((s) => s.when === base)?.name ?? null;
251
291
  }, [storeVersion, store, selectedDef]);
@@ -413,7 +453,9 @@ export function TemplatesView({
413
453
  }, [store, live]);
414
454
 
415
455
  const defaultsOf = (d: Def) =>
416
- Object.fromEntries(Object.entries(d.props ?? {}).map(([k, v]) => [k, v.default]));
456
+ Object.fromEntries(
457
+ Object.entries(d.props ?? {}).map(([k, v]) => [k, v.preview ?? v.default ?? (v.type === "array" ? [] : v.type === "object" ? {} : "")]),
458
+ );
417
459
 
418
460
  // Seed the given components into the latest response, so any slot that matches fills;
419
461
  // unmatched slots skeleton. The SET is chosen by the caller — scoped to the selected
@@ -500,7 +542,13 @@ export function TemplatesView({
500
542
  // • { field, eq } (template-state selector, e.g. defaultState="focus") → set it after seeding
501
543
  // An explicit preview entry for the state OVERRIDES all of it — the author decided.
502
544
  const previewMap = selectedDef?.app?.preview;
503
- const entry = activeLayout ? previewMap?.[activeLayout] : activeState ? previewMap?.[activeState.name] : undefined;
545
+ // An OBJECT preview entry is authored TEMPLATE-STATE mock data — what the workflow
546
+ // would have echoed (comments, railState, an anchor) — merged as-is so a state's
547
+ // pill previews with real content. Arrays keep the existing meaning: component seeds.
548
+ const stateEntry = activeState ? previewMap?.[activeState.name] : undefined;
549
+ const stateData = stateEntry && !Array.isArray(stateEntry) ? (stateEntry as Record<string, unknown>) : undefined;
550
+ const layoutEntry = activeLayout ? previewMap?.[activeLayout] : stateEntry;
551
+ const entry = Array.isArray(layoutEntry) ? layoutEntry : undefined;
504
552
  const wantEmpty = !entry && !activeLayout && typeof when === "string" && when === "isEmpty";
505
553
  // A COMPONENT is only seeded for a reaction-contract state (select.where) — the
506
554
  // surface needs one to grab (Focus). Plain template states (welcome, conversation)
@@ -516,7 +564,11 @@ export function TemplatesView({
516
564
  : [];
517
565
  // Seed when there's preview content OR a picked state that needs a live conversation.
518
566
  if (set.length || activeState) {
519
- store.addUserMessage(chatIdFor(selected ?? "app"), "Show me this app");
567
+ // An autoTrigger app launches by RAW trigger — nothing prints to the
568
+ // conversation (protocol §launch-from-UI). The mock mirrors it: components
569
+ // seed without the synthetic user turn, so a template that renders user turns
570
+ // outside a chat (a review pile) never shows "Show me this app" as content.
571
+ if (!selectedDef?.app?.autoTrigger) store.addUserMessage(chatIdFor(selected ?? "app"), "Show me this app");
520
572
  // Component states need a body to grab; app declares no previewComponents →
521
573
  // fall back to the SAMPLE ANSWER (the Markdown component's own default content)
522
574
  // so the conversation still previews a real assistant turn.
@@ -534,6 +586,9 @@ export function TemplatesView({
534
586
  } else if (typeof when === "string" && when !== "isEmpty" && when !== "hasMessages") {
535
587
  store.mergeTemplateState({ [when]: true });
536
588
  }
589
+ // Authored template-state mock (object preview entry) — merged AFTER the selector so
590
+ // the author's data wins (it may refine the same keys, e.g. railState + comments).
591
+ if (stateData) store.mergeTemplateState(stateData);
537
592
  // Reaction-contract activation (STATE_MODEL §5b): the surface (or the layout pill —
538
593
  // same selector) reacts to COMPONENT state, so activate it the way the runtime
539
594
  // does — put a seeded component INTO that view. Same write the component's own
@@ -578,7 +633,7 @@ export function TemplatesView({
578
633
  the rail keeps only the template's LOCAL states. */}
579
634
  {!live && selected === t.name && (t.states?.length ?? 0) > 0 && (
580
635
  <div className="my-0.5 ml-2 flex flex-col gap-px border-l border-gray-700 pl-2">
581
- {t.states!.filter((s) => !(t.layouts && s.name in t.layouts!)).map((s) => (
636
+ {t.states!.filter((s) => !(t.layouts && s.name in t.layouts!)).filter((s) => stateInLayout(s, t)).map((s) => (
582
637
  <button
583
638
  key={s.name}
584
639
  onClick={() => {
package/screens/states.ts CHANGED
@@ -1,32 +1,75 @@
1
1
  /**
2
- * Component-state activation — shared by the Templates and Components views.
2
+ * Layer resolution + state activation — shared by the Templates and Components views.
3
3
  *
4
4
  * A component's `states/` files are its Switch-case layers: the state NAME is the
5
5
  * case key, and the Switch's `on` field is the discriminant that selects it
6
6
  * (UNOVERSE_LAYERS.md §7). To show a state, write `{ [discriminant]: name }` into
7
7
  * the component's own slice — exactly what the component's own buttons do.
8
+ *
9
+ * SCOPE lives here too (UNOVERSE_LAYERS.md §3c): a layer belongs to the arrangement
10
+ * that DRAWS it — a face's steps are the Switches on the component's own keys inside
11
+ * that face; a template state belongs to the layouts whose trees carry its selector.
12
+ * The structure is the declaration; both views derive, neither lists.
13
+ */
14
+
15
+ /** Walk every object node of a definition tree until `visit` says stop (returns true). */
16
+ function walkTree(n: unknown, visit: (node: Record<string, unknown>) => boolean | void): boolean {
17
+ if (!n || typeof n !== "object") return false;
18
+ if (Array.isArray(n)) return n.some((c) => walkTree(c, visit));
19
+ const o = n as Record<string, unknown>;
20
+ if (visit(o)) return true;
21
+ return Object.values(o).some((v) => walkTree(v, visit));
22
+ }
23
+
24
+ /** The subtree a face draws: the root's `Switch on defaultState` case of that name. */
25
+ export function faceOf(root: unknown, face: string): unknown {
26
+ let found: unknown;
27
+ walkTree(root, (o) => {
28
+ if (o.type === "Switch" && o.on === "defaultState" && o.cases && typeof o.cases === "object" && (o.cases as Record<string, unknown>)[face]) {
29
+ found = (o.cases as Record<string, unknown>)[face];
30
+ return true;
31
+ }
32
+ });
33
+ return found;
34
+ }
35
+
36
+ /**
37
+ * The STEPS a subtree owns: every case of a `Switch` that turns on one of the thing's
38
+ * OWN state keys (`defaultState` excluded — that Switch picks faces, not steps).
39
+ * Switches on data fields (`kind`, a nested component's key) are content selection,
40
+ * not layers — which is why the caller passes the declared keys.
41
+ */
42
+ export function switchLayers(tree: unknown, ownKeys: ReadonlySet<string>): { on: string; name: string }[] {
43
+ const out: { on: string; name: string }[] = [];
44
+ walkTree(tree, (o) => {
45
+ if (o.type === "Switch" && typeof o.on === "string" && o.on !== "defaultState" && ownKeys.has(o.on) && o.cases && typeof o.cases === "object") {
46
+ 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
+ }
48
+ });
49
+ return out;
50
+ }
51
+
52
+ /**
53
+ * Does an arrangement DRAW this state? Includes are inlined at serve time, so the
54
+ * selector authored on a state file (`visibleWhen` for a local layer, `select.where`
55
+ * for a reaction surface) appears verbatim inside every layout that `$include`s it.
56
+ * A state with no selector has nothing to scope by — always in.
8
57
  */
58
+ export function inArrangement(tree: unknown, state: { when?: unknown; where?: unknown }): boolean {
59
+ const sel = JSON.stringify(state.when ?? state.where ?? null);
60
+ if (sel === "null") return true;
61
+ return walkTree(tree, (o) => JSON.stringify(o.visibleWhen ?? null) === sel || JSON.stringify((o.select as { where?: unknown } | undefined)?.where ?? null) === sel);
62
+ }
9
63
 
10
64
  /** Find the discriminant field whose Switch has a case named `stateName`. */
11
65
  export function discriminantFor(root: unknown, stateName: string): string | null {
12
66
  let found: string | null = null;
13
- const walk = (n: unknown): void => {
14
- if (found || !n || typeof n !== "object") return;
15
- if (Array.isArray(n)) return n.forEach(walk);
16
- const o = n as Record<string, unknown>;
17
- if (
18
- o.type === "Switch" &&
19
- typeof o.on === "string" &&
20
- o.cases &&
21
- typeof o.cases === "object" &&
22
- Object.prototype.hasOwnProperty.call(o.cases, stateName)
23
- ) {
67
+ walkTree(root, (o) => {
68
+ if (o.type === "Switch" && typeof o.on === "string" && o.cases && typeof o.cases === "object" && Object.prototype.hasOwnProperty.call(o.cases, stateName)) {
24
69
  found = o.on;
25
- return;
70
+ return true;
26
71
  }
27
- Object.values(o).forEach(walk);
28
- };
29
- walk(root);
72
+ });
30
73
  return found;
31
74
  }
32
75
 
@@ -130,18 +130,96 @@ const inspectId = (node: UnoverseNode): string | undefined => {
130
130
  return typeof id === "string" ? id : undefined;
131
131
  };
132
132
 
133
- export function BoxView({ node, data, theme, children }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme; children?: ReactNode }): ReactNode {
133
+ export function BoxView({ node, data, theme, children, onAction }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme; children?: ReactNode; onAction?: ActionHandler }): ReactNode {
134
134
  const { className, styleEls, style } = nodeChrome(node, data, theme);
135
135
  const scrollRef = useAutoScrollToEnd(node.autoScroll === true);
136
136
  const revealRef = useRevealWhenStuck(node.revealOnStick === true);
137
+ const sel = (node as { selection?: { label?: string; action?: unknown } }).selection;
138
+ const { selRef, pick, clearPick } = useTextSelection(!!sel);
139
+ // A selection host must anchor its floating affordance; default to relative only when
140
+ // the definition declared no position of its own (sticky/absolute roots keep theirs).
141
+ const hostStyle = sel && !(style as CSSProperties).position ? ({ ...style, position: "relative" } as CSSProperties) : style;
137
142
  return (
138
- <div ref={scrollRef ?? revealRef} className={className} style={style} data-unoverse-id={inspectId(node)}>
143
+ <div ref={scrollRef ?? revealRef ?? selRef} className={className} style={hostStyle} data-unoverse-id={inspectId(node)}>
139
144
  {styleEls}
140
145
  {children}
146
+ {sel && pick && (
147
+ <button
148
+ style={{
149
+ position: "absolute",
150
+ top: pick.top,
151
+ left: pick.left,
152
+ transform: "translate(-50%, -100%)",
153
+ zIndex: 40,
154
+ whiteSpace: "nowrap",
155
+ ...styleToCss(((theme as { selection?: { chip?: Record<string, unknown> } }).selection?.chip ?? SELECTION_CHIP_BUILTIN) as Record<string, unknown>, theme),
156
+ }}
157
+ onMouseDown={(e) => e.preventDefault() /* keep the selection alive through the click */}
158
+ onClick={() => {
159
+ onAction?.((sel.action as Parameters<ActionHandler>[0]) ?? "select", { ...data, selection: pick.text });
160
+ clearPick();
161
+ }}
162
+ >
163
+ {sel.label ?? "Comment"}
164
+ </button>
165
+ )}
141
166
  </div>
142
167
  );
143
168
  }
144
169
 
170
+ // The selection chip's look: a SERVED recipe (`theme.selection.chip`) when the org styles
171
+ // one; else this neutral fallback of token NAMES resolved against the same theme — the
172
+ // skeleton-shimmer pattern. The SDK still authors no values, only names.
173
+ const SELECTION_CHIP_BUILTIN: Record<string, unknown> = {
174
+ background: "action.primary",
175
+ color: "text.inverse",
176
+ font: "body.sm",
177
+ radius: "full",
178
+ padding: ["1", "3"],
179
+ shadow: "lg",
180
+ border: "none",
181
+ cursor: "pointer",
182
+ };
183
+
184
+ /**
185
+ * Generic "text was selected here" behavior (opt-in via `node.selection`). The renderer is
186
+ * the ONLY layer that can know a reader selected text — definitions cannot express it —
187
+ * so, like `skeleton` and `autoScroll`, capture is SDK behavior while every consequence
188
+ * stays authored: the node declares the ACTION (fired with the selected text in scope as
189
+ * `{{selection}}`) and the chip's look is served (`theme.selection.chip`). Selection is
190
+ * read from the node's own root (shadow-DOM aware); collapsing it dismisses the chip.
191
+ */
192
+ function useTextSelection(enabled: boolean) {
193
+ const selRef = useRef<HTMLDivElement | null>(null);
194
+ const [pick, setPick] = useState<{ text: string; top: number; left: number } | null>(null);
195
+ useEffect(() => {
196
+ if (!enabled) return;
197
+ const el = selRef.current;
198
+ if (!el) return;
199
+ const read = () => {
200
+ const root = el.getRootNode() as Document | ShadowRoot;
201
+ const s = (root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection();
202
+ if (!s || s.isCollapsed || s.rangeCount === 0) return setPick(null);
203
+ const range = s.getRangeAt(0);
204
+ if (!el.contains(range.commonAncestorContainer)) return setPick(null);
205
+ const text = s.toString().trim();
206
+ if (!text) return setPick(null);
207
+ const r = range.getBoundingClientRect();
208
+ const host = el.getBoundingClientRect();
209
+ setPick({ text, top: r.top - host.top, left: Math.min(Math.max(0, r.left - host.left + r.width / 2), host.width) });
210
+ };
211
+ const up = () => window.setTimeout(read, 0); // after the browser settles the selection
212
+ // Listen on the DOCUMENT, not the host: a drag-select routinely ends with the mouse
213
+ // released outside the host (over a rail, past the edge), and mouse events are
214
+ // composed, so they cross the shadow boundary to reach it. `read` still guards with
215
+ // `el.contains(...)`, so only selections inside this host raise the chip.
216
+ const doc = el.ownerDocument;
217
+ doc.addEventListener("mouseup", up);
218
+ return () => doc.removeEventListener("mouseup", up);
219
+ }, [enabled]);
220
+ return { selRef: enabled ? selRef : undefined, pick, clearPick: () => setPick(null) };
221
+ }
222
+
145
223
  /**
146
224
  * Generic "stick to the newest content" behavior for a scroll container (opt-in via
147
225
  * `node.autoScroll`). Smooth-scrolls to the bottom whenever content grows — a new turn
@@ -364,6 +442,27 @@ export function InputView({ node, data, theme, onAction }: { node: UnoverseNode;
364
442
  if (field == null) setLocal("");
365
443
  };
366
444
  const { style, disabled } = nodeChrome(node, data, theme);
445
+ // `multiline: true` — the same irreducible input as a `<textarea>` (critique, notes —
446
+ // anything longer than one line). Enter inserts a newline; Cmd/Ctrl+Enter submits.
447
+ // Same element class, no chrome, no new primitive.
448
+ if ((node as { multiline?: boolean }).multiline === true) {
449
+ return (
450
+ <textarea
451
+ style={style}
452
+ disabled={disabled}
453
+ value={value}
454
+ placeholder={node.placeholder}
455
+ rows={(node as { rows?: number }).rows ?? 3}
456
+ onChange={(e) => setValue(e.target.value)}
457
+ onKeyDown={(e) => {
458
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
459
+ e.preventDefault();
460
+ submit();
461
+ }
462
+ }}
463
+ />
464
+ );
465
+ }
367
466
  return (
368
467
  <input
369
468
  style={style}
@@ -173,7 +173,7 @@ export function renderNode(
173
173
  case "Row":
174
174
  case "Column":
175
175
  return (
176
- <BoxView key={key} node={node} data={data} theme={theme}>
176
+ <BoxView key={key} node={node} data={data} theme={theme} onAction={onAction}>
177
177
  {kids}
178
178
  </BoxView>
179
179
  );
@@ -12,6 +12,12 @@ type HostConfig = Omit<AppConfig, "getAccessToken"> & { token?: string };
12
12
  let root: Root | null = null;
13
13
  let current: HostConfig | null = null;
14
14
 
15
+ // Stable identity, module-level: the connection hook keys its lifecycle on the
16
+ // `getAccessToken` reference (connection.tsx effect deps), and the host re-posts
17
+ // config on every iframe load — an inline closure here would recycle the live
18
+ // /stream session on each repost.
19
+ const readToken = () => current?.token ?? null;
20
+
15
21
  function render() {
16
22
  if (!current) return;
17
23
  const el = document.getElementById("root")!;
@@ -23,15 +29,21 @@ function render() {
23
29
  templateId={current.templateId}
24
30
  userId={current.userId}
25
31
  conversationId={current.conversationId}
26
- getAccessToken={() => current?.token ?? null}
32
+ // Only claim auth when the host actually sent a token. A fabricated accessor on a
33
+ // tokenless boot made the SDK skip its guest-id substitution (connection.tsx), so
34
+ // the session registered a non-guest id and the public-entry gate refused it.
35
+ // No token ⇒ no accessor ⇒ the SDK mints/persists `guest-<uuid>` per the contract
36
+ // (UNOVERSE_MCP_TEMPLATE_PROTOCOL §5, AUTH_TOKEN_FLOW Run Authorization).
37
+ getAccessToken={current.token ? readToken : undefined}
27
38
  />,
28
39
  );
29
40
  }
30
41
 
31
42
  function apply(cfg: HostConfig) {
32
- const first = !current;
33
43
  current = { ...current, ...cfg };
34
- if (first) render(); // mount once; token refresh just updates `current` (getAccessToken reads it)
44
+ // Re-render on every apply so a token appearing/disappearing takes effect; a same-token
45
+ // refresh changes nothing (readToken is stable, values equal), so no connection churn.
46
+ render();
35
47
  }
36
48
 
37
49
  declare global {