@unoverse-platform/studio 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/bin/studio.mjs +124 -0
  2. package/index.html +12 -0
  3. package/local/catalog.ts +105 -0
  4. package/local/keys.ts +118 -0
  5. package/local/nodes.ts +77 -0
  6. package/local/plugin.ts +401 -0
  7. package/local/scaffold.d.mts +13 -0
  8. package/local/scaffold.mjs +85 -0
  9. package/package.json +43 -0
  10. package/public/logo.png +0 -0
  11. package/screens/AtomsView.tsx +206 -0
  12. package/screens/ComponentsView.tsx +551 -0
  13. package/screens/ConfigForm.tsx +217 -0
  14. package/screens/DesignSystemView.tsx +324 -0
  15. package/screens/IdentityHeader.tsx +88 -0
  16. package/screens/PromptBlocksView.tsx +137 -0
  17. package/screens/SkillsView.tsx +167 -0
  18. package/screens/TemplatesView.tsx +1005 -0
  19. package/screens/index.ts +26 -0
  20. package/screens/states.ts +44 -0
  21. package/src/App.tsx +252 -0
  22. package/src/ConnectUniverse.tsx +232 -0
  23. package/src/NewProject.tsx +89 -0
  24. package/src/NodeKeys.tsx +98 -0
  25. package/src/NodesView.tsx +443 -0
  26. package/src/PublishDialog.tsx +309 -0
  27. package/src/UniverseSession.tsx +121 -0
  28. package/src/host.ts +165 -0
  29. package/src/index.css +7 -0
  30. package/src/main.tsx +11 -0
  31. package/src/universes.ts +210 -0
  32. package/vendor/sdk/appEngine.tsx +219 -0
  33. package/vendor/sdk/lib/UnoverseComponent.tsx +120 -0
  34. package/vendor/sdk/lib/connection.tsx +302 -0
  35. package/vendor/sdk/lib/core/actions.ts +84 -0
  36. package/vendor/sdk/lib/core/client.ts +134 -0
  37. package/vendor/sdk/lib/core/conditions.ts +43 -0
  38. package/vendor/sdk/lib/core/connection.ts +142 -0
  39. package/vendor/sdk/lib/core/index.ts +32 -0
  40. package/vendor/sdk/lib/core/store.ts +455 -0
  41. package/vendor/sdk/lib/core/types.ts +194 -0
  42. package/vendor/sdk/lib/index.ts +59 -0
  43. package/vendor/sdk/lib/isolate.tsx +70 -0
  44. package/vendor/sdk/lib/primitives.tsx +453 -0
  45. package/vendor/sdk/lib/realtime/audioUtils.ts +40 -0
  46. package/vendor/sdk/lib/realtime/types.ts +45 -0
  47. package/vendor/sdk/lib/realtime/useAudioCapture.ts +237 -0
  48. package/vendor/sdk/lib/realtime/useAudioPlayback.ts +145 -0
  49. package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +164 -0
  50. package/vendor/sdk/lib/render.tsx +178 -0
  51. package/vendor/sdk/lib/streamed.tsx +65 -0
  52. package/vendor/sdk/lib/style.ts +227 -0
  53. package/vendor/sdk/lib/template.tsx +521 -0
  54. package/vendor/sdk/lib/theme.ts +55 -0
  55. package/vendor/sdk/lib/voice.tsx +311 -0
  56. package/vendor/sdk/main.tsx +96 -0
  57. package/vite.config.ts +70 -0
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The data-plane translation layer (pure, no React, no DOM).
3
+ *
4
+ * The CHANNEL owns the live workflow connection (WS + REST execute); this file
5
+ * is the thin mapping it feeds: one inbound server message → one mutation of the
6
+ * single `ComponentStore`. Keeping it pure makes it unit-testable and lets the
7
+ * React hook (react/src/connection.tsx) own only socket lifecycle.
8
+ *
9
+ * The Unoverse MCP server is NOT involved here — it's the control plane
10
+ * (definitions + theme). See UNOVERSE_MCP_TEMPLATE_PROTOCOL.md §5a.
11
+ */
12
+ import type { ComponentStore } from "./store";
13
+
14
+ // ---- Inbound server messages (the subset this rung handles) ----
15
+ // Mirrors the legacy gravity-client contract (core/types.ts). nodeId is
16
+ // SERVER-ASSIGNED per emitted component — never minted by the client.
17
+
18
+ export interface ServerComponentInit {
19
+ type: "COMPONENT_INIT";
20
+ chatId: string;
21
+ nodeId: string;
22
+ component: { type: string; componentUrl?: string; props?: Record<string, unknown> };
23
+ /** Marks a TEMPLATE directive (legacy emits a fake COMPONENT_INIT with nodeId
24
+ * `<name>_template`). Templates are SERVED apps, loaded via resources/read — never
25
+ * rendered as components — so we route these to the template plane, not the timeline. */
26
+ metadata?: { isTemplate?: boolean; [k: string]: unknown };
27
+ }
28
+ export interface ServerComponentData {
29
+ type: "COMPONENT_DATA" | "OBJECT_DATA";
30
+ chatId: string;
31
+ nodeId: string;
32
+ data: Record<string, unknown>;
33
+ }
34
+ export interface ServerWorkflowState {
35
+ type: "WORKFLOW_STATE";
36
+ state: string; // WORKFLOW_STARTED | WORKFLOW_COMPLETED | ...
37
+ chatId: string;
38
+ workflowId?: string;
39
+ workflowRunId?: string | null;
40
+ /** Template SELECTION rides here (SPEC §5b: `{ template, templateMode }`) — the
41
+ * OFFICIAL signal for which MCP-app shell to load via resources/read. */
42
+ metadata?: { template?: string; templateMode?: string; [k: string]: unknown };
43
+ }
44
+ export interface ServerSessionReady {
45
+ type: "SESSION_READY";
46
+ }
47
+ /** Generic write into TEMPLATE STATE — the template-state twin of COMPONENT_DATA. The
48
+ * producer names the keys (e.g. the Suggestions node sends `{ faqs }`); core stays generic. */
49
+ export interface ServerTemplateData {
50
+ type: "TEMPLATE_DATA";
51
+ data: Record<string, unknown>;
52
+ }
53
+ export type ServerMessage =
54
+ | ServerComponentInit
55
+ | ServerComponentData
56
+ | ServerWorkflowState
57
+ | ServerSessionReady
58
+ | ServerTemplateData
59
+ | { type: string; [k: string]: unknown }; // forward-compat: unknown types ignored
60
+
61
+ // Track first-seen components per `chatId:nodeId` so a repeated COMPONENT_INIT
62
+ // is applied as a props merge (legacy behaviour), not a second timeline placement.
63
+ // Caller passes a Set it owns (per connection), so this stays pure.
64
+
65
+ /**
66
+ * Apply one inbound server message to the single store. Out-of-scope message types
67
+ * (NODE_EXECUTION, audio) are ignored here; audio-state rides the WS lane, not this stream.
68
+ *
69
+ * @param seen per-connection set of already-initialised `chatId:nodeId` keys.
70
+ */
71
+ export function applyServerMessage(store: ComponentStore, msg: ServerMessage, seen: Set<string>): void {
72
+ switch (msg.type) {
73
+ case "COMPONENT_INIT": {
74
+ const m = msg as ServerComponentInit;
75
+ if (!m.chatId || !m.nodeId) return;
76
+ // TEMPLATE DIRECTIVE — a template is a SERVED MCP app (protocol §0.1), loaded via
77
+ // resources/read unoverse://templates/{name}; it is NEVER a streamed component. Record
78
+ // WHICH app to load (the channel reads it); do not place it in the component timeline
79
+ // (that produced "Unknown Unoverse component: <Template>"). The legacy componentUrl
80
+ // .js bundle is ignored — Unoverse loads the neutral definition, not a React bundle.
81
+ if (m.metadata?.isTemplate === true || m.nodeId.endsWith("_template")) {
82
+ if (m.component?.type) store.setActiveTemplate(m.component.type, m.component.props ?? {});
83
+ return;
84
+ }
85
+ const key = `${m.chatId}:${m.nodeId}`;
86
+ if (seen.has(key)) {
87
+ // Already placed — treat a repeat INIT as a props merge (no re-placement).
88
+ const props = m.component?.props;
89
+ if (props && Object.keys(props).length > 0) {
90
+ store.apply({ type: "COMPONENT_DATA", chatId: m.chatId, nodeId: m.nodeId, data: props });
91
+ }
92
+ return;
93
+ }
94
+ seen.add(key);
95
+ store.apply({ type: "COMPONENT_INIT", chatId: m.chatId, nodeId: m.nodeId, component: m.component });
96
+ return;
97
+ }
98
+ case "COMPONENT_DATA":
99
+ case "OBJECT_DATA": {
100
+ const m = msg as ServerComponentData;
101
+ if (!m.chatId || !m.nodeId) return;
102
+ store.apply({ type: "COMPONENT_DATA", chatId: m.chatId, nodeId: m.nodeId, data: m.data ?? {} });
103
+ return;
104
+ }
105
+ case "WORKFLOW_STATE": {
106
+ const m = msg as ServerWorkflowState;
107
+ // OFFICIAL template selection (SPEC §5b): the workflow names the app shell in
108
+ // metadata.template; the channel loads it via resources/read. (templateMode
109
+ // switch/stack/replace is future — default "switch".)
110
+ if (m.metadata?.template) store.setActiveTemplate(m.metadata.template);
111
+ // Open an EMPTY streaming turn so the timeline shows a thinking state BEFORE the
112
+ // first component (legacy processWorkflowState.ts; aiContext treats these as
113
+ // streaming). COMPONENT_INIT then reuses it. Without this, the assistant turn is
114
+ // born WITH its first component, so the streaming-and-empty window never exists.
115
+ if (m.state === "WORKFLOW_STARTED" || m.state === "THINKING" || m.state === "RESPONDING" || m.state === "WAITING") {
116
+ if (m.chatId) store.startResponse(m.chatId);
117
+ } else if (m.state === "WORKFLOW_COMPLETED" || m.state === "COMPLETE") {
118
+ store.completeResponse(m.chatId);
119
+ }
120
+ // Lifecycle machine — the global turn status (projected for visibleWhen).
121
+ if (m.state === "WORKFLOW_STARTED" || m.state === "THINKING") store.setLifecycle("thinking");
122
+ else if (m.state === "RESPONDING" || m.state === "WAITING") store.setLifecycle("streaming");
123
+ else if (m.state === "WORKFLOW_COMPLETED" || m.state === "COMPLETE") store.setLifecycle("complete");
124
+ else if (m.state === "ERROR" || m.state === "WORKFLOW_ERROR") store.setLifecycle("error");
125
+ return;
126
+ }
127
+ // No AUDIO_STATE case: voice is a NATIVE SERVICE on the WS audio lane (useVoiceService),
128
+ // NOT the structured MCP stream. Audio-state events never arrive here (UNOVERSE_SPEC §2e-1).
129
+ case "TEMPLATE_DATA": {
130
+ // Generic write into TEMPLATE STATE (the template-state twin of COMPONENT_DATA). The
131
+ // PRODUCER names the keys (the Suggestions node sends `{ faqs }`); the template picks
132
+ // them up. Core knows NO key names — fully generic (UNOVERSE_STATE_MODEL §2/§8).
133
+ const data = ((msg as { data?: Record<string, unknown> }).data ?? {}) as Record<string, unknown>;
134
+ store.mergeTemplateState(data);
135
+ return;
136
+ }
137
+ default:
138
+ // SESSION_READY is handled by the hook (it may send initialQuery); everything
139
+ // else (NODE_EXECUTION, audio) is out of scope this rung.
140
+ return;
141
+ }
142
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @unoverse/core — framework-agnostic brain. Public barrel.
3
+ *
4
+ * The implementation is split into focused modules:
5
+ * - ./types neutral definition shape + interaction vocab + resolved-theme shape
6
+ * - ./client the MCP client (reads definitions + theme)
7
+ * - ./store the single shared state + interaction state machines
8
+ * - ./connection data-plane translation (server message → store)
9
+ * - ./actions the action interpreter (§2e-3)
10
+ * No UI framework here; the React binding lives in @unoverse/sdk.
11
+ */
12
+
13
+ export * from "./types";
14
+ export * from "./client";
15
+ export * from "./store";
16
+
17
+ // Data-plane translation layer (pure event→store mapping; channel-owned connection).
18
+ export {
19
+ applyServerMessage,
20
+ type ServerMessage,
21
+ type ServerComponentInit,
22
+ type ServerComponentData,
23
+ type ServerWorkflowState,
24
+ type ServerSessionReady,
25
+ type ServerTemplateData,
26
+ } from "./connection";
27
+
28
+ // The action interpreter (§2e-3) — local `setValue` store write + server route.
29
+ export { dispatchAction, resolveValue, type ActionContext } from "./actions";
30
+
31
+ // The shared "react to state" predicate (visibleWhen / Switch / style.when).
32
+ export { matches, visible, selectCase } from "./conditions";
@@ -0,0 +1,455 @@
1
+ /**
2
+ * @unoverse/core — the single shared state (UNOVERSE_SPEC.md §2e-0).
3
+ *
4
+ * ONE store, ONE subscribe. Framework-agnostic (NO Zustand/XState) so it ports to
5
+ * every platform; the React binding is `useSyncExternalStore` in the react package.
6
+ * Two facets of the same object:
7
+ *
8
+ * 1. DATA — every component instance keyed `${chatId}:${nodeId}`,
9
+ * MERGE-not-replace. The live, high-frequency plane.
10
+ * 2. TIMELINE — the conversation: an ordered list of turns. An assistant
11
+ * turn holds POINTERS (the `chatId:nodeId` keys) into the data
12
+ * plane — never copies. This is what templates read.
13
+ *
14
+ * Plus a generic TEMPLATE-STATE bag (`templateState`) + `lifecycle` (derived from the
15
+ * conversation) + `draft` (the shared composer buffer). The SDK hardcodes NO UI concept —
16
+ * disclosure/focus/etc. are keys the dev writes via the `setValue` (component) or
17
+ * `setTemplateValue` (template) actions and reads via `visibleWhen`. (Voice is a native
18
+ * SERVICE — WS audio lane, react package — whose call state a producer merges into
19
+ * template state, per UNOVERSE_SPEC §2e-1. The store holds structured state only.)
20
+ *
21
+ * The split that makes template-swapping safe: COMPONENT_INIT *places a pointer* in
22
+ * the timeline (structure changes once); COMPONENT_DATA only merges into the data
23
+ * plane (the timeline never moves). A template reads structure, never the stream — so
24
+ * you can swap templates mid-stream and the conversation + live data are untouched.
25
+ */
26
+
27
+ /** A pointer into the data plane — the `${chatId}:${nodeId}` key. */
28
+ export type Pointer = string;
29
+
30
+ export interface UserTurn {
31
+ role: "user";
32
+ id: string;
33
+ chatId: string;
34
+ text: string;
35
+ /** Epoch ms when the turn was created — the renderer formats it (e.g. "just now"). */
36
+ createdAt: number;
37
+ }
38
+ export interface AssistantTurn {
39
+ role: "assistant";
40
+ id: string;
41
+ chatId: string;
42
+ streamingState: "streaming" | "complete";
43
+ /** Pointers into the data plane (`chatId:nodeId`), in arrival order. NOT copies. */
44
+ components: Pointer[];
45
+ /** Epoch ms when the response opened — the renderer formats it (e.g. "just now"). */
46
+ createdAt: number;
47
+ }
48
+ export type Turn = UserTurn | AssistantTurn;
49
+
50
+ export interface ComponentInitEvent {
51
+ type: "COMPONENT_INIT";
52
+ chatId: string;
53
+ nodeId: string;
54
+ component: { type: string; componentUrl?: string; props?: Record<string, unknown> };
55
+ }
56
+ export interface ComponentDataEvent {
57
+ type: "COMPONENT_DATA";
58
+ chatId: string;
59
+ nodeId: string;
60
+ data: Record<string, unknown>;
61
+ }
62
+ export type UnoverseEvent = ComponentInitEvent | ComponentDataEvent;
63
+
64
+ /** Lifecycle machine states (the workflow turn's status). */
65
+ export type LifecycleState = "idle" | "thinking" | "streaming" | "complete" | "error";
66
+
67
+ /** Stable empty slice for missing keys — never mutate. */
68
+ const EMPTY_SLICE: Record<string, unknown> = Object.freeze({});
69
+
70
+ export class ComponentStore {
71
+ // --- data plane ---
72
+ private data = new Map<string, Record<string, unknown>>();
73
+ private types = new Map<string, string>();
74
+ // --- timeline plane (pointers only) ---
75
+ private timeline: Turn[] = [];
76
+ // --- write recency per slice (monotonic seq stamped on every merge) — powers
77
+ // ComponentSlot.select.where's "most recent state-write wins" ordering (§5b).
78
+ // Generic bookkeeping: core stamps WHEN a slice changed, never reads WHAT. ---
79
+ private touches = new Map<string, number>();
80
+ /** Slices whose LATEST write was a guest interaction (`mergeComponentState`) — value is
81
+ * that write's touch seq. Powers `pinned` layouts: guest writes move them, arrivals don't. */
82
+ private interactionTouches = new Map<string, number>();
83
+ private touchSeq = 0;
84
+ // --- shared ---
85
+ private listeners = new Set<() => void>();
86
+ private version = 0;
87
+ private structureVersion = 0;
88
+ private notifyScheduled = false;
89
+ private seq = 0;
90
+ // --- active template (the workflow-selected MCP app shell; §0.1) ---
91
+ private activeTemplateName: string | null = null;
92
+ private activeTemplateProps: Record<string, unknown> = {};
93
+ // --- template state: the active template's OWN generic bag (UNOVERSE_STATE_MODEL §2).
94
+ // draft / suggestions / openPanel / focusedId / voice call state all live here as
95
+ // plain keys — core knows NO key names. Replace/merge only (O(1), never append). ---
96
+ private templateState: Record<string, unknown> = {};
97
+ // --- history cap (UNOVERSE_STATE_MODEL §6a): hold at most N turns, oldest-out-first. ---
98
+ private maxTurns: number;
99
+
100
+ /** @param opts.maxTurns history cap (default 100; §6a). Channels may raise/lower it. */
101
+ constructor(opts?: { maxTurns?: number }) {
102
+ this.maxTurns = opts?.maxTurns ?? 100;
103
+ }
104
+
105
+ // ============ writes ============
106
+
107
+ /** The current composer draft (a key in template state). */
108
+ getDraft(): string {
109
+ return (this.templateState.draft as string) ?? "";
110
+ }
111
+ /** Set the composer draft (input onChange writes here; a send button reads it). */
112
+ setDraft(text: string): void {
113
+ if (this.getDraft() === text) return;
114
+ this.mergeTemplateState({ draft: text });
115
+ }
116
+
117
+ /** The workflow-selected template (MCP app shell) + its props, or null if the channel picks. */
118
+ getActiveTemplate(): { name: string | null; props: Record<string, unknown> } {
119
+ return { name: this.activeTemplateName, props: this.activeTemplateProps };
120
+ }
121
+ /**
122
+ * Switch the active template (a workflow "render this app" directive — a COMPONENT_INIT
123
+ * marked isTemplate). Templates are SERVED apps, never streamed components (protocol
124
+ * §0.1); this records WHICH app the channel should load, never a timeline component.
125
+ */
126
+ setActiveTemplate(name: string, props: Record<string, unknown> = {}): void {
127
+ if (this.activeTemplateName === name && JSON.stringify(this.activeTemplateProps) === JSON.stringify(props)) return;
128
+ // TEMPLATE SWAP = the hard refresh boundary: a NEW shell starts with every surface
129
+ // retired — every instance (durable ones included) returns to inline; the new
130
+ // template derives its base state. Conversation data stays untouched (turns +
131
+ // slices persist; only the active VIEW resets) — swapping back re-presents nothing
132
+ // until something surfaces again.
133
+ if (this.activeTemplateName !== null && this.activeTemplateName !== name) {
134
+ for (const [key, slice] of this.data) {
135
+ if (typeof slice.defaultState === "string" && slice.defaultState !== "inline") {
136
+ this.data.set(key, { ...slice, defaultState: "inline" });
137
+ this.touches.set(key, ++this.touchSeq);
138
+ }
139
+ }
140
+ }
141
+ this.activeTemplateName = name;
142
+ this.activeTemplateProps = props;
143
+ this.bump();
144
+ }
145
+
146
+ /**
147
+ * GUEST CLOSE — un-surface the active view: every non-inline slice returns to `inline`,
148
+ * so a name-synced layout retracts to its base (the ✕ on a rail/panel the guest didn't
149
+ * open a single card from). This is EXACTLY the new-turn reset (`beginExchange`), just
150
+ * triggered by a template close button instead of an arriving turn — same durable
151
+ * (`lifetime: "conversation"`) opt-out, no new vocabulary, nothing stored. The renderer
152
+ * calls this when a template-chrome `setValue defaultState:"inline"` fires (a card that
153
+ * closes ITSELF still uses the plain component write; template chrome has no card to hit).
154
+ */
155
+ closeSurfaces(): void {
156
+ let changed = false;
157
+ for (const [key, slice] of this.data) {
158
+ if (slice.lifetime === "conversation") continue;
159
+ if (typeof slice.defaultState === "string" && slice.defaultState !== "inline") {
160
+ this.data.set(key, { ...slice, defaultState: "inline" });
161
+ this.touches.set(key, ++this.touchSeq);
162
+ changed = true;
163
+ }
164
+ }
165
+ if (changed) this.bump();
166
+ }
167
+
168
+ /** The active template's state bag (templates read this into their root scope). */
169
+ getTemplateState(): Readonly<Record<string, unknown>> {
170
+ return this.templateState;
171
+ }
172
+ /** Merge a patch into template state (replace/merge — never append, stays O(1)). */
173
+ mergeTemplateState(patch: Record<string, unknown>): void {
174
+ this.templateState = { ...this.templateState, ...patch };
175
+ this.bump();
176
+ }
177
+
178
+ // ════════════════════════════════════════════════════════════════════════════
179
+ // STATE HAS TWO HOMES, BY SCOPE (UNOVERSE_STATE_MODEL §2) — and the SDK hardcodes
180
+ // NO UI concept (no panel / focus / faq / voice). The dev picks the key and the home:
181
+ // • COMPONENT state — a component's own data + view state (tab / edit / wizard-step /
182
+ // displayState / …), keyed by its id, written via the `setValue` action (data plane).
183
+ // • TEMPLATE state — the active template's own bag (`templateState`), written via the
184
+ // `setTemplateValue` action (or `mergeTemplateState` from a producer). Just keys.
185
+ // `lifecycle` is the one thing that is NEITHER: it's READ OFF the conversation (the turn's
186
+ // streaming/complete status), so it stays a tiny derived machine here. `draft` is the one
187
+ // named convenience (the shared composer buffer the channel reads to send).
188
+ // (Voice is a NATIVE SERVICE — WS audio lane, react package — whose call state a producer
189
+ // merges into template state. Not a store machine.)
190
+ // ⛔ Do NOT add a UI-pattern method here (togglePanel/openFocus/…). It's a key the dev
191
+ // writes via setValue / setTemplateValue and reads via `visibleWhen`. Removed twice.
192
+ // ════════════════════════════════════════════════════════════════════════════
193
+
194
+ // --- lifecycle: the workflow turn's status (derived from WORKFLOW_STATE) ---
195
+ private lifecycleState: LifecycleState = "idle";
196
+
197
+ /** The conversation lifecycle status. */
198
+ getLifecycle(): LifecycleState {
199
+ return this.lifecycleState;
200
+ }
201
+ /** Move the lifecycle machine to a status (idempotent). */
202
+ setLifecycle(s: LifecycleState): void {
203
+ if (this.lifecycleState === s) return;
204
+ this.lifecycleState = s;
205
+ this.bump();
206
+ }
207
+
208
+ /** Apply a streamed event (COMPONENT_INIT or COMPONENT_DATA). */
209
+ apply(event: UnoverseEvent): void {
210
+ const key = `${event.chatId}:${event.nodeId}`;
211
+ if (event.type === "COMPONENT_INIT") {
212
+ this.types.set(key, event.component.type);
213
+ this.placeInTimeline(event.chatId, key); // structure: place the pointer ONCE
214
+ this.merge(key, event.component.props ?? {}); // data: seed
215
+ } else {
216
+ // NEW ACTIVITY reads newest-at-the-bottom: if this component is no longer the
217
+ // LAST pointer in its turn (something was placed after it), fresh server data
218
+ // moves it back to the end — e.g. answer text resuming AFTER an interactive
219
+ // component the workflow placed mid-turn. At most ONE structure bump per
220
+ // overtake; the streaming hot path (already last) stays data-only.
221
+ const resp = this.responseFor(event.chatId);
222
+ if (resp && resp.components.length > 1 && resp.components[resp.components.length - 1] !== key && resp.components.includes(key)) {
223
+ resp.components.splice(resp.components.indexOf(key), 1);
224
+ resp.components.push(key);
225
+ this.bump();
226
+ }
227
+ this.merge(key, event.data);
228
+ }
229
+ }
230
+
231
+ /** Append a user message turn. Response identity = its chatId. */
232
+ addUserMessage(chatId: string, text: string): void {
233
+ this.timeline.push({ role: "user", id: `u${this.seq++}`, chatId, text, createdAt: Date.now() });
234
+ this.beginExchange();
235
+ this.evict();
236
+ this.bump();
237
+ }
238
+
239
+ /**
240
+ * TWO LIFETIMES, ONE STORE (docs/design/04): CONVERSATION state (turns + each
241
+ * instance's data — durable, append-only, the stream owns it) vs CHAT state (the
242
+ * present interaction: each instance's active VIEW, the template's chrome). A new
243
+ * user turn advances the conversation, so the chat layer returns to its universal
244
+ * default: every instance goes back to `inline` — surfaces empty, the template
245
+ * derives its base state, panels retract. Data is untouched; history keeps every
246
+ * card. (A future durable, conversation-scoped component — a cart, an itinerary —
247
+ * will opt out of this explicitly; today nothing does.)
248
+ */
249
+ private beginExchange(): void {
250
+ for (const [key, slice] of this.data) {
251
+ // DURABLE conversation-scoped surface (docs/design/04 §Two lifetimes): the
252
+ // producer marked the slice `lifetime: "conversation"` (a cart, an itinerary,
253
+ // a composed page) — it opted out of the new-turn reset and stays surfaced
254
+ // until replaced, closed, or the template swaps. Generic: a key the producer
255
+ // wrote, not a UI concept.
256
+ if (slice.lifetime === "conversation") continue;
257
+ if (typeof slice.defaultState === "string" && slice.defaultState !== "inline") {
258
+ this.data.set(key, { ...slice, defaultState: "inline" });
259
+ this.touches.set(key, ++this.touchSeq);
260
+ }
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Open (or re-open) the assistant response for chatId in streaming state, WITHOUT
266
+ * attaching a component. Mirrors legacy WORKFLOW_STARTED (processWorkflowState.ts):
267
+ * it creates the EMPTY streaming turn so the timeline shows a "thinking" state in
268
+ * the window BEFORE the first component arrives. The first COMPONENT_INIT then
269
+ * reuses this same turn (responseFor matches by chatId). Idempotent.
270
+ */
271
+ startResponse(chatId: string): void {
272
+ const resp = this.responseFor(chatId);
273
+ if (!resp) {
274
+ this.timeline.push({ role: "assistant", id: `a${this.seq++}`, chatId, streamingState: "streaming", components: [], createdAt: Date.now() });
275
+ this.evict();
276
+ this.bump();
277
+ } else if (resp.streamingState !== "streaming") {
278
+ resp.streamingState = "streaming";
279
+ this.bump();
280
+ }
281
+ }
282
+
283
+ /** Mark the assistant response for this chatId complete (e.g. on workflow-completed). */
284
+ completeResponse(chatId: string): void {
285
+ const resp = this.responseFor(chatId);
286
+ if (resp && resp.streamingState !== "complete") {
287
+ resp.streamingState = "complete";
288
+ this.bump();
289
+ }
290
+ }
291
+
292
+ /** Place a component pointer into the open assistant turn for chatId (lazily creating it). */
293
+ private placeInTimeline(chatId: string, key: Pointer): void {
294
+ let resp = this.responseFor(chatId);
295
+ if (!resp) {
296
+ // Born COMPLETE: only lifecycle events (startResponse) open a STREAMING turn. A
297
+ // turn lazily created just to hold an arriving component (e.g. a conversation-keyed
298
+ // durable render) is not a spoken reply — no completion event will ever match its
299
+ // chatId, so born-streaming left it as an eternal thinking bubble. If a real
300
+ // lifecycle start for this id follows, startResponse flips it to streaming.
301
+ resp = { role: "assistant", id: `a${this.seq++}`, chatId, streamingState: "complete", components: [], createdAt: Date.now() };
302
+ this.timeline.push(resp);
303
+ this.evict();
304
+ }
305
+ if (!resp.components.includes(key)) resp.components.push(key);
306
+ this.bump();
307
+ }
308
+
309
+ private responseFor(chatId: string): AssistantTurn | undefined {
310
+ // Response identity is the chatId — all components of one turn share it.
311
+ for (let i = this.timeline.length - 1; i >= 0; i--) {
312
+ const t = this.timeline[i];
313
+ if (t.role === "assistant" && t.chatId === chatId) return t;
314
+ }
315
+ return undefined;
316
+ }
317
+
318
+ /** Trim the timeline to the last `maxTurns`, deleting the component slices the dropped
319
+ * turns pointed at so the data/types maps can't leak (UNOVERSE_STATE_MODEL §6a). */
320
+ private evict(): void {
321
+ while (this.timeline.length > this.maxTurns) {
322
+ const dropped = this.timeline.shift();
323
+ if (dropped?.role === "assistant") {
324
+ for (const ptr of dropped.components) {
325
+ this.data.delete(ptr);
326
+ this.types.delete(ptr);
327
+ this.touches.delete(ptr);
328
+ this.interactionTouches.delete(ptr);
329
+ }
330
+ }
331
+ }
332
+ }
333
+
334
+ private merge(key: string, patch: Record<string, unknown>): void {
335
+ // merge-not-replace. Fresh object identity per merge = the per-slice snapshot
336
+ // React bindings key on (useSyncExternalStore bails out for untouched slices).
337
+ this.data.set(key, { ...(this.data.get(key) ?? {}), ...patch });
338
+ this.touches.set(key, ++this.touchSeq);
339
+ this.bumpData();
340
+ }
341
+
342
+ /**
343
+ * LOCAL interaction write into a component's own slice (the `setValue` action).
344
+ * Same merge as inbound COMPONENT_DATA, but STRUCTURE-bumps: slot selection
345
+ * (`ComponentSlot.select.where`) reads component state, and templates subscribe to
346
+ * structure only — a click that changes a component's state must re-resolve the
347
+ * template's slots. Low-frequency (a click, not a token stream), so the synchronous
348
+ * notify is fine.
349
+ */
350
+ mergeComponentState(chatId: string, nodeId: string, patch: Record<string, unknown>): void {
351
+ const key = `${chatId}:${nodeId}`;
352
+ this.data.set(key, { ...(this.data.get(key) ?? {}), ...patch });
353
+ this.touches.set(key, ++this.touchSeq);
354
+ // Remember that this slice's LATEST write came through the guest's hand — the one
355
+ // fact `pinned` layouts need: a pinned layout yields to guest writes, never to
356
+ // background arrivals (which land via `merge` and don't stamp this).
357
+ this.interactionTouches.set(key, this.touchSeq);
358
+ this.bump();
359
+ }
360
+
361
+ /** The touch seq of this slice's last GUEST write (`setValue`), or -1 if the latest
362
+ * activity was a server arrival. Compare with `touchOf`: equal ⇒ the guest wrote last. */
363
+ interactionTouchOf(pointer: string): number {
364
+ return this.interactionTouches.get(pointer) ?? -1;
365
+ }
366
+
367
+ /**
368
+ * STRUCTURE bump — timeline placement, template state, lifecycle. Low-frequency
369
+ * and often interaction-driven (composer draft), so listeners fire synchronously.
370
+ */
371
+ private bump(): void {
372
+ this.version++;
373
+ this.structureVersion++;
374
+ this.notify();
375
+ }
376
+
377
+ /**
378
+ * DATA bump — COMPONENT_DATA merges, the per-chunk streaming hot path. A token
379
+ * stream can deliver hundreds of events/sec; notifying per event drives one React
380
+ * render pass each. State is updated synchronously (reads are always fresh) but
381
+ * listener notification coalesces to once per animation frame (display rate),
382
+ * falling back to a macrotask outside the DOM. useSyncExternalStore is safe with
383
+ * deferred notify — it re-reads the snapshot when the notification lands.
384
+ */
385
+ private bumpData(): void {
386
+ this.version++;
387
+ if (this.notifyScheduled) return;
388
+ this.notifyScheduled = true;
389
+ const flush = () => {
390
+ this.notifyScheduled = false;
391
+ this.notify();
392
+ };
393
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(flush);
394
+ else setTimeout(flush, 0);
395
+ }
396
+
397
+ private notify(): void {
398
+ this.listeners.forEach((l) => l());
399
+ }
400
+
401
+ // ============ reads — data plane (unchanged API; streamed.tsx depends on these) ============
402
+
403
+ get(chatId: string, nodeId: string): Record<string, unknown> {
404
+ // EMPTY (module-stable) for missing keys — a fresh {} per call would make a
405
+ // snapshot-based useSyncExternalStore loop forever.
406
+ return this.data.get(`${chatId}:${nodeId}`) ?? EMPTY_SLICE;
407
+ }
408
+ getType(chatId: string, nodeId: string): string | undefined {
409
+ return this.types.get(`${chatId}:${nodeId}`);
410
+ }
411
+ getVersion(): number {
412
+ return this.version;
413
+ }
414
+ /** Bumped only by timeline/template-state/lifecycle changes — what templates subscribe to.
415
+ * COMPONENT_DATA merges don't touch it, so streaming chunks never re-render the template. */
416
+ getStructureVersion(): number {
417
+ return this.structureVersion;
418
+ }
419
+ subscribe(fn: () => void): () => void {
420
+ this.listeners.add(fn);
421
+ return () => {
422
+ this.listeners.delete(fn);
423
+ };
424
+ }
425
+
426
+ // ============ reads — timeline plane (what templates iterate) ============
427
+
428
+ /** The full conversation, in order. */
429
+ getTimeline(): readonly Turn[] {
430
+ return this.timeline;
431
+ }
432
+ /** Just the assistant responses, in order. */
433
+ getResponses(): AssistantTurn[] {
434
+ return this.timeline.filter((t): t is AssistantTurn => t.role === "assistant");
435
+ }
436
+ /** The most recent assistant response (KeyService-style single-response layouts). */
437
+ latestResponse(): AssistantTurn | undefined {
438
+ const r = this.getResponses();
439
+ return r[r.length - 1];
440
+ }
441
+ /** Resolve a pointer back to its parts (for handing to the leaf renderer). */
442
+ split(pointer: Pointer): { chatId: string; nodeId: string } {
443
+ const i = pointer.indexOf(":");
444
+ return { chatId: pointer.slice(0, i), nodeId: pointer.slice(i + 1) };
445
+ }
446
+ /** Type of the component a pointer refers to (for filter-by-type in templates). */
447
+ typeOf(pointer: Pointer): string | undefined {
448
+ return this.types.get(pointer);
449
+ }
450
+ /** Write-recency of a slice (monotonic seq; 0 = never written). Powers the
451
+ * `select.where` most-recent-wins ordering — last writer wins, like any store. */
452
+ touchOf(pointer: Pointer): number {
453
+ return this.touches.get(pointer) ?? 0;
454
+ }
455
+ }