@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.
- package/package.json +1 -1
- package/screens/AtomsView.tsx +40 -2
- package/screens/ComponentsView.tsx +112 -21
- package/screens/NativeAppHost.tsx +8 -4
- package/screens/SourceView.tsx +1 -1
- package/screens/TemplatesView.tsx +52 -22
- package/screens/states.ts +27 -4
- package/src/PublishDialog.tsx +1 -1
- package/vendor/sdk/appEngine.tsx +44 -3
- package/vendor/sdk/lib/UnoverseComponent.tsx +1 -1
- package/vendor/sdk/lib/core/actions.ts +20 -6
- package/vendor/sdk/lib/core/analytics.ts +89 -0
- package/vendor/sdk/lib/core/client.ts +1 -1
- package/vendor/sdk/lib/core/index.ts +30 -0
- package/vendor/sdk/lib/core/store.ts +65 -38
- package/vendor/sdk/lib/core/templates.ts +252 -0
- package/vendor/sdk/lib/core/types.ts +40 -7
- package/vendor/sdk/lib/index.ts +9 -0
- package/vendor/sdk/lib/orb/ThinkingOrb.tsx +127 -0
- package/vendor/sdk/lib/orb/engine/braid.ts +50 -0
- package/vendor/sdk/lib/orb/engine/core.ts +157 -0
- package/vendor/sdk/lib/orb/engine/lattice.ts +205 -0
- package/vendor/sdk/lib/orb/engine/morph.ts +138 -0
- package/vendor/sdk/lib/orb/engine/orbits.ts +80 -0
- package/vendor/sdk/lib/orb/engine/profiles.ts +162 -0
- package/vendor/sdk/lib/orb/engine/registry.ts +24 -0
- package/vendor/sdk/lib/orb/engine/ribbon.ts +93 -0
- package/vendor/sdk/lib/orb/engine/types.ts +14 -0
- package/vendor/sdk/lib/orb/engine/web.ts +98 -0
- package/vendor/sdk/lib/orb/index.ts +20 -0
- package/vendor/sdk/lib/orb/presets.ts +104 -0
- package/vendor/sdk/lib/orb/theme.ts +83 -0
- package/vendor/sdk/lib/orb/types.ts +73 -0
- package/vendor/sdk/lib/primitives.tsx +114 -5
- package/vendor/sdk/lib/render.tsx +25 -5
- package/vendor/sdk/lib/style.ts +6 -2
- package/vendor/sdk/lib/template.tsx +77 -218
- package/vendor/sdk/main.tsx +7 -0
- package/vendor/skills/unoverse-create/SKILL.md +18 -18
- package/vendor/skills/unoverse-create/references/agent-skill.md +12 -12
- package/vendor/skills/unoverse-create/references/block.md +5 -5
- package/vendor/skills/unoverse-create/references/component.md +150 -75
- package/vendor/skills/unoverse-create/references/node.md +1 -1
- package/vendor/skills/unoverse-create/references/template.md +142 -82
- package/vendor/skills/unoverse-create/references/workflow.md +15 -15
package/vendor/sdk/appEngine.tsx
CHANGED
|
@@ -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
|
-
|
|
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/
|
|
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
|
|
57
|
-
//
|
|
58
|
-
// `onEnterView` hook exists for. Reported
|
|
59
|
-
// action
|
|
60
|
-
//
|
|
61
|
-
|
|
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/
|
|
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 (
|
|
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):
|