@unoverse-platform/studio 0.1.25 → 0.1.27

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 (45) hide show
  1. package/package.json +1 -1
  2. package/screens/AtomsView.tsx +40 -2
  3. package/screens/ComponentsView.tsx +112 -21
  4. package/screens/NativeAppHost.tsx +8 -4
  5. package/screens/SourceView.tsx +1 -1
  6. package/screens/TemplatesView.tsx +52 -22
  7. package/screens/states.ts +27 -4
  8. package/src/PublishDialog.tsx +1 -1
  9. package/vendor/sdk/appEngine.tsx +44 -3
  10. package/vendor/sdk/lib/UnoverseComponent.tsx +1 -1
  11. package/vendor/sdk/lib/core/actions.ts +20 -6
  12. package/vendor/sdk/lib/core/analytics.ts +89 -0
  13. package/vendor/sdk/lib/core/client.ts +1 -1
  14. package/vendor/sdk/lib/core/index.ts +30 -0
  15. package/vendor/sdk/lib/core/store.ts +65 -38
  16. package/vendor/sdk/lib/core/templates.ts +252 -0
  17. package/vendor/sdk/lib/core/types.ts +40 -7
  18. package/vendor/sdk/lib/index.ts +9 -0
  19. package/vendor/sdk/lib/orb/ThinkingOrb.tsx +127 -0
  20. package/vendor/sdk/lib/orb/engine/braid.ts +50 -0
  21. package/vendor/sdk/lib/orb/engine/core.ts +157 -0
  22. package/vendor/sdk/lib/orb/engine/lattice.ts +205 -0
  23. package/vendor/sdk/lib/orb/engine/morph.ts +138 -0
  24. package/vendor/sdk/lib/orb/engine/orbits.ts +80 -0
  25. package/vendor/sdk/lib/orb/engine/profiles.ts +162 -0
  26. package/vendor/sdk/lib/orb/engine/registry.ts +24 -0
  27. package/vendor/sdk/lib/orb/engine/ribbon.ts +93 -0
  28. package/vendor/sdk/lib/orb/engine/types.ts +14 -0
  29. package/vendor/sdk/lib/orb/engine/web.ts +98 -0
  30. package/vendor/sdk/lib/orb/index.ts +20 -0
  31. package/vendor/sdk/lib/orb/presets.ts +104 -0
  32. package/vendor/sdk/lib/orb/theme.ts +83 -0
  33. package/vendor/sdk/lib/orb/types.ts +73 -0
  34. package/vendor/sdk/lib/primitives.tsx +114 -5
  35. package/vendor/sdk/lib/render.tsx +25 -5
  36. package/vendor/sdk/lib/style.ts +6 -2
  37. package/vendor/sdk/lib/template.tsx +77 -218
  38. package/vendor/sdk/main.tsx +7 -0
  39. package/vendor/skills/unoverse-create/SKILL.md +18 -18
  40. package/vendor/skills/unoverse-create/references/agent-skill.md +12 -12
  41. package/vendor/skills/unoverse-create/references/block.md +5 -5
  42. package/vendor/skills/unoverse-create/references/component.md +150 -75
  43. package/vendor/skills/unoverse-create/references/node.md +1 -1
  44. package/vendor/skills/unoverse-create/references/template.md +142 -82
  45. package/vendor/skills/unoverse-create/references/workflow.md +15 -15
@@ -17,6 +17,9 @@ import {
17
17
  useUnoverseTheme,
18
18
  computeAppWidth,
19
19
  resolveActiveLayout,
20
+ setAnalyticsSink,
21
+ setAnalyticsContext,
22
+ setAnalyticsDebug,
20
23
  type UseVoiceServiceConfig,
21
24
  } from "./lib";
22
25
 
@@ -27,12 +30,24 @@ export interface AppConfig {
27
30
  getAccessToken?: () => Promise<string | null> | string | null;
28
31
  userId?: string;
29
32
  conversationId: string;
33
+ /** Where this app is running, reported on every analytics event
34
+ * (UNOVERSE_ANALYTICS.md §surfaces). Host-supplied: the app cannot know which frame
35
+ * it was placed in. */
36
+ surface?: string;
37
+ /** Log each analytics event as it fires. Server-delivered with the rest of the tenant
38
+ * config, never a client-side flag someone can turn on against a live site. */
39
+ analyticsDebug?: boolean;
30
40
  }
