@unoverse-platform/studio 0.1.103 → 0.1.104

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.
Files changed (79) hide show
  1. package/package.json +4 -2
  2. package/screens/AtomsView.tsx +2 -1
  3. package/screens/ComponentsView.tsx +2 -1
  4. package/screens/NativeAppHost.tsx +1 -2
  5. package/screens/TemplatesLibraryView.tsx +14 -1
  6. package/screens/TemplatesView.tsx +2 -1
  7. package/screens/index.ts +4 -0
  8. package/screens/react/unoverse.tsx +281 -0
  9. package/vendor/base/src/agent-mcp/index.ts +1 -1
  10. package/vendor/base/src/agent-mcp/invoke.ts +6 -2
  11. package/vendor/base/src/agent-mcp/tools.ts +14 -5
  12. package/vendor/base/src/agent-mcp/types.ts +6 -0
  13. package/vendor/base/src/auth/auth.ts +90 -23
  14. package/vendor/base/src/definitions/load/compile/sections.ts +79 -0
  15. package/vendor/base/src/definitions/load/compile/template.ts +23 -2
  16. package/vendor/base/src/definitions/model.ts +9 -0
  17. package/vendor/base/src/definitions/registry/catalog.ts +6 -7
  18. package/vendor/base/src/executor.ts +25 -1
  19. package/vendor/base/src/manifests/executor/toolBridge.ts +25 -19
  20. package/vendor/base/src/manifests/runtime/channels/final.ts +4 -1
  21. package/vendor/base/src/manifests/runtime/duplex/session.ts +3 -9
  22. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +28 -25
  23. package/vendor/base/src/platform/serviceCalls.ts +13 -0
  24. package/vendor/sdk/appEngine.ts +363 -0
  25. package/vendor/sdk/lib/actions.ts +36 -0
  26. package/vendor/sdk/lib/citations.ts +48 -0
  27. package/vendor/sdk/lib/core/apps.ts +18 -0
  28. package/vendor/sdk/lib/core/client.ts +3 -3
  29. package/vendor/sdk/lib/core/store.ts +6 -0
  30. package/vendor/sdk/lib/dom/app.ts +546 -0
  31. package/vendor/sdk/lib/dom/chrome.ts +125 -0
  32. package/vendor/sdk/lib/dom/component.ts +333 -0
  33. package/vendor/sdk/lib/dom/connection.ts +309 -0
  34. package/vendor/sdk/lib/dom/element.ts +69 -0
  35. package/vendor/sdk/lib/dom/isolate.ts +37 -0
  36. package/vendor/sdk/lib/dom/markdown.ts +388 -0
  37. package/vendor/sdk/lib/dom/markdownParse.ts +440 -0
  38. package/vendor/sdk/lib/dom/mounted.ts +29 -0
  39. package/vendor/sdk/lib/dom/primitives.ts +689 -0
  40. package/vendor/sdk/lib/dom/region.ts +195 -0
  41. package/vendor/sdk/lib/dom/render.ts +177 -0
  42. package/vendor/sdk/lib/dom/theme.ts +38 -0
  43. package/vendor/sdk/lib/dom/types.ts +36 -0
  44. package/vendor/sdk/lib/index.ts +26 -17
  45. package/vendor/sdk/lib/orb/canvas.ts +139 -0
  46. package/vendor/sdk/lib/orb/index.ts +8 -18
  47. package/vendor/sdk/lib/orb/presets.ts +1 -1
  48. package/vendor/sdk/lib/orb/{types.ts → states.ts} +0 -24
  49. package/vendor/sdk/lib/realtime/capture.ts +152 -0
  50. package/vendor/sdk/lib/realtime/playback.ts +129 -0
  51. package/vendor/sdk/lib/realtime/socket.ts +117 -0
  52. package/vendor/sdk/lib/style.ts +45 -37
  53. package/vendor/sdk/lib/voiceService.ts +344 -0
  54. package/vendor/sdk/main.ts +107 -0
  55. package/vendor/sdk/mcpAppBridge.ts +125 -102
  56. package/vendor/sdk/pageInterfaces.ts +452 -0
  57. package/vendor/sdk/responseView.ts +193 -0
  58. package/vite.config.ts +3 -3
  59. package/vendor/sdk/appEngine.tsx +0 -395
  60. package/vendor/sdk/lib/UnoverseComponent.tsx +0 -325
  61. package/vendor/sdk/lib/app.tsx +0 -730
  62. package/vendor/sdk/lib/connection.tsx +0 -433
  63. package/vendor/sdk/lib/isolate.tsx +0 -70
  64. package/vendor/sdk/lib/orb/ThinkingOrb.tsx +0 -131
  65. package/vendor/sdk/lib/orb/theme.ts +0 -83
  66. package/vendor/sdk/lib/primitives.tsx +0 -943
  67. package/vendor/sdk/lib/realtime/useAudioCapture.ts +0 -237
  68. package/vendor/sdk/lib/realtime/useAudioPlayback.ts +0 -157
  69. package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +0 -164
  70. package/vendor/sdk/lib/render.tsx +0 -244
  71. package/vendor/sdk/lib/streamed.tsx +0 -30
  72. package/vendor/sdk/lib/theme.ts +0 -55
  73. package/vendor/sdk/lib/transition/MountArrangement.tsx +0 -71
  74. package/vendor/sdk/lib/transition/index.ts +0 -9
  75. package/vendor/sdk/lib/transition/viewTransition.ts +0 -29
  76. package/vendor/sdk/lib/voice.tsx +0 -422
  77. package/vendor/sdk/main.tsx +0 -125
  78. package/vendor/sdk/pageInterfaces.tsx +0 -388
  79. package/vendor/sdk/responseView.tsx +0 -82
