@unoverse-platform/studio 0.1.68 → 0.1.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/studio.mjs +1 -1
- package/local/catalog.ts +2 -2
- package/local/mcp.ts +3 -2
- package/local/plugin.ts +13 -11
- package/local/sample-app/chat/components/chat-region.yaml +10 -0
- package/local/sample-app/chat/components/core-focus.yaml +18 -0
- package/local/sample-app/chat/components/core.yaml +14 -0
- package/local/{sample-template → sample-app}/chat/manifest.yaml +10 -15
- package/local/{sample-template/chat → sample-component/text-chat}/components/suggestions.yaml +3 -3
- package/local/{sample-template/chat/states/conversation.yaml → sample-component/text-chat/layouts/chat-active.yaml} +0 -1
- package/local/{sample-template/chat/states/welcome.yaml → sample-component/text-chat/layouts/chat-welcome.yaml} +0 -1
- package/local/sample-component/text-chat/layouts/chat.yaml +77 -0
- package/local/sample-component/text-chat/manifest.yaml +10 -0
- package/local/sample-component/text-chat/text-chat.yaml +59 -0
- package/local/scaffold.mjs +14 -9
- package/package.json +2 -1
- package/screens/ComponentsView.tsx +8 -1
- package/screens/NativeAppHost.tsx +5 -5
- package/screens/TemplatesView.tsx +77 -77
- package/screens/chrome.ts +1 -1
- package/screens/states.ts +3 -3
- package/src/App.tsx +20 -9
- package/src/NewProject.tsx +1 -1
- package/src/host.ts +2 -2
- package/vendor/sdk/appEngine.tsx +19 -19
- package/vendor/sdk/lib/{template.tsx → app.tsx} +11 -11
- package/vendor/sdk/lib/connection.tsx +10 -10
- package/vendor/sdk/lib/core/actions.ts +3 -3
- package/vendor/sdk/lib/core/{templates.ts → apps.ts} +1 -1
- package/vendor/sdk/lib/core/connection.ts +17 -15
- package/vendor/sdk/lib/core/index.ts +2 -2
- package/vendor/sdk/lib/core/store.ts +22 -22
- package/vendor/sdk/lib/core/types.ts +2 -2
- package/vendor/sdk/lib/index.ts +3 -3
- package/vendor/sdk/lib/render.tsx +1 -1
- package/vendor/sdk/lib/voice.tsx +2 -2
- package/vendor/sdk/main.tsx +7 -7
- package/vendor/skills/unoverse-create/SKILL.md +9 -9
- package/vendor/skills/unoverse-create/references/{template.md → app.md} +34 -34
- package/vendor/skills/unoverse-create/references/brand.md +8 -8
- package/vendor/skills/unoverse-create/references/component.md +39 -19
- package/vendor/skills/unoverse-create/references/node.md +2 -2
- package/vendor/skills/unoverse-prompt-component/SKILL.md +2 -2
- package/local/sample-template/chat/components/core-focus.yaml +0 -46
- package/local/sample-template/chat/components/core.yaml +0 -41
- /package/local/{sample-template → sample-app}/chat/components/focus-overlay.yaml +0 -0
- /package/local/{sample-template → sample-app}/chat/layouts/focused.yaml +0 -0
- /package/local/{sample-template → sample-app}/chat/layouts/standard.yaml +0 -0
- /package/local/{sample-template/chat → sample-component/text-chat}/components/assistant-turn.yaml +0 -0
- /package/local/{sample-template/chat → sample-component/text-chat}/components/header.yaml +0 -0
- /package/local/{sample-template/chat → sample-component/text-chat}/components/thinking-dots.yaml +0 -0
- /package/local/{sample-template/chat → sample-component/text-chat}/components/user-turn.yaml +0 -0
package/vendor/sdk/appEngine.tsx
CHANGED
|
@@ -13,7 +13,7 @@ import { postToEmbedHost } from "./mcpAppBridge";
|
|
|
13
13
|
import {
|
|
14
14
|
UnoverseClient,
|
|
15
15
|
ComponentStore,
|
|
16
|
-
|
|
16
|
+
StreamedUnoverseApp,
|
|
17
17
|
useUnoverseConnection,
|
|
18
18
|
useUnoverseTheme,
|
|
19
19
|
computeAppWidth,
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
export interface AppConfig {
|
|
29
29
|
serverUrl: string;
|
|
30
30
|
apiUrl?: string;
|
|
31
|
-
|
|
31
|
+
appId: string;
|
|
32
32
|
getAccessToken?: () => Promise<string | null> | string | null;
|
|
33
33
|
userId?: string;
|
|
34
34
|
conversationId: string;
|
|
@@ -42,7 +42,7 @@ export interface AppConfig {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
export function AppEngine(cfg: AppConfig) {
|
|
45
|
-
const { serverUrl, apiUrl,
|
|
45
|
+
const { serverUrl, apiUrl, appId, getAccessToken, userId, conversationId, surface, analyticsDebug } = cfg;
|
|
46
46
|
|
|
47
47
|
// ONE STORE PER CONVERSATION. The host may reuse this engine (the native-host
|
|
48
48
|
// iframe posts new config on app switch), and a template swap keeps the store by
|
|
@@ -63,9 +63,9 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
63
63
|
// Setting state during render makes React re-render BEFORE the commit, so the
|
|
64
64
|
// connection effect only ever sees either the old app intact or an empty binding —
|
|
65
65
|
// never the cross-app hybrid.
|
|
66
|
-
const [boundFor, setBoundFor] = useState(
|
|
67
|
-
if (boundFor !==
|
|
68
|
-
setBoundFor(
|
|
66
|
+
const [boundFor, setBoundFor] = useState(appId);
|
|
67
|
+
if (boundFor !== appId) {
|
|
68
|
+
setBoundFor(appId);
|
|
69
69
|
setBinding({});
|
|
70
70
|
}
|
|
71
71
|
// STATE-OWNED WIDTH (design data): the layout root's `appWidth` = the core surface's
|
|
@@ -81,11 +81,11 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
81
81
|
const [def, setDef] = useState<UnoverseDefinition | null>(null);
|
|
82
82
|
const lastWidthRef = useRef<string | undefined>(undefined);
|
|
83
83
|
// Named app sizes resolve via the served theme (theme.appSize), like every token.
|
|
84
|
-
// The theme's name is the template's org. When
|
|
84
|
+
// The theme's name is the template's org. When appId already carries it
|
|
85
85
|
// ("org/name" — every production boot), start the theme read IMMEDIATELY, in
|
|
86
86
|
// parallel with the template read; waiting for the definition just to learn an org
|
|
87
87
|
// we were handed serialized two round trips for nothing. Bare ids (dev) still wait.
|
|
88
|
-
const orgFromId =
|
|
88
|
+
const orgFromId = appId.includes("/") ? appId.split("/")[0] : null;
|
|
89
89
|
const theme = useUnoverseTheme(
|
|
90
90
|
client,
|
|
91
91
|
orgFromId ? `${orgFromId}/light` : def ? (def.org ? `${def.org}/light` : "light") : null,
|
|
@@ -99,7 +99,7 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
99
99
|
// the previous app's trigger within the same commit.
|
|
100
100
|
client
|
|
101
101
|
.connect()
|
|
102
|
-
.then(() => (client as any).client.readResource({ uri: `unoverse://apps/${
|
|
102
|
+
.then(() => (client as any).client.readResource({ uri: `unoverse://apps/${appId.toLowerCase()}` }))
|
|
103
103
|
.then((res: any) => {
|
|
104
104
|
const parsed = res?.contents?.[0]?.text ? JSON.parse(res.contents[0].text) : null;
|
|
105
105
|
const m = parsed?.manifest ?? parsed ?? {};
|
|
@@ -113,7 +113,7 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
113
113
|
// definition too — same resource the renderer loads.
|
|
114
114
|
client
|
|
115
115
|
.connect()
|
|
116
|
-
.then(() => client.readDefinition(`unoverse://
|
|
116
|
+
.then(() => client.readDefinition(`unoverse://apps/${appId}/definition`))
|
|
117
117
|
.then((d) => {
|
|
118
118
|
if (alive) setDef(d ?? null);
|
|
119
119
|
})
|
|
@@ -121,7 +121,7 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
121
121
|
return () => {
|
|
122
122
|
alive = false;
|
|
123
123
|
};
|
|
124
|
-
}, [client,
|
|
124
|
+
}, [client, appId]);
|
|
125
125
|
|
|
126
126
|
// Report the app's width UP to the embedding host (iframe boundary → the twin of the
|
|
127
127
|
// inbound `unoverse:config` channel), re-reporting whenever a panel surface opens or
|
|
@@ -145,13 +145,13 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
145
145
|
useEffect(() => {
|
|
146
146
|
// A NEW APP RE-ANNOUNCES ITS WIDTH, ALWAYS. The native host keeps ONE iframe for every
|
|
147
147
|
// app (its srcdoc is keyed on the server, not the app — it just re-posts
|
|
148
|
-
// `unoverse:config` with a new
|
|
148
|
+
// `unoverse:config` with a new appId) and this engine is re-rendered in place, so
|
|
149
149
|
// the change-guard below outlives the switch — while the host RESETS its frame to the
|
|
150
150
|
// default on it (NativeAppHost/TemplatesView). Two apps of the same declared width is
|
|
151
151
|
// the ordinary case (one org, one `chat` size), and the incoming app then reported
|
|
152
152
|
// nothing: its frame sat at the host default (100%) while its core panel held its
|
|
153
153
|
// declared width, leaving a dead band beside it (observed live 2026-08-13,
|
|
154
|
-
// bpp-voice
|
|
154
|
+
// bpp-voice). Clearing the guard here makes the first report after an app
|
|
155
155
|
// switch unconditional; the guard still suppresses repeats within one app.
|
|
156
156
|
lastWidthRef.current = undefined;
|
|
157
157
|
const report = () => {
|
|
@@ -208,7 +208,7 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
208
208
|
}, [conn.isReady, conn.isConnected, conn.refusal, failure]);
|
|
209
209
|
|
|
210
210
|
// VOICE (§2e-1): a `service: "voice"` app runs the native voice service — which now lives
|
|
211
|
-
// in the shared renderer (
|
|
211
|
+
// in the shared renderer (StreamedUnoverseApp), identical to Studio. The host only
|
|
212
212
|
// supplies the connection identity it already holds; startCall/endCall/toggleMute are
|
|
213
213
|
// answered inside the renderer, so this host wires NO voice buttons. Absent → inert.
|
|
214
214
|
const voiceConfig = useMemo<UseVoiceServiceConfig | undefined>(
|
|
@@ -235,10 +235,10 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
235
235
|
);
|
|
236
236
|
|
|
237
237
|
return (
|
|
238
|
-
<
|
|
238
|
+
<StreamedUnoverseApp
|
|
239
239
|
client={client}
|
|
240
240
|
store={store}
|
|
241
|
-
uri={`unoverse://
|
|
241
|
+
uri={`unoverse://apps/${appId}/definition`}
|
|
242
242
|
voice={voiceConfig}
|
|
243
243
|
onAction={(action, data, meta) => {
|
|
244
244
|
const t = typeof action === "string" ? action : (action as any)?.type;
|
|
@@ -270,10 +270,10 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
270
270
|
// pre-rename alias) into template state NOW — e.g. a "focus" app pre-opens
|
|
271
271
|
// the focus surface, whose slot shows its fallback (skeleton) until the
|
|
272
272
|
// workflow streams the component in. Same template-state key the node's
|
|
273
|
-
//
|
|
274
|
-
//
|
|
273
|
+
// APP_DATA emit writes; no new vocabulary. An "app" app IS the surface
|
|
274
|
+
// — nothing to write.
|
|
275
275
|
const declared = parsed?.manifest?.defaultState ?? parsed?.manifest?.mode ?? parsed?.defaultState ?? parsed?.mode;
|
|
276
|
-
if (declared && declared !== "
|
|
276
|
+
if (declared && declared !== "app") store.mergeAppState({ defaultState: declared });
|
|
277
277
|
conn.trigger({ app, metadata: { targetTriggerNode: b.trigger } });
|
|
278
278
|
})
|
|
279
279
|
.catch((e: any) => console.error("[app] launch failed:", e));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* <
|
|
2
|
+
* <StreamedUnoverseApp> — renders a TEMPLATE definition (the layout) and
|
|
3
3
|
* fills its ComponentSlots from the store's timeline.
|
|
4
4
|
*
|
|
5
5
|
* A template owns nothing (UNOVERSE_SPEC.md §2e-0): it reads the shared state
|
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
} from "./core";
|
|
35
35
|
import { renderNode, styleToCss, keyframesCss, type ActionHandler, type SlotResolver } from "./render";
|
|
36
36
|
|
|
37
|
-
// The pure template derivations moved to core/
|
|
37
|
+
// The pure template derivations moved to core/apps.ts (the portable brain —
|
|
38
38
|
// framework-agnostic, guarded by sdk-core-portable.test.ts). Re-exported here so
|
|
39
39
|
// existing consumers keep their import path.
|
|
40
40
|
export { collectSurfacedViews, computeAppWidth, orderedClaims, resolveActiveLayout, resolveActiveView, selectPointers, viewOf } from "./core";
|
|
@@ -43,10 +43,10 @@ import { useUnoverseTheme, ensureFontStylesheets } from "./theme";
|
|
|
43
43
|
import { IsolatedRoot } from "./isolate";
|
|
44
44
|
import { useVoiceService, type UseVoiceServiceConfig } from "./voice";
|
|
45
45
|
|
|
46
|
-
export interface
|
|
46
|
+
export interface StreamedUnoverseAppProps {
|
|
47
47
|
client: UnoverseClient;
|
|
48
48
|
store: ComponentStore;
|
|
49
|
-
/** e.g. "unoverse://
|
|
49
|
+
/** e.g. "unoverse://apps/acme/KeyService/definition" */
|
|
50
50
|
uri: string;
|
|
51
51
|
onAction?: ActionHandler;
|
|
52
52
|
theme?: ResolvedTheme;
|
|
@@ -72,7 +72,7 @@ const INERT_VOICE: UseVoiceServiceConfig = {
|
|
|
72
72
|
session: { userId: "", conversationId: "", workflowId: "", targetTriggerNode: "" },
|
|
73
73
|
};
|
|
74
74
|
|
|
75
|
-
export function
|
|
75
|
+
export function StreamedUnoverseApp({ client, store, uri, onAction, theme: themeProp, props, isolate = true, voice: voiceConfig }: StreamedUnoverseAppProps) {
|
|
76
76
|
const [def, setDef] = useState<UnoverseDefinition | null>(null);
|
|
77
77
|
const [error, setError] = useState<string | null>(null);
|
|
78
78
|
// The voice native service lives HERE (shared), not in each host. Called every render
|
|
@@ -189,14 +189,14 @@ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme:
|
|
|
189
189
|
* dies with the thing dismissed: a different component taking the latch is a different
|
|
190
190
|
* key, and its pill comes up as it should rather than being suppressed by a stale flag.
|
|
191
191
|
*/
|
|
192
|
-
const tstate = store.
|
|
192
|
+
const tstate = store.getAppState() as Record<string, unknown>;
|
|
193
193
|
const dismissedRaw = tstate.latchDismissed;
|
|
194
194
|
const held = def ? resolveLatch(def, store, typeof dismissedRaw === "string" ? dismissedRaw : undefined) : null;
|
|
195
195
|
useEffect(() => {
|
|
196
196
|
if (dismissedRaw === true) {
|
|
197
197
|
// Resolve the bare flag against whoever holds it RIGHT NOW, then re-derive.
|
|
198
198
|
const current = def ? resolveLatch(def, store) : null;
|
|
199
|
-
store.
|
|
199
|
+
store.mergeAppState({ latchDismissed: current ? current.key : "" });
|
|
200
200
|
return;
|
|
201
201
|
}
|
|
202
202
|
const l = held?.latch as { title?: unknown } | undefined;
|
|
@@ -208,7 +208,7 @@ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme:
|
|
|
208
208
|
// its condition — which is why they are separate keys and not the condition itself.
|
|
209
209
|
const latched = held != null;
|
|
210
210
|
if (tstate.latched !== latched || tstate.latchTitle !== title || tstate.latchKey !== (held?.key ?? ""))
|
|
211
|
-
store.
|
|
211
|
+
store.mergeAppState({ latched, latchTitle: title, latchKey: held?.key ?? "" });
|
|
212
212
|
}, [
|
|
213
213
|
held?.key,
|
|
214
214
|
held?.latch,
|
|
@@ -226,7 +226,7 @@ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme:
|
|
|
226
226
|
if (!def || !theme) return <div>Loading {uri}…</div>;
|
|
227
227
|
|
|
228
228
|
// ONE action interpreter for the whole tree: template chrome runs the same
|
|
229
|
-
// `dispatchAction` (core/actions.ts) every leaf runs — `
|
|
229
|
+
// `dispatchAction` (core/actions.ts) every leaf runs — `setAppValue` writes
|
|
230
230
|
// template state, `then` chains, anything else routes to the channel. Template
|
|
231
231
|
// chrome has NO component slice, so `setValue` here is an authoring error (it
|
|
232
232
|
// belongs on the component); the empty ids make that write inert.
|
|
@@ -321,7 +321,7 @@ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme:
|
|
|
321
321
|
const frame = appWidth
|
|
322
322
|
? ({ ...node.frame, style: { ...(node.frame.style ?? {}), width: appWidth, flex: "0 0 auto" } } as UnoverseNode)
|
|
323
323
|
: node.frame;
|
|
324
|
-
return renderNode(frame, store.
|
|
324
|
+
return renderNode(frame, store.getAppState() as Record<string, unknown>, dispatch, theme, key ?? "frame", innerSlot, def?.defs);
|
|
325
325
|
}
|
|
326
326
|
return pointers.map((p) => leaf(p));
|
|
327
327
|
};
|
|
@@ -404,7 +404,7 @@ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme:
|
|
|
404
404
|
// The active template's OWN state — one generic bag of the DEV's chosen keys (draft,
|
|
405
405
|
// openPanel, suggestions data, focusMode, voice call state, …). No per-feature
|
|
406
406
|
// projections; the engine knows no key names (UNOVERSE_STATE_MODEL §2).
|
|
407
|
-
...store.
|
|
407
|
+
...store.getAppState(),
|
|
408
408
|
// Conversation-derived facts — READ OFF the timeline / lifecycle, never stored.
|
|
409
409
|
isEmpty,
|
|
410
410
|
hasMessages: !isEmpty,
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* sending messages. Inbound `notifications/unoverse/event` messages feed the single
|
|
7
7
|
* `ComponentStore` via the pure `applyServerMessage` mapper.
|
|
8
8
|
*
|
|
9
|
-
* Per
|
|
9
|
+
* Per UNOVERSE_MCP_APP_PROTOCOL.md §5/§5a/§5b: the component stream rides MCP
|
|
10
10
|
* (NOT the legacy `/ws/gravity` WS — that's reserved for live audio). The node
|
|
11
11
|
* executes in the same Unoverse process that serves this stream, so there's no
|
|
12
12
|
* gateway relay and no Redis in the component path.
|
|
@@ -65,8 +65,8 @@ export interface UnoverseConnection {
|
|
|
65
65
|
* channel then falls back to `sendAction`). */
|
|
66
66
|
resolveElicitation: (state: Record<string, unknown>) => boolean;
|
|
67
67
|
/** The workflow-selected template (MCP app) name, or null. The channel renders
|
|
68
|
-
* `unoverse://
|
|
69
|
-
|
|
68
|
+
* `unoverse://apps/{activeApp}/definition` when set — the official resources/read load. */
|
|
69
|
+
activeApp: string | null;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
/** The data-plane endpoint on the Unoverse server (§5b). */
|
|
@@ -171,7 +171,7 @@ export function useUnoverseConnection(
|
|
|
171
171
|
const connect = async () => {
|
|
172
172
|
if (cancelled) return;
|
|
173
173
|
// Declare the ELICITATION capability (form mode) — the native collection path
|
|
174
|
-
// (
|
|
174
|
+
// (UNOVERSE_MCP_APP_PROTOCOL §3.3): the server pauses inside an app tool
|
|
175
175
|
// call and asks THIS session for the user's structured input. The interactive
|
|
176
176
|
// component already on screen IS the form; its `submit` answers the elicitation.
|
|
177
177
|
const client = new Client(
|
|
@@ -317,7 +317,7 @@ export function useUnoverseConnection(
|
|
|
317
317
|
const turn = turnId ?? mintTurn();
|
|
318
318
|
(async () => {
|
|
319
319
|
// NATIVE MCP: fire the run via a `tools/call` on the live /stream session — NOT
|
|
320
|
-
// the retired REST `/api/workflows/:id/execute` (
|
|
320
|
+
// the retired REST `/api/workflows/:id/execute` (UNOVERSE_MCP_APP_PROTOCOL
|
|
321
321
|
// §0.3). Fire-and-forget: the run's UI streams back over this same session; the
|
|
322
322
|
// caller's token rides the connection (server reads it from extra.authInfo).
|
|
323
323
|
const client = clientRef.current;
|
|
@@ -378,8 +378,8 @@ export function useUnoverseConnection(
|
|
|
378
378
|
const cache = clientRef.current as unknown as
|
|
379
379
|
| { peekDefinition?: (uri: string) => Parameters<typeof resolveLatch>[0] }
|
|
380
380
|
| null;
|
|
381
|
-
const tmpl = store.
|
|
382
|
-
const def = tmpl ? (cache?.peekDefinition?.(`unoverse://
|
|
381
|
+
const tmpl = store.getActiveApp().name;
|
|
382
|
+
const def = tmpl ? (cache?.peekDefinition?.(`unoverse://apps/${tmpl}/definition`) ?? null) : null;
|
|
383
383
|
const held = def ? resolveLatch(def, store) : null;
|
|
384
384
|
trigger(
|
|
385
385
|
held
|
|
@@ -412,9 +412,9 @@ export function useUnoverseConnection(
|
|
|
412
412
|
// The workflow-selected template, reactively (a primitive snapshot so the store
|
|
413
413
|
// subscription doesn't loop). The channel renders it via resources/read.
|
|
414
414
|
// Stable subscribe fn — an inline arrow re-subscribes every render.
|
|
415
|
-
const
|
|
415
|
+
const activeApp = useSyncExternalStore(
|
|
416
416
|
useCallback((cb) => store.subscribe(cb), [store]),
|
|
417
|
-
useCallback(() => store.
|
|
417
|
+
useCallback(() => store.getActiveApp().name, [store]),
|
|
418
418
|
);
|
|
419
419
|
|
|
420
420
|
// Answer a pending elicitation with the submitted component state (native path).
|
|
@@ -425,5 +425,5 @@ export function useUnoverseConnection(
|
|
|
425
425
|
return true;
|
|
426
426
|
}, []);
|
|
427
427
|
|
|
428
|
-
return { isConnected, isReady, refusal, sendMessage, trigger, sendAction, resolveElicitation,
|
|
428
|
+
return { isConnected, isReady, refusal, sendMessage, trigger, sendAction, resolveElicitation, activeApp };
|
|
429
429
|
}
|
|
@@ -73,14 +73,14 @@ export function dispatchAction(
|
|
|
73
73
|
const entered = patch.view ?? patch.defaultState;
|
|
74
74
|
ctx.sendToServer?.("viewEntered", { layout: entered, view: entered });
|
|
75
75
|
}
|
|
76
|
-
} else if (spec.type === "
|
|
76
|
+
} else if (spec.type === "setAppValue") {
|
|
77
77
|
// Write the dev's chosen keys into TEMPLATE state — the template-state twin of setValue.
|
|
78
78
|
// No component scope. The dev builds disclosure / focus / etc. from a key THEY name
|
|
79
79
|
// (openPanel, focusMode, …) and reads it via `visibleWhen`. The SDK hardcodes NO UI
|
|
80
80
|
// concept — only "write a key to component state" and "write a key to template state".
|
|
81
81
|
const patch: Record<string, unknown> = {};
|
|
82
82
|
for (const { key, value } of spec.values ?? []) patch[key] = resolveValue(value, scope);
|
|
83
|
-
ctx.store.
|
|
83
|
+
ctx.store.mergeAppState(patch);
|
|
84
84
|
} else if (spec.type === "input") {
|
|
85
85
|
// A controlled Input changed — write its bound field back into the state bag that
|
|
86
86
|
// OWNS this tree (local two-way binding; every keystroke is local, never a server
|
|
@@ -92,7 +92,7 @@ export function dispatchAction(
|
|
|
92
92
|
const field = typeof scope.field === "string" ? scope.field : undefined;
|
|
93
93
|
if (field) {
|
|
94
94
|
if (ctx.nodeId) ctx.store.apply({ type: "COMPONENT_DATA", chatId: ctx.chatId, nodeId: ctx.nodeId, data: { [field]: scope.text } });
|
|
95
|
-
else ctx.store.
|
|
95
|
+
else ctx.store.mergeAppState({ [field]: scope.text });
|
|
96
96
|
}
|
|
97
97
|
} else {
|
|
98
98
|
// SERVER — narrow the payload by `send` if given, else forward the whole scope.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Everything a template "decides" is DERIVED here from the store, never stored:
|
|
5
5
|
* the active layout (name-sync), the active view (with the pinned/guest-control
|
|
6
6
|
* law), which pointers a reaction surface selects, which views are claimed, and
|
|
7
|
-
* the app's declared width. `
|
|
7
|
+
* the app's declared width. `StreamedUnoverseApp` (the React binding) calls
|
|
8
8
|
* these; a platform port (Flutter/Swift/Kotlin) re-implements its own binding and
|
|
9
9
|
* calls the SAME derivations — this file IS the port list for the template tier.
|
|
10
10
|
*
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* React hook (react/src/connection.tsx) own only socket lifecycle.
|
|
8
8
|
*
|
|
9
9
|
* The Unoverse MCP server is NOT involved here — it's the control plane
|
|
10
|
-
* (definitions + theme). See
|
|
10
|
+
* (definitions + theme). See UNOVERSE_MCP_APP_PROTOCOL.md §5a.
|
|
11
11
|
*/
|
|
12
12
|
import type { ComponentStore } from "./store";
|
|
13
13
|
|
|
@@ -23,7 +23,7 @@ export interface ServerComponentInit {
|
|
|
23
23
|
/** Marks a TEMPLATE directive (legacy emits a fake COMPONENT_INIT with nodeId
|
|
24
24
|
* `<name>_template`). Templates are SERVED apps, loaded via resources/read — never
|
|
25
25
|
* rendered as components — so we route these to the template plane, not the timeline. */
|
|
26
|
-
metadata?: {
|
|
26
|
+
metadata?: { isApp?: boolean; [k: string]: unknown };
|
|
27
27
|
}
|
|
28
28
|
export interface ServerComponentData {
|
|
29
29
|
type: "COMPONENT_DATA" | "OBJECT_DATA";
|
|
@@ -37,17 +37,19 @@ export interface ServerWorkflowState {
|
|
|
37
37
|
chatId: string;
|
|
38
38
|
workflowId?: string;
|
|
39
39
|
workflowRunId?: string | null;
|
|
40
|
-
/**
|
|
41
|
-
*
|
|
42
|
-
metadata
|
|
40
|
+
/** APP SELECTION rides here (SPEC §5b: `{ app, appMode }`) — the OFFICIAL signal for
|
|
41
|
+
* which MCP-app shell to load via resources/read. Note this `app` is a bare app NAME.
|
|
42
|
+
* A content ROW's `metadata.app` is a different bag entirely and holds a component URI;
|
|
43
|
+
* the two never meet on one object. */
|
|
44
|
+
metadata?: { app?: string; appMode?: string; [k: string]: unknown };
|
|
43
45
|
}
|
|
44
46
|
export interface ServerSessionReady {
|
|
45
47
|
type: "SESSION_READY";
|
|
46
48
|
}
|
|
47
49
|
/** Generic write into TEMPLATE STATE — the template-state twin of COMPONENT_DATA. The
|
|
48
50
|
* producer names the keys (e.g. the Suggestions node sends `{ faqs }`); core stays generic. */
|
|
49
|
-
export interface
|
|
50
|
-
type: "
|
|
51
|
+
export interface ServerAppData {
|
|
52
|
+
type: "APP_DATA";
|
|
51
53
|
data: Record<string, unknown>;
|
|
52
54
|
}
|
|
53
55
|
export type ServerMessage =
|
|
@@ -55,7 +57,7 @@ export type ServerMessage =
|
|
|
55
57
|
| ServerComponentData
|
|
56
58
|
| ServerWorkflowState
|
|
57
59
|
| ServerSessionReady
|
|
58
|
-
|
|
|
60
|
+
| ServerAppData
|
|
59
61
|
| { type: string; [k: string]: unknown }; // forward-compat: unknown types ignored
|
|
60
62
|
|
|
61
63
|
// Track first-seen components per `chatId:nodeId` so a repeated COMPONENT_INIT
|
|
@@ -74,12 +76,12 @@ export function applyServerMessage(store: ComponentStore, msg: ServerMessage, se
|
|
|
74
76
|
const m = msg as ServerComponentInit;
|
|
75
77
|
if (!m.chatId || !m.nodeId) return;
|
|
76
78
|
// TEMPLATE DIRECTIVE — a template is a SERVED MCP app (protocol §0.1), loaded via
|
|
77
|
-
// resources/read unoverse://
|
|
79
|
+
// resources/read unoverse://apps/{org}/{name}/definition; it is NEVER a streamed component. Record
|
|
78
80
|
// WHICH app to load (the channel reads it); do not place it in the component timeline
|
|
79
81
|
// (that produced "Unknown Unoverse component: <Template>"). The legacy componentUrl
|
|
80
82
|
// .js bundle is ignored — Unoverse loads the neutral definition, not a React bundle.
|
|
81
|
-
if (m.metadata?.
|
|
82
|
-
if (m.component?.type) store.
|
|
83
|
+
if (m.metadata?.isApp === true || m.nodeId.endsWith("_template")) {
|
|
84
|
+
if (m.component?.type) store.setActiveApp(m.component.type, m.component.props ?? {});
|
|
83
85
|
return;
|
|
84
86
|
}
|
|
85
87
|
const key = `${m.chatId}:${m.nodeId}`;
|
|
@@ -105,9 +107,9 @@ export function applyServerMessage(store: ComponentStore, msg: ServerMessage, se
|
|
|
105
107
|
case "WORKFLOW_STATE": {
|
|
106
108
|
const m = msg as ServerWorkflowState;
|
|
107
109
|
// OFFICIAL template selection (SPEC §5b): the workflow names the app shell in
|
|
108
|
-
// metadata.
|
|
110
|
+
// metadata.app; the channel loads it via resources/read. (appMode
|
|
109
111
|
// switch/stack/replace is future — default "switch".)
|
|
110
|
-
if (m.metadata?.
|
|
112
|
+
if (m.metadata?.app) store.setActiveApp(m.metadata.app);
|
|
111
113
|
// Open an EMPTY streaming turn so the timeline shows a thinking state BEFORE the
|
|
112
114
|
// first component (legacy processWorkflowState.ts; aiContext treats these as
|
|
113
115
|
// streaming). COMPONENT_INIT then reuses it. Without this, the assistant turn is
|
|
@@ -137,12 +139,12 @@ export function applyServerMessage(store: ComponentStore, msg: ServerMessage, se
|
|
|
137
139
|
}
|
|
138
140
|
// No AUDIO_STATE case: voice is a NATIVE SERVICE on the WS audio lane (useVoiceService),
|
|
139
141
|
// NOT the structured MCP stream. Audio-state events never arrive here (UNOVERSE_SPEC §2e-1).
|
|
140
|
-
case "
|
|
142
|
+
case "APP_DATA": {
|
|
141
143
|
// Generic write into TEMPLATE STATE (the template-state twin of COMPONENT_DATA). The
|
|
142
144
|
// PRODUCER names the keys (the Suggestions node sends `{ faqs }`); the template picks
|
|
143
145
|
// them up. Core knows NO key names — fully generic (UNOVERSE_STATE_MODEL §2/§8).
|
|
144
146
|
const data = ((msg as { data?: Record<string, unknown> }).data ?? {}) as Record<string, unknown>;
|
|
145
|
-
store.
|
|
147
|
+
store.mergeAppState(data);
|
|
146
148
|
return;
|
|
147
149
|
}
|
|
148
150
|
default:
|
|
@@ -22,7 +22,7 @@ export {
|
|
|
22
22
|
type ServerComponentData,
|
|
23
23
|
type ServerWorkflowState,
|
|
24
24
|
type ServerSessionReady,
|
|
25
|
-
type
|
|
25
|
+
type ServerAppData,
|
|
26
26
|
} from "./connection";
|
|
27
27
|
|
|
28
28
|
// The action interpreter (§2e-3) — local `setValue` store write + server route.
|
|
@@ -45,7 +45,7 @@ export {
|
|
|
45
45
|
resolveLatch,
|
|
46
46
|
selectPointers,
|
|
47
47
|
viewOf,
|
|
48
|
-
} from "./
|
|
48
|
+
} from "./apps";
|
|
49
49
|
|
|
50
50
|
// Experience analytics (UNOVERSE_ANALYTICS.md) — the SDK emits; the host decides where to.
|
|
51
51
|
export {
|
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
* turn holds POINTERS (the `chatId:nodeId` keys) into the data
|
|
12
12
|
* plane — never copies. This is what templates read.
|
|
13
13
|
*
|
|
14
|
-
* Plus a generic TEMPLATE-STATE bag (`
|
|
14
|
+
* Plus a generic TEMPLATE-STATE bag (`appState`) + `lifecycle` (derived from the
|
|
15
15
|
* conversation) + `draft` (the shared composer buffer). The SDK hardcodes NO UI concept —
|
|
16
16
|
* disclosure/focus/etc. are keys the dev writes via the `setValue` (component) or
|
|
17
|
-
* `
|
|
17
|
+
* `setAppValue` (template) actions and reads via `visibleWhen`. (Voice is a native
|
|
18
18
|
* SERVICE — WS audio lane, react package — whose call state a producer merges into
|
|
19
19
|
* template state, per UNOVERSE_SPEC §2e-1. The store holds structured state only.)
|
|
20
20
|
*
|
|
@@ -92,12 +92,12 @@ export class ComponentStore {
|
|
|
92
92
|
private notifyScheduled = false;
|
|
93
93
|
private seq = 0;
|
|
94
94
|
// --- active template (the workflow-selected MCP app shell; §0.1) ---
|
|
95
|
-
private
|
|
96
|
-
private
|
|
95
|
+
private activeAppName: string | null = null;
|
|
96
|
+
private activeAppProps: Record<string, unknown> = {};
|
|
97
97
|
// --- template state: the active template's OWN generic bag (UNOVERSE_STATE_MODEL §2).
|
|
98
98
|
// draft / suggestions / openPanel / focusedId / voice call state all live here as
|
|
99
99
|
// plain keys — core knows NO key names. Replace/merge only (O(1), never append). ---
|
|
100
|
-
private
|
|
100
|
+
private appState: Record<string, unknown> = {};
|
|
101
101
|
// --- history cap (UNOVERSE_STATE_MODEL §6a): hold at most N turns, oldest-out-first. ---
|
|
102
102
|
private maxTurns: number;
|
|
103
103
|
|
|
@@ -130,35 +130,35 @@ export class ComponentStore {
|
|
|
130
130
|
|
|
131
131
|
/** The current composer draft (a key in template state). */
|
|
132
132
|
getDraft(): string {
|
|
133
|
-
return (this.
|
|
133
|
+
return (this.appState.draft as string) ?? "";
|
|
134
134
|
}
|
|
135
135
|
/** Set the composer draft (input onChange writes here; a send button reads it). */
|
|
136
136
|
setDraft(text: string): void {
|
|
137
137
|
if (this.getDraft() === text) return;
|
|
138
|
-
this.
|
|
138
|
+
this.mergeAppState({ draft: text });
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
/** The workflow-selected template (MCP app shell) + its props, or null if the channel picks. */
|
|
142
|
-
|
|
143
|
-
return { name: this.
|
|
142
|
+
getActiveApp(): { name: string | null; props: Record<string, unknown> } {
|
|
143
|
+
return { name: this.activeAppName, props: this.activeAppProps };
|
|
144
144
|
}
|
|
145
145
|
/**
|
|
146
146
|
* Switch the active template (a workflow "render this app" directive — a COMPONENT_INIT
|
|
147
|
-
* marked
|
|
147
|
+
* marked isApp). Templates are SERVED apps, never streamed components (protocol
|
|
148
148
|
* §0.1); this records WHICH app the channel should load, never a timeline component.
|
|
149
149
|
*/
|
|
150
|
-
|
|
151
|
-
if (this.
|
|
150
|
+
setActiveApp(name: string, props: Record<string, unknown> = {}): void {
|
|
151
|
+
if (this.activeAppName === name && JSON.stringify(this.activeAppProps) === JSON.stringify(props)) return;
|
|
152
152
|
// TEMPLATE SWAP = the hard refresh boundary: a NEW shell starts with every surface
|
|
153
153
|
// retired — every instance (durable ones included) returns to inline; the new
|
|
154
154
|
// template derives its base state. Conversation data stays untouched (turns +
|
|
155
155
|
// slices persist; only the active VIEW resets) — swapping back re-presents nothing
|
|
156
156
|
// until something surfaces again.
|
|
157
|
-
if (this.
|
|
157
|
+
if (this.activeAppName !== null && this.activeAppName !== name) {
|
|
158
158
|
for (const [key, slice] of this.data) this.retractSlice(key, slice);
|
|
159
159
|
}
|
|
160
|
-
this.
|
|
161
|
-
this.
|
|
160
|
+
this.activeAppName = name;
|
|
161
|
+
this.activeAppProps = props;
|
|
162
162
|
this.bump();
|
|
163
163
|
}
|
|
164
164
|
|
|
@@ -235,12 +235,12 @@ export class ComponentStore {
|
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
/** The active template's state bag (templates read this into their root scope). */
|
|
238
|
-
|
|
239
|
-
return this.
|
|
238
|
+
getAppState(): Readonly<Record<string, unknown>> {
|
|
239
|
+
return this.appState;
|
|
240
240
|
}
|
|
241
241
|
/** Merge a patch into template state (replace/merge — never append, stays O(1)). */
|
|
242
|
-
|
|
243
|
-
this.
|
|
242
|
+
mergeAppState(patch: Record<string, unknown>): void {
|
|
243
|
+
this.appState = { ...this.appState, ...patch };
|
|
244
244
|
this.bump();
|
|
245
245
|
}
|
|
246
246
|
|
|
@@ -249,15 +249,15 @@ export class ComponentStore {
|
|
|
249
249
|
// NO UI concept (no panel / focus / faq / voice). The dev picks the key and the home:
|
|
250
250
|
// • COMPONENT state — a component's own data + view state (tab / edit / wizard-step /
|
|
251
251
|
// displayState / …), keyed by its id, written via the `setValue` action (data plane).
|
|
252
|
-
// • TEMPLATE state — the active template's own bag (`
|
|
253
|
-
// `
|
|
252
|
+
// • TEMPLATE state — the active template's own bag (`appState`), written via the
|
|
253
|
+
// `setAppValue` action (or `mergeAppState` from a producer). Just keys.
|
|
254
254
|
// `lifecycle` is the one thing that is NEITHER: it's READ OFF the conversation (the turn's
|
|
255
255
|
// streaming/complete status), so it stays a tiny derived machine here. `draft` is the one
|
|
256
256
|
// named convenience (the shared composer buffer the channel reads to send).
|
|
257
257
|
// (Voice is a NATIVE SERVICE — WS audio lane, react package — whose call state a producer
|
|
258
258
|
// merges into template state. Not a store machine.)
|
|
259
259
|
// ⛔ Do NOT add a UI-pattern method here (togglePanel/openFocus/…). It's a key the dev
|
|
260
|
-
// writes via setValue /
|
|
260
|
+
// writes via setValue / setAppValue and reads via `visibleWhen`. Removed twice.
|
|
261
261
|
// ════════════════════════════════════════════════════════════════════════════
|
|
262
262
|
|
|
263
263
|
// --- lifecycle: the workflow turn's status (derived from WORKFLOW_STATE) ---
|
|
@@ -171,8 +171,8 @@ export interface ActionValue {
|
|
|
171
171
|
* (optionally narrowed by `send`). `then` chains a follow-up (Stac Multi Action). */
|
|
172
172
|
export interface ActionSpec {
|
|
173
173
|
// The SDK hardcodes no UI concept — only generic writes + a server route:
|
|
174
|
-
type: string; // "setValue" (component slice) | "
|
|
175
|
-
values?: ActionValue[]; // setValue /
|
|
174
|
+
type: string; // "setValue" (component slice) | "setAppValue" (template state) | "input" | "submit"/custom (→ server)
|
|
175
|
+
values?: ActionValue[]; // setValue / setAppValue patches (the dev's chosen keys)
|
|
176
176
|
send?: string[]; // server route: which scope fields to include (default: all)
|
|
177
177
|
then?: ActionSpec | string; // optional chained follow-up
|
|
178
178
|
/** Carried here by the renderer, NOT authored on the action: a node declares
|
package/vendor/sdk/lib/index.ts
CHANGED
|
@@ -2,8 +2,8 @@ export { UnoverseComponent } from "./UnoverseComponent";
|
|
|
2
2
|
export type { UnoverseComponentProps } from "./UnoverseComponent";
|
|
3
3
|
export { StreamedUnoverseComponent, useUnoverseInstance } from "./streamed";
|
|
4
4
|
export type { StreamedUnoverseComponentProps } from "./streamed";
|
|
5
|
-
export {
|
|
6
|
-
export type {
|
|
5
|
+
export { StreamedUnoverseApp, selectPointers, computeAppWidth, resolveActiveLayout } from "./app";
|
|
6
|
+
export type { StreamedUnoverseAppProps } from "./app";
|
|
7
7
|
export { renderNode, styleToCss, keyframesCss } from "./render";
|
|
8
8
|
export type { ActionHandler, SlotResolver } from "./render";
|
|
9
9
|
export { useUnoverseTheme } from "./theme";
|
|
@@ -59,7 +59,7 @@ export type {
|
|
|
59
59
|
ServerComponentInit,
|
|
60
60
|
ServerComponentData,
|
|
61
61
|
ServerWorkflowState,
|
|
62
|
-
|
|
62
|
+
ServerAppData,
|
|
63
63
|
ServerSessionReady,
|
|
64
64
|
AnalyticsSpec,
|
|
65
65
|
AnalyticsEvent,
|
|
@@ -81,7 +81,7 @@ export function nodeAction(node: UnoverseNode, fallback: string): string | Actio
|
|
|
81
81
|
*/
|
|
82
82
|
export type SlotResolver = (node: UnoverseNode, key?: React.Key) => ReactNode;
|
|
83
83
|
|
|
84
|
-
// cssWidth moved to core/
|
|
84
|
+
// cssWidth moved to core/apps.ts (computeAppWidth needs it and core imports
|
|
85
85
|
// nothing from the React layer); re-exported to keep this import path working.
|
|
86
86
|
export { cssWidth };
|
|
87
87
|
|
package/vendor/sdk/lib/voice.tsx
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* voice action names to the actions — see the workbench wiring. The SDK owns NO styles
|
|
13
13
|
* and exposes only NEUTRAL model facts here (never UX flags).
|
|
14
14
|
*
|
|
15
|
-
* Transport split (docs/
|
|
15
|
+
* Transport split (docs/UNOVERSE_MCP_APP_PROTOCOL §5): the live PCM frames AND the
|
|
16
16
|
* synchronized audio-state control events both ride the /ws/gravity WS lane (they stay
|
|
17
17
|
* frame-synchronized); only structured component data is on MCP, untouched by voice.
|
|
18
18
|
*/
|
|
@@ -318,7 +318,7 @@ export function useVoiceService(config: UseVoiceServiceConfig): VoiceService {
|
|
|
318
318
|
// host-side props spread. Low-frequency (call phases + a 1Hz duration tick).
|
|
319
319
|
const store = config.store;
|
|
320
320
|
useEffect(() => {
|
|
321
|
-
store?.
|
|
321
|
+
store?.mergeAppState(state as unknown as Record<string, unknown>);
|
|
322
322
|
}, [store, state]);
|
|
323
323
|
|
|
324
324
|
return { state, startCall, endCall, toggleMute };
|