31
41
 
32
42
  export function AppEngine(cfg: AppConfig) {
33
- const { serverUrl, apiUrl, templateId, getAccessToken, userId, conversationId } = cfg;
43
+ const { serverUrl, apiUrl, templateId, getAccessToken, userId, conversationId, surface, analyticsDebug } = cfg;
34
44
 
35
- const store = useMemo(() => new ComponentStore(), []);
45
+ // ONE STORE PER CONVERSATION. The host may reuse this engine (the native-host
46
+ // iframe posts new config on app switch), and a template swap keeps the store by
47
+ // law (the template owns nothing) — but a CONVERSATION swap must not: carrying the
48
+ // old timeline across conversations rendered one app's history inside another's
49
+ // shell (observed live, 2026-08-08: one org's chat inside another org's app).
50
+ const store = useMemo(() => new ComponentStore(), [userId, conversationId]);
36
51
  const client = useMemo(
37
52
  () => new UnoverseClient(`${serverUrl}/mcp`, { getAccessToken: getAccessToken as any }),
38
53
  [serverUrl],
@@ -80,11 +95,26 @@ export function AppEngine(cfg: AppConfig) {
80
95
  // inbound `unoverse:config` channel), re-reporting whenever a panel surface opens or
81
96
  // closes: the app grows by the panel's declared width, then shrinks back. The host
82
97
  // animates the resize. Only posts on an ACTUAL width change (guard).
98
+ // ANALYTICS (UNOVERSE_ANALYTICS.md). The SDK produces events and knows no destination;
99
+ // this is the pipe out. Events cross to the HOST REALM, where the page's own analytics
100
+ // tag lives with the visitor's cookie and consent state already resolved — which is the
101
+ // whole reason measurement runs client-side rather than from a server that cannot read
102
+ // either. With no parent (a first-party page, an MCP app host) the post is a silent
103
+ // no-op, so a surface with nothing to report to costs nothing.
104
+ useEffect(() => {
105
+ setAnalyticsDebug(!!analyticsDebug);
106
+ setAnalyticsContext({ surface, conversation_id: conversationId, user_id: userId });
107
+ setAnalyticsSink((event) => {
108
+ window.parent?.postMessage({ type: "unoverse:event", ...event }, "*");
109
+ });
110
+ return () => setAnalyticsSink(null);
111
+ }, [surface, conversationId, userId, analyticsDebug]);
112
+
83
113
  useEffect(() => {
84
114
  const report = () => {
85
115
  // NAME-SYNC: width is computed off the ACTIVE layout (multi-layout templates
86
116
  // swap whole arrangements); single-layout defs resolve to their root.
87
- const w = computeAppWidth(resolveActiveLayout(def as never, store), store, theme?.appSize);
117
+ const w = computeAppWidth(resolveActiveLayout(def as never, store), store, theme?.appSize, def as never);
88
118
  if (w && w !== lastWidthRef.current) {
89
119
  lastWidthRef.current = w;
90
120
  window.parent?.postMessage({ type: "unoverse:size", width: w }, "*");
@@ -111,6 +141,17 @@ export function AppEngine(cfg: AppConfig) {
111
141
  { enabled: !!userId && !!binding.workflow },
112
142
  );
113
143
 
144
+ // STATUS TO THE HOST. In native-host mode the iframe owns the only real
145
+ // connection, so a host badge watching its own (never-opened) in-page connection
146
+ // reads "offline" forever while this session streams (observed live). Report the
147
+ // truth the same way size is reported; hosts that don't listen lose nothing.
148
+ useEffect(() => {
149
+ window.parent?.postMessage(
150
+ { type: "unoverse:status", ready: !!conn.isReady, connected: !!conn.isConnected },
151
+ "*",
152
+ );
153
+ }, [conn.isReady, conn.isConnected]);
154
+
114
155
  // VOICE (§2e-1): a `service: "voice"` app runs the native voice service — which now lives
115
156
  // in the shared renderer (StreamedUnoverseTemplate), identical to Studio. The host only
116
157
  // supplies the connection identity it already holds; startCall/endCall/toggleMute are
@@ -38,7 +38,7 @@ export function useUnoverseInstance(store?: ComponentStore, chatId = "", nodeId
38
38
 
39
39
  export interface UnoverseComponentProps {
40
40
  client: UnoverseClient;
41
- /** STATIC source: the definition to render, e.g. "unoverse://components/Card". */
41
+ /** STATIC source: the definition to render, e.g. "unoverse://components/card". */
42
42
  uri?: string;
43
43
  /** STATIC source: instance data bound into the definition. */
44
44
  data?: Record<string, unknown>;
@@ -9,6 +9,7 @@
9
9
  * `{{binding}}` resolution, an optional chained `then`, and a hard split between
10
10
  * LOCAL `setValue` and a server route for anything computed/transactional.
11
11
  */
12
+ import { emitAnalytics, resolveAnalyticsParams } from "./analytics";
12
13
  import type { ComponentStore } from "./store";
13
14
  import type { ActionSpec } from "./types";
14
15
 
@@ -44,6 +45,15 @@ export function dispatchAction(
44
45
  if (action == null) return;
45
46
  const spec: ActionSpec = typeof action === "string" ? { type: action } : action;
46
47
 
48
+ // ANALYTICS FIRST, and unconditionally. Reporting must not depend on what the action
49
+ // then does, nor on it succeeding: an interaction that happened is news whether or not
50
+ // the write, the route or the chained `then` works out. This is also why it is here and
51
+ // not in a leaf — one interpreter sees every interaction, so an event cannot be missed
52
+ // by an author writing an unusual definition.
53
+ if (spec.analytics?.event) {
54
+ emitAnalytics(spec.analytics.event, resolveAnalyticsParams(spec.analytics, scope, resolveValue));
55
+ }
56
+
47
57
  if (spec.type === "setValue") {
48
58
  // Write the dev's chosen keys into THIS COMPONENT's OWN slice (chatId:nodeId) — its own
49
59
  // view state: tab / edit / wizard-step / displayState / anything. Read back via
@@ -53,12 +63,16 @@ export function dispatchAction(
53
63
  // Structure-bumping write (not the deferred COMPONENT_DATA path): templates re-resolve
54
64
  // their `select.where` slots the moment a component changes its own state (§5b).
55
65
  ctx.store.mergeComponentState(ctx.chatId, ctx.nodeId, patch);
56
- // A CHANGED LAYOUT IS NEWS. `defaultState` is the view a component is showing, so a
57
- // write to it means this instance just entered a different face — the moment an
58
- // `onEnterView` hook exists for. Reported on the channel path every other server
59
- // action already uses; the server decides whether any hook cares. Nothing to author:
60
- // the setValue already carries the fact, so no component repeats it in a `then`.
61
- if (ctx.nodeId && "defaultState" in patch) ctx.sendToServer?.("viewEntered", { layout: patch.defaultState });
66
+ // A CHANGED VIEW IS NEWS. The public axis (`view`, legacy alias `defaultState`) is
67
+ // the state a component is showing, so a write to it means this instance just
68
+ // entered a different state — the moment an `onEnterView` hook exists for. Reported
69
+ // on the channel path every other server action uses; the server decides whether
70
+ // any hook cares. The payload carries both spellings during the migration (the
71
+ // server's lifecycle scope historically read `layout`).
72
+ if (ctx.nodeId && ("view" in patch || "defaultState" in patch)) {
73
+ const entered = patch.view ?? patch.defaultState;
74
+ ctx.sendToServer?.("viewEntered", { layout: entered, view: entered });
75
+ }
62
76
  } else if (spec.type === "setTemplateValue") {
63
77
  // Write the dev's chosen keys into TEMPLATE state — the template-state twin of setValue.
64
78
  // No component scope. The dev builds disclosure / focus / etc. from a key THEY name
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Experience analytics — the emit side (docs/unoverse/UNOVERSE_ANALYTICS.md).
3
+ *
4
+ * The SDK produces events and knows NOTHING about where they go. No GA, no gtag, no
5
+ * dataLayer, no vendor of any kind appears here. A host registers a sink; without one
6
+ * every emit is a no-op, which is what makes a preview, a test, or an MCP app host with
7
+ * no page to report to cost nothing.
8
+ *
9
+ * Events are NEWS, never instruction: nothing downstream may branch on one. The trigger
10
+ * originates in a browser the visitor controls, so a path that could act on an event
11
+ * would be a path a visitor could drive.
12
+ */
13
+
14
+ import type { AnalyticsSpec } from "./types";
15
+
16
+ /** What a sink receives. `context` is the platform's own vocabulary, kept separate from
17
+ * the author's `params` so the two can never collide on a key. */
18
+ export interface AnalyticsEvent {
19
+ event: string;
20
+ params: Record<string, unknown>;
21
+ context: AnalyticsContext;
22
+ }
23
+
24
+ export interface AnalyticsContext {
25
+ /** Where this is running: `unoverse_assistant` | `unoverse_client` | `mcp_app_host`. */
26
+ surface?: string;
27
+ /** The journey key. The SAME id the chat, the session and agent memory use. */
28
+ conversation_id?: string;
29
+ user_id?: string;
30
+ }
31
+
32
+ export type AnalyticsSink = (event: AnalyticsEvent) => void;
33
+
34
+ let sink: AnalyticsSink | null = null;
35
+ let context: AnalyticsContext = {};
36
+ let debug = false;
37
+
38
+ /** Registered by the host (the channel), which owns identity and the destination.
39
+ * Passing null removes it, so a torn-down host stops receiving. */
40
+ export function setAnalyticsSink(next: AnalyticsSink | null): void {
41
+ sink = next;
42
+ }
43
+
44
+ /** Session facts, supplied once by the channel. The SDK never invents them. */
45
+ export function setAnalyticsContext(next: AnalyticsContext): void {
46
+ context = { ...context, ...next };
47
+ }
48
+
49
+ /** Server-delivered, never a client-side flag: log each event as it fires. */
50
+ export function setAnalyticsDebug(next: boolean): void {
51
+ debug = next;
52
+ }
53
+
54
+ /**
55
+ * Emit one event. Failure is SILENT and CLOSED: a throwing sink never breaks a render
56
+ * and never blocks the interaction that produced it.
57
+ */
58
+ export function emitAnalytics(event: string, params?: Record<string, unknown>): void {
59
+ if (!event) return;
60
+ const payload: AnalyticsEvent = { event, params: params ?? {}, context: { ...context } };
61
+ // Matches the platform's existing log convention (`[unoverse:lifecycle] …`), so one
62
+ // console filter catches it among a host page's own noise.
63
+ if (debug) console.log(`[unoverse:analytics] ${event}`, payload.params, payload.context);
64
+ if (!sink) return;
65
+ try {
66
+ sink(payload);
67
+ } catch (err) {
68
+ console.warn(`[unoverse:analytics] sink failed for ${event}: ${(err as Error)?.message ?? err}`);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Resolve a declared `params` block against the data scope at the interaction site,
74
+ * using the SAME `{{path}}` binding an action's values already use. Keys resolving to
75
+ * `undefined` are dropped rather than sent as empty, so an absent field is absent in the
76
+ * report instead of arriving as a misleading blank.
77
+ */
78
+ export function resolveAnalyticsParams(
79
+ spec: AnalyticsSpec | undefined,
80
+ scope: Record<string, unknown>,
81
+ resolve: (value: unknown, scope: Record<string, unknown>) => unknown,
82
+ ): Record<string, unknown> {
83
+ const out: Record<string, unknown> = {};
84
+ for (const [key, value] of Object.entries(spec?.params ?? {})) {
85
+ const v = resolve(value, scope);
86
+ if (v !== undefined && v !== null && v !== "") out[key] = v;
87
+ }
88
+ return out;
89
+ }
@@ -114,7 +114,7 @@ export class UnoverseClient {
114
114
  return this.withSession(() => this.client.callTool({ name, arguments: args }));
115
115
  }
116
116
 
117
- /** resources/read a definition by URI (e.g. unoverse://components/Card). Cached. */
117
+ /** resources/read a definition by URI (e.g. unoverse://components/card). Cached. */
118
118
  async readDefinition(uri: string): Promise<UnoverseDefinition> {
119
119
  const cached = this.cache.get(uri);
120
120
  if (cached) return cached;
@@ -28,5 +28,35 @@ export {
28
28
  // The action interpreter (§2e-3) — local `setValue` store write + server route.
29
29
  export { dispatchAction, resolveValue, type ActionContext } from "./actions";
30
30
 
31
+ // The pure template-tier derivations (STATE_MODEL v2: the stateOrder ladder, state-
32
+ // owns-layout resolution, the pinned law, slot selection, claimed views, app width).
33
+ // Framework-agnostic — the React binding (template.tsx) and every platform port call
34
+ // these, never re-derive them.
35
+ export {
36
+ collectSurfacedViews,
37
+ computeAppWidth,
38
+ cssWidth,
39
+ formatRelative,
40
+ isViewField,
41
+ orderedClaims,
42
+ propDefaults,
43
+ resolveActiveLayout,
44
+ resolveActiveView,
45
+ selectPointers,
46
+ viewOf,
47
+ } from "./templates";
48
+
49
+ // Experience analytics (UNOVERSE_ANALYTICS.md) — the SDK emits; the host decides where to.
50
+ export {
51
+ emitAnalytics,
52
+ setAnalyticsSink,
53
+ setAnalyticsContext,
54
+ setAnalyticsDebug,
55
+ resolveAnalyticsParams,
56
+ type AnalyticsEvent,
57
+ type AnalyticsContext,
58
+ type AnalyticsSink,
59
+ } from "./analytics";
60
+
31
61
  // The shared "react to state" predicate (visibleWhen / Switch / style.when).
32
62
  export { matches, visible, selectCase } from "./conditions";
@@ -102,6 +102,26 @@ export class ComponentStore {
102
102
  this.maxTurns = opts?.maxTurns ?? 100;
103
103
  }
104
104
 
105
+ /**
106
+ * RETRACT ONE SLICE to its declared base (STATE_MODEL v2 §8 item 6). The public
107
+ * axis is `view` with `defaultState` as the legacy alias; the retract target is the
108
+ * slice's declared initial (`initialView`, seeded at spawn) falling back to
109
+ * `"inline"` (the universal fallback). Returns true when the slice changed. This is
110
+ * the ONE home for the reset every boundary uses (new turn, guest close, template
111
+ * swap) — never re-hardcode `"inline"` at a call site.
112
+ */
113
+ private retractSlice(key: string, slice: Record<string, unknown>): boolean {
114
+ const current = (slice.view ?? slice.defaultState) as unknown;
115
+ const base = typeof slice.initialView === "string" ? slice.initialView : "inline";
116
+ if (typeof current !== "string" || current === base) return false;
117
+ const next: Record<string, unknown> = { ...slice };
118
+ if ("view" in next || !("defaultState" in next)) next.view = base;
119
+ if ("defaultState" in next) next.defaultState = base;
120
+ this.data.set(key, next);
121
+ this.touches.set(key, ++this.touchSeq);
122
+ return true;
123
+ }
124
+
105
125
  // ============ writes ============
106
126
 
107
127
  /** The current composer draft (a key in template state). */
@@ -131,12 +151,7 @@ export class ComponentStore {
131
151
  // slices persist; only the active VIEW resets) — swapping back re-presents nothing
132
152
  // until something surfaces again.
133
153
  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
- }
154
+ for (const [key, slice] of this.data) this.retractSlice(key, slice);
140
155
  }
141
156
  this.activeTemplateName = name;
142
157
  this.activeTemplateProps = props;
@@ -152,15 +167,40 @@ export class ComponentStore {
152
167
  * calls this when a template-chrome `setValue defaultState:"inline"` fires (a card that
153
168
  * closes ITSELF still uses the plain component write; template chrome has no card to hit).
154
169
  */
170
+ /**
171
+ * ARRIVALS REFRESH THEIR STATE (THE SEVEN RULES, 2026-08-09, rule 5): when a
172
+ * component ARRIVES into state X (a server placement — never a guest write),
173
+ * components from EARLIER turns sitting in X retract to inline. New cards
174
+ * replace the rail's old cards; a new wizard replaces the old wizard — latest
175
+ * wins, at every rung. Same-turn siblings coexist (one answer's card set is one
176
+ * delivery, so eviction is keyed by TURN, not by instance). Durable
177
+ * (`lifetime: "conversation"`) slices opt out, as with every retract. Whether a
178
+ * refresh is VISIBLE is the ladder's business alone (rule 4: higher states mask;
179
+ * lower states update silently underneath).
180
+ */
181
+ private supersede(arrivingChatId: string, view: unknown): void {
182
+ if (typeof view !== "string" || !view || view === "inline") return;
183
+ let changed = false;
184
+ for (const [key, slice] of this.data) {
185
+ if (key.startsWith(`${arrivingChatId}:`)) continue; // same turn = same delivery
186
+ if (slice.lifetime === "conversation") continue;
187
+ const v = (slice.view ?? slice.defaultState) as unknown;
188
+ if (v !== view) continue;
189
+ const next: Record<string, unknown> = { ...slice };
190
+ if ("view" in next || !("defaultState" in next)) next.view = "inline";
191
+ if ("defaultState" in next) next.defaultState = "inline";
192
+ this.data.set(key, next);
193
+ this.touches.set(key, ++this.touchSeq);
194
+ changed = true;
195
+ }
196
+ if (changed) this.bump();
197
+ }
198
+
155
199
  closeSurfaces(): void {
156
200
  let changed = false;
157
201
  for (const [key, slice] of this.data) {
158
202
  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
- }
203
+ if (this.retractSlice(key, slice)) changed = true;
164
204
  }
165
205
  if (changed) this.bump();
166
206
  }
@@ -212,6 +252,9 @@ export class ComponentStore {
212
252
  this.types.set(key, event.component.type);
213
253
  this.placeInTimeline(event.chatId, key); // structure: place the pointer ONCE
214
254
  this.merge(key, event.component.props ?? {}); // data: seed
255
+ // Rule 5 — an arrival carrying a public state refreshes that state.
256
+ const seeded = event.component.props as Record<string, unknown> | undefined;
257
+ this.supersede(event.chatId, seeded?.view ?? seeded?.defaultState);
215
258
  } else {
216
259
  // NEW ACTIVITY reads newest-at-the-bottom: if this component is no longer the
217
260
  // LAST pointer in its turn (something was placed after it), fresh server data
@@ -225,42 +268,26 @@ export class ComponentStore {
225
268
  this.bump();
226
269
  }
227
270
  this.merge(key, event.data);
271
+ // Rule 5 — a server data-write that PLACES a component into a public state
272
+ // (the host's arrival placement rides COMPONENT_DATA) refreshes that state.
273
+ this.supersede(event.chatId, (event.data as Record<string, unknown> | undefined)?.view ?? (event.data as Record<string, unknown> | undefined)?.defaultState);
228
274
  }
229
275
  }
230
276
 
231
- /** Append a user message turn. Response identity = its chatId. */
277
+ /** Append a user message turn. Response identity = its chatId.
278
+ *
279
+ * RULE 7 (the seven rules, 2026-08-09): typing changes NOTHING by itself. The
280
+ * old new-turn reset (`beginExchange`, the "two lifetimes" chat-layer wipe) is
281
+ * GONE — only arrivals move the screen (rule 5's same-state refresh) and only
282
+ * closings release it (rule 6's ladder re-look). A wizard mid-flow survives the
283
+ * guest's next message; a stale rail is cleared by the cards that replace it,
284
+ * never by the message that asked for them. */
232
285
  addUserMessage(chatId: string, text: string): void {
233
286
  this.timeline.push({ role: "user", id: `u${this.seq++}`, chatId, text, createdAt: Date.now() });
234
- this.beginExchange();
235
287
  this.evict();
236
288
  this.bump();
237
289
  }
238
290
 
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
291
  /**
265
292
  * Open (or re-open) the assistant response for chatId in streaming state, WITHOUT
266
293
  * attaching a component. Mirrors legacy WORKFLOW_STARTED (processWorkflowState.ts):