@@ -1,433 +0,0 @@
1
- /**
2
- * useUnoverseConnection — the CHANNEL's data-plane connector (MCP stream).
3
- *
4
- * Opens the live workflow connection to the Unoverse server's `/stream` MCP endpoint
5
- * (Streamable-HTTP SSE) for the inbound component stream, and REST `…/execute` for
6
- * sending messages. Inbound `notifications/unoverse/event` messages feed the single
7
- * `ComponentStore` via the pure `applyServerMessage` mapper.
8
- *
9
- * Per UNOVERSE_MCP_APP_PROTOCOL.md §5/§5a/§5b: the component stream rides MCP
10
- * (NOT the legacy `/ws/gravity` WS — that's reserved for live audio). The node
11
- * executes in the same Unoverse process that serves this stream, so there's no
12
- * gateway relay and no Redis in the component path.
13
- */
14
- import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
15
- import { emitAnalytics } from "./core/analytics";
16
- import { applyServerMessage, viewOf, type ComponentStore, type ServerMessage } from "./core";
17
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
18
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
19
- // Interpreter-based JSON-schema validator (no `new Function`). The SDK's default Ajv
20
- // provider compiles schemas with `new Function`, which ChatGPT's widget CSP (no
21
- // unsafe-eval) blocks — that crashed the in-iframe MCP client on boot and left the
22
- // widget blank. CfWorker is the SDK's own edge/no-eval provider.
23
- import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
24
- import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
25
-
26
- export interface UnoverseConnectionConfig {
27
- /** Gravity API base, e.g. "http://localhost:4100". REST execute lives here. */
28
- apiUrl: string;
29
- /** Unoverse server base, e.g. "http://localhost:4105" (or the workbench origin in
30
- * dev). The `/stream` MCP data-plane endpoint (§5b) is appended. */
31
- streamUrl: string;
32
- /** Optional JWT provider — the token rides the MCP transport fetch + REST Authorization header. */
33
- getAccessToken?: () => Promise<string | null>;
34
- }
35
-
36
- export interface UnoverseSessionParams {
37
- workflowId: string;
38
- targetTriggerNode: string;
39
- userId: string;
40
- conversationId: string;
41
- /** Groups one request→response turn. Defaults to conversationId if omitted. */
42
- chatId?: string;
43
- /** The app this session shows (`<org>/<name>`). The server draws an app that declares places
44
- * from its screen document (docs/unoverse/design/screen/UNOVERSE_SCREEN.md). */
45
- appId?: string;
46
- }
47
-
48
- export interface UnoverseConnection {
49
- isConnected: boolean;
50
- isReady: boolean;
51
- /** The server's refusal text when register_session answered isError (public-entry
52
- * gate, requires.role, stale token) — null while unrefused. A refusal is TERMINAL
53
- * for the attempt (the server has answered; retrying the same identity loops), so
54
- * hosts must surface this instead of showing "connecting" forever. Cleared when a
55
- * new connect sequence starts. */
56
- refusal: string | null;
57
- /** Composer target: optimistic user turn + REST workflow trigger. */
58
- sendMessage: (text: string) => void;
59
- /** Fire the bound workflow WITHOUT a user turn — the "load → run the workflow" ping
60
- * (its component streams back into the shell). `input` shapes the execute payload per
61
- * the app's `inputSchema`; omit for a bare trigger. (§5b REST `/execute`; the future
62
- * MCP-native form is a `tools/call` on the InputTrigger tool, same call site.) */
63
- trigger: (input?: Record<string, unknown>) => void;
64
- /** Other component actions (button clicks, etc.) → MCP `user_action` tool. */
65
- sendAction: (action: string, data: Record<string, unknown>) => void;
66
- /** Answer a pending ELICITATION with the submitted component state (the native
67
- * collection path — §3.3). Returns false when no elicitation is pending (the
68
- * channel then falls back to `sendAction`). */
69
- resolveElicitation: (state: Record<string, unknown>, action?: "accept" | "cancel") => boolean;
70
- /** The workflow-selected template (MCP app) name, or null. The channel renders
71
- * `unoverse://apps/{activeApp}/definition` when set — the official resources/read load. */
72
- activeApp: string | null;
73
- }
74
-
75
- /** The data-plane endpoint on the Unoverse server (§5b). */
76
- const STREAM_ENDPOINT = "/stream";
77
- /** Custom notification carrying one ServerMessage as `params` (§5b). */
78
- const UNOVERSE_EVENT = "notifications/unoverse/event";
79
-
80
- /**
81
- * Guest identity — CONTAINED HERE so hosts never write guest logic. A token-less
82
- * session is a GUEST to the platform's public-entry gate, which only admits
83
- * `guest-`-prefixed ids (Run Authorization, docs/auth-security/IDENTITY.md). Minted once per
84
- * browser and persisted so the visitor's conversation and memory cohere across
85
- * reloads. A host that supplies its own guest id wins (two apps in one page share
86
- * one visitor); a host with a token is untouched.
87
- */
88
- const GUEST_KEY = "unoverse:guestId";
89
- export function resolveGuestId(): string {
90
- let id: string | null = null;
91
- try {
92
- id = localStorage.getItem(GUEST_KEY);
93
- } catch {
94
- /* storage blocked → per-load guest below */
95
- }
96
- if (!id || !/^guest[-:]/.test(id)) {
97
- id = `guest-${crypto.randomUUID()}`;
98
- try {
99
- localStorage.setItem(GUEST_KEY, id);
100
- } catch {
101
- /* fine: the id just won't survive a reload */
102
- }
103
- }
104
- return id;
105
- }
106
-
107
- /**
108
- * THE ONE IDENTITY RULE for every lane the SDK opens: with a token the host's userId passes
109
- * through; without one the platform sees a guest and requires a guest-prefixed id, so a
110
- * host-supplied guest id wins and anything else is replaced by the stored guest id. The data
111
- * plane and the audio lane both call this, so a page never speaks with two names.
112
- */
113
- export function sessionUserId(userId: string | undefined, getAccessToken: unknown): string {
114
- return getAccessToken || /^guest[-:]/.test(userId ?? "") ? (userId as string) : resolveGuestId();
115
- }
116
-
117
- export function useUnoverseConnection(
118
- config: UnoverseConnectionConfig,
119
- session: UnoverseSessionParams,
120
- store: ComponentStore,
121
- options: { enabled?: boolean; onAppSwitch?: (app: string) => void; onJoinCall?: (workflowId?: string) => void } = {},
122
- ): UnoverseConnection {
123
- const enabled = options.enabled ?? true;
124
- // Read through a ref so a host's inline arrow does not tear down the stream on
125
- // every render — the connection effect must not depend on the callback's identity.
126
- const onAppSwitchRef = useRef(options.onAppSwitch);
127
- onAppSwitchRef.current = options.onAppSwitch;
128
- const onAppSwitch = (app: string) => onAppSwitchRef.current?.(app);
129
- const onJoinCallRef = useRef(options.onJoinCall);
130
- onJoinCallRef.current = options.onJoinCall;
131
- // Read through a ref too: the server follows app switches itself, so a new app must not
132
- // tear the stream down; the next registration simply names it.
133
- const appIdRef = useRef(session.appId);
134
- appIdRef.current = session.appId;
135
- const { streamUrl, getAccessToken } = config;
136
- const { workflowId, targetTriggerNode, conversationId } = session;
137
- // No token ⇒ the platform sees a GUEST session and requires a guest-prefixed id —
138
- // supply one automatically (host-supplied guest ids win; with a token the host's
139
- // real userId passes through untouched). This is the whole guest story for a host.
140
- const userId = sessionUserId(session.userId, getAccessToken);
141
- const chatId = session.chatId ?? conversationId;
142
-
143
- const [isConnected, setIsConnected] = useState(false);
144
- const [isReady, setIsReady] = useState(false);
145
- const [refusal, setRefusal] = useState<string | null>(null);
146
- const clientRef = useRef<Client | null>(null);
147
- // First-seen component keys for this connection (repeat INIT → props merge).
148
- const seenRef = useRef<Set<string>>(new Set());
149
- // The pending elicitation's resolver (one at a time per session) — set while the
150
- // server is asking this session for structured input; the channel's submit branch
151
- // answers it via `resolveElicitation`.
152
- const pendingElicitRef = useRef<((state: Record<string, unknown>, action: "accept" | "cancel") => void) | null>(null);
153
-
154
- // ---- connection lifecycle (MCP stream) ----
155
- useEffect(() => {
156
- if (!enabled) return; // mock mode — no live stream
157
- // No workflowId is fine: a page app with none still hears its conversation (the server
158
- // takes a signed-in session without one; a guest's still needs a public trigger).
159
- if (!userId || !conversationId) {
160
- console.warn("[unoverse] connection missing userId/conversationId");
161
- return;
162
- }
163
- let cancelled = false;
164
- let attempt = 0;
165
- let retryTimer: ReturnType<typeof setTimeout> | null = null;
166
- // Kept ACROSS reconnects on purpose: after a re-bind the server may re-push
167
- // components this session already placed, and a repeat INIT on a seen key merges
168
- // props instead of re-placing (applyServerMessage) — the screen never jumps.
169
- seenRef.current = new Set();
170
-
171
- /**
172
- * SESSION-DEATH RECOVERY (the MCP spec's own rule): a session id the server no
173
- * longer knows answers 404, and the client MUST start a NEW session rather than
174
- * mourn the old one. Sessions are process memory server-side, so every restart —
175
- * a dev edit, a prod deploy — orphans every open channel; before this, the error
176
- * was logged once ("stream connect failed" / "client error") and the surface sat
177
- * disconnected forever. Now ANY client error recycles the whole client and
178
- * re-runs the connect sequence with backoff: register_session is idempotent, a
179
- * re-bind emits SESSION_READY, and mount-reads re-hydrate state, so downstream
180
- * already copes. Blunt on purpose — a transport error we fail to recognise as
181
- * session death costs one spurious reconnect, while one we miss is a dead screen.
182
- */
183
- const recycle = (dead: Client, why: unknown) => {
184
- // Only the CURRENT client may trigger recovery: a dying client's teardown fires
185
- // stray errors (observed: four AbortErrors in one restart) and each would
186
- // otherwise schedule its own storm of reconnects.
187
- if (cancelled || clientRef.current !== dead) return;
188
- clientRef.current = null;
189
- void dead.close().catch(() => {});
190
- setIsConnected(false);
191
- setIsReady(false);
192
- const delay = Math.min(15_000, 500 * 2 ** Math.min(attempt++, 5));
193
- console.warn(`[unoverse] session lost — reconnecting in ${delay}ms (attempt ${attempt})`, why);
194
- retryTimer = setTimeout(() => void connect(), delay);
195
- };
196
-
197
- const connect = async () => {
198
- if (cancelled) return;
199
- // Declare the ELICITATION capability (form mode) — the native collection path
200
- // (UNOVERSE_MCP_APP_PROTOCOL §3.3): the server pauses inside an app tool
201
- // call and asks THIS session for the user's structured input. The interactive
202
- // component already on screen IS the form; its `submit` answers the elicitation.
203
- const client = new Client(
204
- { name: "unoverse-channel", version: "0.0.1" },
205
- { capabilities: { elicitation: {} }, jsonSchemaValidator: new CfWorkerJsonSchemaValidator() },
206
- );
207
- client.setRequestHandler(ElicitRequestSchema, async (req) => {
208
- // Pend until the user submits the component (the channel calls resolveElicitation
209
- // from its submit branch). The branded UI renders the request — never a generic form.
210
- // The accept content MUST match the requestedSchema (spec: flat primitives only) —
211
- // project the submitted component state to the REQUESTED keys, or the SDK rejects
212
- // the response (view-state like a `courses` array fails the primitive union).
213
- const requested =
214
- (req.params as unknown as { requestedSchema?: { properties?: Record<string, unknown> } }).requestedSchema?.properties ?? {};
215
- return await new Promise((resolve) => {
216
- pendingElicitRef.current = (state: Record<string, unknown>, action: "accept" | "cancel") => {
217
- pendingElicitRef.current = null;
218
- if (action === "cancel") return resolve({ action: "cancel" } as never);
219
- const content: Record<string, unknown> = {};
220
- for (const k of Object.keys(requested)) if (state[k] !== undefined) content[k] = state[k];
221
- resolve({ action: "accept", content } as never);
222
- };
223
- });
224
- });
225
- // The SDK fire-and-forgets the standalone GET SSE open on `initialized`; a failure
226
- // lands ONLY here. This is also where a dead session surfaces mid-life (the SDK's
227
- // own GET reconnect meets the restarted server's 404) — so it feeds recovery.
228
- client.onerror = (e) => {
229
- // TEARDOWN NOISE, not an error: closing a client aborts its SSE streams and the
230
- // SDK reports every abort here. When the close was OURS (effect cleanup on an
231
- // app switch, or a recycle that already replaced this client), a red error log
232
- // reads as breakage — it made a clean app switch look like a failure (observed
233
- // live 2026-08-16). Quiet log, no recovery; a live client's errors still recycle.
234
- if (cancelled || clientRef.current !== client) {
235
- // eslint-disable-next-line no-console
236
- console.debug("[unoverse] client closed:", (e as Error)?.message ?? e);
237
- return;
238
- }
239
- console.error("[unoverse] client error:", e);
240
- recycle(client, e);
241
- };
242
- clientRef.current = client;
243
-
244
- // The server pushes the live stream as `notifications/unoverse/event` over the
245
- // GET SSE channel. Custom notification methods land on the fallback handler —
246
- // route their `params` (one ServerMessage) into the store.
247
- client.fallbackNotificationHandler = async (notification) => {
248
- if (notification.method !== UNOVERSE_EVENT) return;
249
- const msg = (notification.params ?? {}) as unknown as ServerMessage;
250
- if ((msg as { type?: string }).type === "SESSION_READY") {
251
- setIsReady(true);
252
- return;
253
- }
254
- // A manifest-declared analytics moment, resolved SERVER-side beside the lifecycle
255
- // hook that shares it (componentLifecycle.ts). The SDK adds nothing and decides
256
- // nothing here: it hands the event to the same sink every node-attached emit uses,
257
- // and the host realm delivers or drops it. News, never instruction — it does not
258
- // touch the store.
259
- const evt = msg as unknown as { type?: string; event?: string; params?: Record<string, unknown> };
260
- if (evt.type === "ANALYTICS_EVENT") {
261
- if (typeof evt.event === "string" && evt.event) emitAnalytics(evt.event, evt.params ?? {});
262
- return;
263
- }
264
- // AN APP SWAP IS THE HOST'S CALL, not the renderer's. Handed straight out, like
265
- // ANALYTICS_EVENT above and for the same reason: it does not touch the store —
266
- // the conversation and everything already on screen survive a swap, which is the
267
- // whole point of swapping rather than starting again. The host re-points its one
268
- // engine (appEngine.tsx); a host that ignores this simply stays where it is.
269
- if (evt.type === "APP_SWITCH") {
270
- const next = (msg as unknown as { app?: unknown }).app;
271
- if (typeof next === "string" && next) onAppSwitch?.(next);
272
- return;
273
- }
274
- // A VOICE IN THE WORKFLOW ASKS THE PAGE TO JOIN (VOICE_STREAMING_GUIDE.md, "Call
275
- // Initialization"). The host's voice service joins; a host without one ignores it.
276
- if (evt.type === "JOIN_CALL") {
277
- const workflowId = (msg as unknown as { workflowId?: unknown }).workflowId;
278
- onJoinCallRef.current?.(typeof workflowId === "string" ? workflowId : undefined);
279
- return;
280
- }
281
- applyServerMessage(store, msg, seenRef.current);
282
- };
283
-
284
- // Inject the bearer on every underlying request (fresh per call → refresh-safe).
285
- // ALWAYS wrapped: dev tunnels need the ngrok bypass header, token or not
286
- // (ngrok free interstitial = 200 HTML no CORS = dead /stream in sandboxed
287
- // hosts; header is hostname-gated so prod requests are untouched).
288
- const authFetch = async (url: string | URL, init?: RequestInit): Promise<Response> => {
289
- const token = getAccessToken ? await getAccessToken() : undefined;
290
- const headers = new Headers(init?.headers);
291
- if (token) headers.set("Authorization", `Bearer ${token}`);
292
- if (String(url).includes("ngrok")) headers.set("ngrok-skip-browser-warning", "true");
293
- return fetch(url, { ...init, headers });
294
- };
295
- try {
296
- const transport = new StreamableHTTPClientTransport(
297
- new URL(`${streamUrl}${STREAM_ENDPOINT}`),
298
- { fetch: authFetch },
299
- );
300
- await client.connect(transport);
301
- if (cancelled || clientRef.current !== client) {
302
- await client.close().catch(() => {});
303
- return;
304
- }
305
- setIsConnected(true);
306
- setRefusal(null); // a fresh attempt owns a fresh verdict
307
- // Bind this stream to the session — the server then pushes the live component
308
- // stream and an immediate SESSION_READY.
309
- const bind = await client.callTool({
310
- name: "register_session",
311
- arguments: { userId, conversationId, workflowId, targetTriggerNode, chatId, ...(appIdRef.current ? { appId: appIdRef.current } : {}) },
312
- });
313
- // A refusal (public-entry gate, requires.role, …) is an isError tool RESULT,
314
- // not a thrown error — surface it and STOP, never retry: the server has
315
- // answered, and re-asking with the same identity is a polite way to loop
316
- // (the server logs the same refusal as REFUSED:). The reason is kept as state,
317
- // not just logged: a console.error alone left the host chip on "Connecting…"
318
- // forever with the cause visible to nobody (observed live 2026-08-16).
319
- if ((bind as { isError?: boolean }).isError) {
320
- const c = (bind as { content?: Array<{ type?: string; text?: string }> }).content;
321
- const reason = c?.[0]?.text ?? "unauthorized";
322
- console.error("[unoverse] session refused:", reason);
323
- setRefusal(reason);
324
- return;
325
- }
326
- // A completed re-bind is health — the next death starts its backoff fresh.
327
- attempt = 0;
328
- } catch (e) {
329
- if (cancelled) return;
330
- console.error("[unoverse] stream connect failed", e);
331
- recycle(client, e);
332
- }
333
- };
334
-
335
- void connect();
336
-
337
- return () => {
338
- cancelled = true;
339
- if (retryTimer !== null) clearTimeout(retryTimer);
340
- setIsConnected(false);
341
- setIsReady(false);
342
- setRefusal(null);
343
- void clientRef.current?.close().catch(() => {});
344
- clientRef.current = null;
345
- };
346
- }, [enabled, streamUrl, workflowId, targetTriggerNode, userId, conversationId, chatId, getAccessToken, store]);
347
-
348
- // ---- turn identity ----
349
- // `conversationId` names the CONVERSATION (session addressing, agent memory,
350
- // elicitation); `chatId` names ONE TURN (the store's response identity — every
351
- // component of a turn keys `chatId:nodeId`). The channel MINTS a fresh turn id per
352
- // outbound send; the `<conversationId>:t…` prefix keeps conversation-scoped waiter
353
- // resolution (elicitation accept) matching turn-scoped node waiters.
354
- const mintTurn = useCallback(
355
- () => `${chatId}:t${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`,
356
- [chatId],
357
- );
358
-
359
- // ---- outbound: fire the bound workflow over REST (§5b /execute) ----
360
- // No user turn — this is the raw trigger. `sendMessage` layers a user turn on top.
361
- const trigger = useCallback(
362
- (input?: Record<string, unknown>, turnId?: string) => {
363
- const turn = turnId ?? mintTurn();
364
- (async () => {
365
- // NATIVE MCP: fire the run via a `tools/call` on the live /stream session — NOT
366
- // the retired REST `/api/workflows/:id/execute` (UNOVERSE_MCP_APP_PROTOCOL
367
- // §0.3). Fire-and-forget: the run's UI streams back over this same session; the
368
- // caller's token rides the connection (server reads it from extra.authInfo).
369
- const client = clientRef.current;
370
- if (!client) {
371
- console.error("[unoverse] trigger dropped — MCP stream not connected yet");
372
- return;
373
- }
374
- // Same `input` envelope the REST route received (server unwraps it 1:1).
375
- const inputEnvelope = { chatId: turn, conversationId, userId, providerId: "gravity-ds", metadata: { targetTriggerNode }, ...input };
376
- try {
377
- await client.callTool({ name: "trigger", arguments: { workflowId, conversationId, input: inputEnvelope } });
378
- } catch (e) {
379
- console.error("[unoverse] trigger failed", e);
380
- }
381
- })();
382
- },
383
- [workflowId, targetTriggerNode, userId, conversationId, mintTurn],
384
- );
385
-
386
- // ---- outbound: send a message (optimistic user turn + trigger) ----
387
- const sendMessage = useCallback(
388
- (text: string) => {
389
- const message = text.trim();
390
- if (!message) return;
391
- const turn = mintTurn();
392
- store.addUserMessage(turn, message); // optimistic user turn
393
- // The answer opens under the message that asked for it. What is on screen is the
394
- // SERVER's fact (the standing set, the open pages); the send carries nothing about it.
395
- const run = turn;
396
- // Optimistically open the assistant turn too, so the avatar + thinking dots
397
- // appear IMMEDIATELY on send instead of waiting for the backend's
398
- // WORKFLOW_STARTED. startResponse is idempotent — the later WORKFLOW_STARTED
399
- // (and the first COMPONENT_INIT) reuse this same turn (responseFor by chatId).
400
- store.startResponse(run);
401
- trigger({ message }, run);
402
- },
403
- [mintTurn, store, trigger],
404
- );
405
-
406
- // ---- outbound: generic component action → MCP `user_action` tool ----
407
- const sendAction = useCallback(
408
- (action: string, data: Record<string, unknown>) => {
409
- void clientRef.current
410
- ?.callTool({ name: "user_action", arguments: { action, data, userId, conversationId, chatId } })
411
- .catch((e) => console.error("[unoverse] user_action failed", e));
412
- },
413
- [userId, conversationId, chatId],
414
- );
415
-
416
- // The workflow-selected template, reactively (a primitive snapshot so the store
417
- // subscription doesn't loop). The channel renders it via resources/read.
418
- // Stable subscribe fn — an inline arrow re-subscribes every render.
419
- const activeApp = useSyncExternalStore(
420
- useCallback((cb) => store.subscribe(cb), [store]),
421
- useCallback(() => store.getActiveApp().name, [store]),
422
- );
423
-
424
- // Answer a pending elicitation with the submitted component state (native path).
425
- const resolveElicitation = useCallback((state: Record<string, unknown>, action: "accept" | "cancel" = "accept"): boolean => {
426
- const pending = pendingElicitRef.current;
427
- if (!pending) return false;
428
- pending(state, action);
429
- return true;
430
- }, []);
431
-
432
- return { isConnected, isReady, refusal, sendMessage, trigger, sendAction, resolveElicitation, activeApp };
433
- }
@@ -1,70 +0,0 @@
1
- /**
2
- * <IsolatedRoot> — mounts `children` inside an open Shadow DOM for complete CSS isolation.
3
- *
4
- * The SDK is the renderer for EVERY consumer (the canvas node preview, the live channel,
5
- * the workbench, external host apps), so the isolation boundary lives HERE — once — rather
6
- * than being bolted onto each host (today only the canvas wraps; the channel renders
7
- * unprotected, so page styles and component styles leak both ways).
8
- *
9
- * Why a portal, not a nested React root: `createPortal` renders into the shadow root while
10
- * keeping the SAME React tree/context, so the SDK's client/store/theme context still flows
11
- * to the rendered components. A separate `createRoot` would sever that.
12
- *
13
- * Web-only — this file lives in `unoverse-react`, never in the framework-agnostic core
14
- * (Flutter/Swift have no DOM). Token VALUES are already resolved inline by the renderer, so
15
- * concrete styles work unchanged inside the shadow root; only inherited typography needs the
16
- * reset below, after which the served `theme.root` is the sole typographic baseline.
17
- */
18
- import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
19
- import { createPortal } from "react-dom";
20
-
21
- // Shadow DOM blocks style SELECTORS but NOT inheritance: inherited properties (font, colour,
22
- // line-height, letter-spacing) still pierce the boundary, and <button>/<input> fall back to
23
- // the UA font. `:host { all: initial }` neutralizes the inherited cascade; `display: contents`
24
- // keeps the host out of layout (the consumer owns the outer box). The themed child the SDK
25
- // passes in (theme.root) then re-establishes the design-system typography.
26
- const RESET_CSS = `
27
- :host { all: initial; display: contents; }
28
- .uno-isolate-root { box-sizing: border-box; }
29
- .uno-isolate-root *, .uno-isolate-root *::before, .uno-isolate-root *::after { box-sizing: border-box; }
30
- .uno-isolate-root button, .uno-isolate-root input, .uno-isolate-root textarea, .uno-isolate-root select {
31
- font: inherit; letter-spacing: inherit; color: inherit; margin: 0;
32
- }
33
- /* Scrollbars hidden by default across every scroll container — scrolling still works,
34
- the bar is just not painted. Generic chrome that design definitions can't author per-node (pseudo-
35
- elements), so it lives with the structural reset. */
36
- .uno-isolate-root * { scrollbar-width: none; }
37
- .uno-isolate-root *::-webkit-scrollbar { width: 0; height: 0; display: none; }
38
- `;
39
-
40
- export function IsolatedRoot({ children, rootStyle }: { children: ReactNode; rootStyle?: CSSProperties }) {
41
- const hostRef = useRef<HTMLDivElement>(null);
42
- const [shadow, setShadow] = useState<ShadowRoot | null>(null);
43
-
44
- useEffect(() => {
45
- const host = hostRef.current;
46
- if (!host) return;
47
- // Reuse an existing shadow root across re-mounts (attachShadow throws if called twice).
48
- setShadow(host.shadowRoot ?? host.attachShadow({ mode: "open" }));
49
- }, []);
50
-
51
- return (
52
- <div ref={hostRef} style={{ display: "contents" }}>
53
- {shadow &&
54
- createPortal(
55
- <>
56
- <style>{RESET_CSS}</style>
57
- {/* Apply the served DS render-root (theme.root: font family/size/weight/line-height/
58
- colour + smoothing) DIRECTLY to this REAL container — not a `display:contents`
59
- wrapper, whose inherited-font cascade is unreliable inside a shadow root. This is
60
- the Unoverse equivalent of the legacy `.gravity-component` baseline, so every node
61
- in the tree inherits the design-system typography. */}
62
- <div className="uno-isolate-root" style={rootStyle}>
63
- {children}
64
- </div>
65
- </>,
66
- shadow as unknown as Element,
67
- )}
68
- </div>
69
- );
70
- }
@@ -1,131 +0,0 @@
1
- // The ThinkingOrb component. One shared clock (performance.now) keeps
2
- // every mounted orb in phase; each instance runs its own rAF loop but
3
- // pauses automatically while offscreen (IntersectionObserver) or when
4
- // the tab is hidden (visibilitychange). Reduced-motion users get a
5
- // static representative frame that still follows the live theme.
6
-
7
- import { useEffect, useRef } from 'react';
8
- import { MODE_DRAWS } from './engine/registry';
9
- import { setInk } from './engine/core';
10
- import { resolvePreset } from './presets';
11
- import { useReducedMotion, useResolvedDark } from './theme';
12
- import type { OrbSize, ThinkingOrbProps } from './types';
13
-
14
- const LABELS: Record<string, string> = {
15
- working: 'Working…',
16
- searching: 'Searching…',
17
- solving: 'Solving…',
18
- listening: 'Listening…',
19
- connecting: 'Connecting…',
20
- weaving: 'Weaving…',
21
- composing: 'Composing…',
22
- breathing: 'Thinking…',
23
- shaping: 'Shaping…',
24
- halo: 'Waiting…',
25
- sonar: 'Listening…',
26
- shimmer: 'Thinking…',
27
- pulse: 'Responding…'
28
- };
29
-
30
- export function ThinkingOrb({
31
- state = 'working',
32
- size = 64,
33
- theme = 'auto',
34
- speed = 1,
35
- paused = false,
36
- ink = null,
37
- style,
38
- 'aria-label': ariaLabel,
39
- ...rest
40
- }: ThinkingOrbProps & { ink?: [number, number, number] | null }) {
41
- const ref = useRef<HTMLCanvasElement | null>(null);
42
- const dark = useResolvedDark(theme, ref);
43
- const reduced = useReducedMotion();
44
-
45
- useEffect(() => {
46
- const canvas = ref.current;
47
- if (!canvas) return;
48
- const dpr = Math.min(2, (typeof devicePixelRatio !== 'undefined' && devicePixelRatio) || 1);
49
- canvas.width = Math.round(size * dpr);
50
- canvas.height = Math.round(size * dpr);
51
- const ctx = canvas.getContext('2d');
52
- if (!ctx) return;
53
-
54
- // LOCAL EXTENSION (Unoverse): `size` is any positive number — the canvas and
55
- // draw run at the true size (the engine's radiusScale compensates), while the
56
- // TUNING snaps to the nearest of the two baked presets (20 inline / 64 avatar).
57
- const preset: OrbSize = size <= 32 ? 20 : 64;
58
- const { mode, speed: baseSpeed, opts } = resolvePreset(state, preset);
59
- const draw = MODE_DRAWS[mode];
60
- const effSpeed = baseSpeed * speed;
61
-
62
- const frame = (tSec: number) => {
63
- // Ink is a module-level current (frames are synchronous); set per frame so
64
- // orbs with different brand tokens on one page never bleed into each other.
65
- setInk(ink);
66
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
67
- ctx.clearRect(0, 0, size, size);
68
- draw(ctx, size, tSec, dark, opts);
69
- };
70
-
71
- // reduced motion → one static, deterministic frame
72
- if (reduced) {
73
- frame(0.6);
74
- return;
75
- }
76
-
77
- let raf = 0;
78
- let running = false;
79
- const loop = () => {
80
- frame((performance.now() / 1000) * effSpeed);
81
- if (running) raf = requestAnimationFrame(loop);
82
- };
83
- const start = () => {
84
- if (running || paused) return;
85
- running = true;
86
- raf = requestAnimationFrame(loop);
87
- };
88
- const stop = () => {
89
- running = false;
90
- cancelAnimationFrame(raf);
91
- };
92
-
93
- // draw at least one frame even when paused/offscreen
94
- frame((performance.now() / 1000) * effSpeed);
95
-
96
- // pause offscreen + on hidden tabs — free when not visible
97
- let visible = true;
98
- const io =
99
- typeof IntersectionObserver !== 'undefined'
100
- ? new IntersectionObserver(([entry]) => {
101
- visible = entry.isIntersecting;
102
- if (visible && document.visibilityState !== 'hidden') start();
103
- else stop();
104
- })
105
- : null;
106
- io?.observe(canvas);
107
- const onVis = () => {
108
- if (document.visibilityState === 'hidden') stop();
109
- else if (visible) start();
110
- };
111
- document.addEventListener('visibilitychange', onVis);
112
- if (!io) start();
113
-
114
- return () => {
115
- stop();
116
- io?.disconnect();
117
- document.removeEventListener('visibilitychange', onVis);
118
- };
119
- // eslint-disable-next-line react-hooks/exhaustive-deps -- ink compared by value: a tuple is a fresh identity every render
120
- }, [state, size, dark, speed, paused, reduced, ink?.join(",")]);
121
-
122
- return (
123
- <canvas
124
- ref={ref}
125
- role="img"
126
- aria-label={ariaLabel ?? LABELS[state]}
127
- style={{ width: size, height: size, display: 'block', ...style }}
128
- {...rest}
129
- />
130
- );
131
- }