@unoverse-platform/studio 0.1.26 → 0.1.28
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/TemplatesView.tsx +51 -21
- package/screens/states.ts +27 -4
- package/vendor/sdk/appEngine.tsx +20 -4
- package/vendor/sdk/lib/UnoverseComponent.tsx +8 -2
- package/vendor/sdk/lib/core/actions.ts +10 -6
- package/vendor/sdk/lib/core/client.ts +39 -12
- package/vendor/sdk/lib/core/index.ts +21 -0
- package/vendor/sdk/lib/core/refs.ts +117 -0
- package/vendor/sdk/lib/core/store.ts +65 -38
- package/vendor/sdk/lib/core/templates.ts +270 -0
- package/vendor/sdk/lib/core/types.ts +27 -7
- package/vendor/sdk/lib/orb/ThinkingOrb.tsx +131 -0
- package/vendor/sdk/lib/orb/engine/braid.ts +50 -0
- package/vendor/sdk/lib/orb/engine/core.ts +167 -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 +31 -0
- package/vendor/sdk/lib/orb/engine/ribbon.ts +93 -0
- package/vendor/sdk/lib/orb/engine/rings.ts +239 -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 +181 -0
- package/vendor/sdk/lib/orb/theme.ts +83 -0
- package/vendor/sdk/lib/orb/types.ts +84 -0
- package/vendor/sdk/lib/primitives.tsx +116 -2
- package/vendor/sdk/lib/render.tsx +32 -8
- package/vendor/sdk/lib/style.ts +6 -2
- package/vendor/sdk/lib/template.tsx +86 -224
- 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
|
@@ -83,7 +83,11 @@ export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, ex
|
|
|
83
83
|
// LIVE SOURCE. The hook runs either way (hooks cannot be conditional) and no-ops without a
|
|
84
84
|
// store, so one component serves both paths without a wrapper deciding for it.
|
|
85
85
|
const live = useUnoverseInstance(store, chatId, nodeId);
|
|
86
|
-
|
|
86
|
+
// `@refs` asks for the REFERENCED form: shared pieces named once and shipped once,
|
|
87
|
+
// rather than a copy pasted at every use (746 KB became 221 KB across five real
|
|
88
|
+
// definitions). This client resolves them, so it asks; a client that could not would
|
|
89
|
+
// render a hole, which is why the bare URI still serves the inlined form.
|
|
90
|
+
const resolvedUri = store ? (live.type ? `unoverse://components/${live.type}@refs` : null) : (uri ?? null);
|
|
87
91
|
const sourceData = store ? (extraData ? { ...(live.data ?? {}), ...extraData } : live.data) : data;
|
|
88
92
|
// No theme passed → fetch it from the server (the SDK owns zero token values). The
|
|
89
93
|
// theme REF comes from the definition itself: a template's `org` names its client's
|
|
@@ -179,7 +183,9 @@ export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, ex
|
|
|
179
183
|
// host owns isolation, so apply it here on a `display:contents` wrapper (out of layout while
|
|
180
184
|
// its inherited props cascade) — the design-system baseline either way.
|
|
181
185
|
const keyframes = <style>{keyframesCss(activeTheme)}</style>;
|
|
182
|
-
|
|
186
|
+
// The shared pieces this definition NAMES travel beside it and are threaded through
|
|
187
|
+
// every recursion, so a reference nested in a Switch branch or an Each item resolves too.
|
|
188
|
+
const tree = renderNode(def.root, merged, handleAction, activeTheme, undefined, undefined, def.defs);
|
|
183
189
|
// The isolation container MIRRORS the component root's declared sizing intent (§4a:
|
|
184
190
|
// the component owns its size; the host responds). A root that declares
|
|
185
191
|
// `height: "full"` needs a definite chain — the container passes the host's height
|
|
@@ -63,12 +63,16 @@ export function dispatchAction(
|
|
|
63
63
|
// Structure-bumping write (not the deferred COMPONENT_DATA path): templates re-resolve
|
|
64
64
|
// their `select.where` slots the moment a component changes its own state (§5b).
|
|
65
65
|
ctx.store.mergeComponentState(ctx.chatId, ctx.nodeId, patch);
|
|
66
|
-
// A CHANGED
|
|
67
|
-
//
|
|
68
|
-
// `onEnterView` hook exists for. Reported
|
|
69
|
-
// action
|
|
70
|
-
//
|
|
71
|
-
|
|
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
|
+
}
|
|
72
76
|
} else if (spec.type === "setTemplateValue") {
|
|
73
77
|
// Write the dev's chosen keys into TEMPLATE state — the template-state twin of setValue.
|
|
74
78
|
// No component scope. The dev builds disclosure / focus / etc. from a key THEY name
|
|
@@ -28,6 +28,17 @@ export class UnoverseClient {
|
|
|
28
28
|
private connecting: Promise<void> | null = null;
|
|
29
29
|
private cache = new Map<string, UnoverseDefinition>();
|
|
30
30
|
private themeCache = new Map<string, ResolvedTheme>();
|
|
31
|
+
/**
|
|
32
|
+
* IN-FLIGHT reads, so N askers cost ONE request.
|
|
33
|
+
*
|
|
34
|
+
* The caches above are written only after `await`, so every instance mounting in the
|
|
35
|
+
* same tick missed and issued its own identical `resources/read`: ten cards of one type
|
|
36
|
+
* meant ten copies of that definition over the wire, and on a link where a round trip
|
|
37
|
+
* costs 300ms they queue behind each other. Sharing the PROMISE is the whole fix; the
|
|
38
|
+
* value cache still serves everything after the first resolve.
|
|
39
|
+
*/
|
|
40
|
+
private inflight = new Map<string, Promise<UnoverseDefinition>>();
|
|
41
|
+
private themeInflight = new Map<string, Promise<ResolvedTheme>>();
|
|
31
42
|
|
|
32
43
|
constructor(
|
|
33
44
|
private readonly url: string,
|
|
@@ -118,12 +129,21 @@ export class UnoverseClient {
|
|
|
118
129
|
async readDefinition(uri: string): Promise<UnoverseDefinition> {
|
|
119
130
|
const cached = this.cache.get(uri);
|
|
120
131
|
if (cached) return cached;
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
132
|
+
const pending = this.inflight.get(uri);
|
|
133
|
+
if (pending) return pending;
|
|
134
|
+
const read = (async () => {
|
|
135
|
+
const res = await this.withSession(() => this.client.readResource({ uri }));
|
|
136
|
+
const first = res.contents[0] as { text?: string };
|
|
137
|
+
if (!first?.text) throw new Error(`No definition content for ${uri}`);
|
|
138
|
+
const def = JSON.parse(first.text) as UnoverseDefinition;
|
|
139
|
+
this.cache.set(uri, def);
|
|
140
|
+
return def;
|
|
141
|
+
})();
|
|
142
|
+
this.inflight.set(uri, read);
|
|
143
|
+
// Cleared either way: a failed read must not be remembered as pending forever, or the
|
|
144
|
+
// next asker waits on a promise that will never resolve again.
|
|
145
|
+
read.finally(() => this.inflight.delete(uri));
|
|
146
|
+
return read;
|
|
127
147
|
}
|
|
128
148
|
|
|
129
149
|
/**
|
|
@@ -133,12 +153,19 @@ export class UnoverseClient {
|
|
|
133
153
|
async readTheme(name = "light"): Promise<ResolvedTheme> {
|
|
134
154
|
const cached = this.themeCache.get(name);
|
|
135
155
|
if (cached) return cached;
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
156
|
+
const pending = this.themeInflight.get(name);
|
|
157
|
+
if (pending) return pending;
|
|
158
|
+
const read = (async () => {
|
|
159
|
+
const res = await this.withSession(() => this.client.readResource({ uri: `unoverse://theme/${name}` }));
|
|
160
|
+
const first = res.contents[0] as { text?: string };
|
|
161
|
+
if (!first?.text) throw new Error(`No theme content for ${name}`);
|
|
162
|
+
const theme = JSON.parse(first.text) as ResolvedTheme;
|
|
163
|
+
this.themeCache.set(name, theme);
|
|
164
|
+
return theme;
|
|
165
|
+
})();
|
|
166
|
+
this.themeInflight.set(name, read);
|
|
167
|
+
read.finally(() => this.themeInflight.delete(name));
|
|
168
|
+
return read;
|
|
142
169
|
}
|
|
143
170
|
|
|
144
171
|
/** List available theme names (e.g. ["light", "dark"]) from the server. */
|
|
@@ -28,6 +28,24 @@ 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
|
+
|
|
31
49
|
// Experience analytics (UNOVERSE_ANALYTICS.md) — the SDK emits; the host decides where to.
|
|
32
50
|
export {
|
|
33
51
|
emitAnalytics,
|
|
@@ -42,3 +60,6 @@ export {
|
|
|
42
60
|
|
|
43
61
|
// The shared "react to state" predicate (visibleWhen / Switch / style.when).
|
|
44
62
|
export { matches, visible, selectCase } from "./conditions";
|
|
63
|
+
// REFERENCE, DON'T COPY: specialising a `Ref` against a shared definition, so the design
|
|
64
|
+
// system travels once instead of being pasted into every definition that uses it.
|
|
65
|
+
export { resolveRef } from "./refs";
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REFERENCE, DON'T COPY — resolving a `Ref` at render time.
|
|
3
|
+
*
|
|
4
|
+
* A design system's whole promise is "define once, use everywhere". Until now the server
|
|
5
|
+
* kept that promise by INLINING: every `Ref` was replaced with a specialised copy of the
|
|
6
|
+
* atom before serving, so the browser received one flat tree with the same button, the
|
|
7
|
+
* same field, pasted in at every use. Measured on the estate at the time this landed:
|
|
8
|
+
* 1259 KB served where 224 KB of distinct structure existed, and one card
|
|
9
|
+
* (`sab/product-card`) weighed 361 KB because it referenced a document that itself
|
|
10
|
+
* carried six copies of an eleven-branch field switch. Every page load paid it again,
|
|
11
|
+
* because MCP reads go over POST and no browser cache can hold them.
|
|
12
|
+
*
|
|
13
|
+
* A `Ref` is a PURE FUNCTION of the atom and the reference's own parameters:
|
|
14
|
+
*
|
|
15
|
+
* resolve(atom, { props, with, style, action, visibleWhen }) → tree
|
|
16
|
+
*
|
|
17
|
+
* `props` remaps the atom's field names onto the host's; `with` bakes per-use literals in;
|
|
18
|
+
* style, action and visibleWhen override. Nothing in that needs a server, which is why it
|
|
19
|
+
* belongs here: the definition then carries the reference (tiny) and the atom travels once
|
|
20
|
+
* and is cached by URI, shared by every definition that names it.
|
|
21
|
+
*
|
|
22
|
+
* PORTABLE BRAIN (UNOVERSE_SDK_PORTING §1): this is core, so every SDK port implements the
|
|
23
|
+
* same resolution and the fixtures prove they agree. A port that resolves `with` differently
|
|
24
|
+
* renders different copy from the same definition, which is exactly the class of drift the
|
|
25
|
+
* conformance suite exists to catch.
|
|
26
|
+
*
|
|
27
|
+
* A 1:1 port of the server's expansion (packages/base/src/definitions/definitions.ts:
|
|
28
|
+
* remapFields / applyLiterals / applyStyleLiterals / expandNode's Ref branch). The two must
|
|
29
|
+
* stay in step while the server still inlines for older clients.
|
|
30
|
+
*/
|
|
31
|
+
import type { UnoverseNode } from "./types";
|
|
32
|
+
|
|
33
|
+
type AnyNode = Record<string, any>;
|
|
34
|
+
|
|
35
|
+
/** Deep clone. The atom is CACHED and shared, so specialising it in place would poison
|
|
36
|
+
* every other user of that atom with one host's literals. */
|
|
37
|
+
const clone = <T,>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
|
|
38
|
+
|
|
39
|
+
/** `props` remaps the atom's OWN field names to the host's fields, everywhere a field name
|
|
40
|
+
* can appear: binds, both spellings of `visibleWhen`, and `style.when` predicates. */
|
|
41
|
+
function remapFields(node: AnyNode, propMap: Record<string, string>): void {
|
|
42
|
+
if (node.bind) for (const k of Object.keys(node.bind)) if (propMap[node.bind[k]]) node.bind[k] = propMap[node.bind[k]];
|
|
43
|
+
if (typeof node.visibleWhen === "string" && propMap[node.visibleWhen]) node.visibleWhen = propMap[node.visibleWhen];
|
|
44
|
+
// The OBJECT form remaps too. A guard is a guard whichever way it is written; carrying
|
|
45
|
+
// only the string form left an atom testing its own prop name after the host remapped it,
|
|
46
|
+
// so the guard read an absent key and the branch never showed.
|
|
47
|
+
if (node.visibleWhen && typeof node.visibleWhen === "object" && !Array.isArray(node.visibleWhen)) {
|
|
48
|
+
const g = node.visibleWhen as { field?: string };
|
|
49
|
+
if (typeof g.field === "string" && propMap[g.field]) g.field = propMap[g.field];
|
|
50
|
+
}
|
|
51
|
+
// `style.when` is the same predicate wearing a different hat: an atom whose LOOK is
|
|
52
|
+
// data-driven must follow its host's field for the same reason.
|
|
53
|
+
const when = (node.style as AnyNode | undefined)?.when;
|
|
54
|
+
if (Array.isArray(when)) {
|
|
55
|
+
for (const v of when as { field?: string }[]) if (v && typeof v.field === "string" && propMap[v.field]) v.field = propMap[v.field];
|
|
56
|
+
}
|
|
57
|
+
if (Array.isArray(node.children)) node.children.forEach((c: AnyNode) => remapFields(c, propMap));
|
|
58
|
+
if (node.template) remapFields(node.template as AnyNode, propMap);
|
|
59
|
+
if (node.cases && typeof node.cases === "object") for (const k of Object.keys(node.cases)) remapFields(node.cases[k] as AnyNode, propMap);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `{{name}}` substitution at any depth of a style object: a radial's `at`, a `when`
|
|
63
|
+
* clause's `apply`. Only the top level was walked once, and a nested one reached the
|
|
64
|
+
* renderer verbatim. */
|
|
65
|
+
function applyStyleLiterals(style: unknown, lits: Record<string, unknown>): void {
|
|
66
|
+
if (Array.isArray(style)) return style.forEach((v) => applyStyleLiterals(v, lits));
|
|
67
|
+
if (!style || typeof style !== "object") return;
|
|
68
|
+
const s = style as AnyNode;
|
|
69
|
+
for (const k of Object.keys(s)) {
|
|
70
|
+
const v = s[k];
|
|
71
|
+
if (typeof v === "string" && v.includes("{{")) s[k] = v.replace(/\{\{(\w[\w.]*)\}\}/g, (m, f) => (f in lits ? String(lits[f]) : m));
|
|
72
|
+
else if (v && typeof v === "object") applyStyleLiterals(v, lits);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** `with` resolves an atom's BINDINGS to literal content: a bound attribute becomes a
|
|
77
|
+
* hardcoded one, and a guard on a truthy literal is satisfied statically and dropped. */
|
|
78
|
+
function applyLiterals(node: AnyNode, lits: Record<string, unknown>): void {
|
|
79
|
+
if (node.bind) {
|
|
80
|
+
for (const attr of Object.keys(node.bind)) {
|
|
81
|
+
const field = node.bind[attr];
|
|
82
|
+
if (typeof field === "string" && field in lits) {
|
|
83
|
+
node[attr] = lits[field];
|
|
84
|
+
delete node.bind[attr];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (Object.keys(node.bind).length === 0) delete node.bind;
|
|
88
|
+
}
|
|
89
|
+
if (typeof node.visibleWhen === "string" && node.visibleWhen in lits) {
|
|
90
|
+
if (lits[node.visibleWhen]) delete node.visibleWhen; // statically satisfied
|
|
91
|
+
// a falsy literal KEEPS the (now never-true) guard, so the node stays hidden
|
|
92
|
+
}
|
|
93
|
+
if (node.style && typeof node.style === "object") applyStyleLiterals(node.style as AnyNode, lits);
|
|
94
|
+
if (Array.isArray(node.children)) node.children.forEach((c: AnyNode) => applyLiterals(c, lits));
|
|
95
|
+
if (node.template) applyLiterals(node.template as AnyNode, lits);
|
|
96
|
+
if (node.cases && typeof node.cases === "object") for (const k of Object.keys(node.cases)) applyLiterals(node.cases[k] as AnyNode, lits);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Specialise `atomRoot` for one `Ref`. Returns a NEW tree; the atom is untouched.
|
|
101
|
+
*
|
|
102
|
+
* Returns null when the atom is not available, and the caller renders nothing rather than
|
|
103
|
+
* a broken box: a missing atom is a serving problem, and a hole is easier to diagnose than
|
|
104
|
+
* a mystery element.
|
|
105
|
+
*/
|
|
106
|
+
export function resolveRef(refNode: UnoverseNode, atomRoot: UnoverseNode | undefined | null): UnoverseNode | null {
|
|
107
|
+
if (!atomRoot) return null;
|
|
108
|
+
const n = refNode as AnyNode;
|
|
109
|
+
const root = clone(atomRoot) as AnyNode;
|
|
110
|
+
remapFields(root, (n.props ?? {}) as Record<string, string>);
|
|
111
|
+
if (n.with && typeof n.with === "object") applyLiterals(root, n.with as Record<string, unknown>);
|
|
112
|
+
// The reference's own overrides win: these are the per-host facts an atom cannot carry.
|
|
113
|
+
if (n.visibleWhen) root.visibleWhen = n.visibleWhen;
|
|
114
|
+
if (n.action) root.action = n.action;
|
|
115
|
+
if (n.style) root.style = { ...((root.style as AnyNode) ?? {}), ...(n.style as AnyNode) };
|
|
116
|
+
return root as UnoverseNode;
|
|
117
|
+
}
|
|
@@ -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):
|