@unoverse-platform/studio 0.1.97 → 0.1.99

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 (120) hide show
  1. package/bin/studio.mjs +2 -2
  2. package/local/catalog.ts +5 -1
  3. package/local/nodes.ts +28 -41
  4. package/local/plugin.ts +71 -13
  5. package/local/sample-identity/brand.yaml +24 -0
  6. package/local/sample-identity/organisation.yaml +14 -0
  7. package/local/sample-identity/purpose.yaml +14 -0
  8. package/local/sample-identity/story.yaml +18 -0
  9. package/local/scaffold.mjs +43 -23
  10. package/local/source.ts +3 -8
  11. package/package.json +1 -1
  12. package/screens/AgentsView.tsx +173 -0
  13. package/screens/ComponentsView.tsx +2 -2
  14. package/screens/EmptyLibrary.tsx +1 -1
  15. package/screens/IdentityView.tsx +3 -3
  16. package/screens/PromptBlocksView.tsx +1 -1
  17. package/screens/TemplatesLibraryView.tsx +91 -6
  18. package/screens/TemplatesView.tsx +24 -9
  19. package/screens/index.ts +2 -0
  20. package/src/App.tsx +8 -3
  21. package/src/host.ts +1 -14
  22. package/vendor/base/src/agent-mcp/discovery.ts +76 -18
  23. package/vendor/base/src/agent-mcp/handoff.ts +11 -31
  24. package/vendor/base/src/agent-mcp/index.ts +5 -5
  25. package/vendor/base/src/agent-mcp/invoke.ts +37 -19
  26. package/vendor/base/src/agent-mcp/lean.ts +19 -0
  27. package/vendor/base/src/agent-mcp/memoryContext.ts +15 -9
  28. package/vendor/base/src/agent-mcp/memoryIngest.ts +8 -3
  29. package/vendor/base/src/agent-mcp/rowComponents.ts +184 -72
  30. package/vendor/base/src/agent-mcp/tools.ts +136 -7
  31. package/vendor/base/src/agent-mcp/types.ts +47 -10
  32. package/vendor/base/src/auth/auth.ts +35 -5
  33. package/vendor/base/src/definitions/isolated.ts +37 -0
  34. package/vendor/base/src/definitions/load/compile/component.ts +59 -4
  35. package/vendor/base/src/definitions/load/compile/sections.ts +7 -1
  36. package/vendor/base/src/definitions/load/compile/template.ts +19 -15
  37. package/vendor/base/src/definitions/load/expand/inherit.ts +5 -1
  38. package/vendor/base/src/definitions/load/expand/referenced.ts +21 -2
  39. package/vendor/base/src/definitions/load/expand/refs.ts +14 -0
  40. package/vendor/base/src/definitions/load/expand/walk.ts +88 -38
  41. package/vendor/base/src/definitions/load/load.ts +37 -28
  42. package/vendor/base/src/definitions/model.ts +8 -12
  43. package/vendor/base/src/definitions/registry/agents.ts +117 -0
  44. package/vendor/base/src/definitions/registry/catalog.ts +40 -24
  45. package/vendor/base/src/definitions/registry/datasetSql.ts +149 -0
  46. package/vendor/base/src/definitions/registry/homes.ts +69 -5
  47. package/vendor/base/src/definitions/registry/objects.ts +413 -0
  48. package/vendor/base/src/definitions/registry/orgDocuments.ts +69 -29
  49. package/vendor/base/src/definitions/registry/orgPack.ts +11 -11
  50. package/vendor/base/src/definitions/registry/pipelines.ts +197 -0
  51. package/vendor/base/src/definitions/theme.ts +1 -2
  52. package/vendor/base/src/items/collect.ts +29 -25
  53. package/vendor/base/src/items/publish.ts +30 -6
  54. package/vendor/base/src/lint/context.mjs +17 -0
  55. package/vendor/base/src/lint/design/file.mjs +199 -2
  56. package/vendor/base/src/lint/design/index.mjs +66 -73
  57. package/vendor/base/src/lint/design/vocabulary.mjs +5 -2
  58. package/vendor/base/src/lint/design/walk.mjs +14 -4
  59. package/vendor/base/src/lint/index.mjs +15 -9
  60. package/vendor/base/src/lint/nodes/blocks.mjs +6 -2
  61. package/vendor/base/src/lint/nodes/index.mjs +5 -0
  62. package/vendor/base/src/lint/nodes/node.mjs +157 -7
  63. package/vendor/base/src/lint/objects/index.d.mts +32 -0
  64. package/vendor/base/src/lint/objects/index.mjs +324 -0
  65. package/vendor/base/src/lint/objects/rules.d.mts +44 -0
  66. package/vendor/base/src/lint/objects/rules.mjs +446 -0
  67. package/vendor/base/src/lint/prompts/index.d.mts +34 -0
  68. package/vendor/base/src/lint/prompts/index.mjs +198 -0
  69. package/vendor/base/src/manifests/compose.ts +80 -9
  70. package/vendor/base/src/manifests/executor/callback.ts +15 -4
  71. package/vendor/base/src/manifests/executor/context.ts +9 -1
  72. package/vendor/base/src/manifests/executor/memory.ts +83 -3
  73. package/vendor/base/src/manifests/executor/session.ts +50 -0
  74. package/vendor/base/src/manifests/executor/toolBridge.ts +114 -49
  75. package/vendor/base/src/manifests/runtime/auth/aws.ts +14 -1
  76. package/vendor/base/src/manifests/runtime/channels/final.ts +46 -34
  77. package/vendor/base/src/manifests/runtime/channels/service.ts +30 -32
  78. package/vendor/base/src/manifests/runtime/channels/workflow.ts +6 -11
  79. package/vendor/base/src/manifests/runtime/context.ts +18 -6
  80. package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +159 -3
  81. package/vendor/base/src/manifests/runtime/duplex/session.ts +304 -9
  82. package/vendor/base/src/manifests/runtime/events.ts +39 -3
  83. package/vendor/base/src/manifests/runtime/http/concurrency.ts +44 -0
  84. package/vendor/base/src/manifests/runtime/http/request.ts +36 -7
  85. package/vendor/base/src/manifests/runtime/index.ts +3 -2
  86. package/vendor/base/src/manifests/runtime/loops/repeat.ts +86 -0
  87. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +30 -3
  88. package/vendor/base/src/manifests/runtime/usage.ts +10 -0
  89. package/vendor/base/src/paths.ts +16 -8
  90. package/vendor/base/src/platform/audioManager.ts +32 -0
  91. package/vendor/base/src/platform/executionContext.ts +14 -0
  92. package/vendor/base/src/platform/liveInput.ts +52 -0
  93. package/vendor/base/src/plugins/discovery.ts +1 -1
  94. package/vendor/base/src/template/SafeExpression.ts +6 -0
  95. package/vendor/base/src/workflow/GetMemoryService.ts +12 -11
  96. package/vendor/base/src/workflow/PlatformMemoryCore.ts +23 -10
  97. package/vendor/sdk/appEngine.tsx +30 -11
  98. package/vendor/sdk/lib/UnoverseComponent.tsx +8 -4
  99. package/vendor/sdk/lib/app.tsx +70 -95
  100. package/vendor/sdk/lib/connection.tsx +47 -57
  101. package/vendor/sdk/lib/core/actions.ts +6 -0
  102. package/vendor/sdk/lib/core/apps.ts +66 -81
  103. package/vendor/sdk/lib/core/client.ts +5 -14
  104. package/vendor/sdk/lib/core/connection.ts +30 -0
  105. package/vendor/sdk/lib/core/index.ts +2 -2
  106. package/vendor/sdk/lib/core/store.ts +90 -15
  107. package/vendor/sdk/lib/core/types.ts +9 -8
  108. package/vendor/sdk/lib/primitives.tsx +113 -4
  109. package/vendor/sdk/lib/realtime/useAudioPlayback.ts +8 -1
  110. package/vendor/sdk/lib/render.tsx +3 -3
  111. package/vendor/sdk/lib/voice.tsx +27 -13
  112. package/local/sample-identity/brand/brand.yaml +0 -88
  113. package/local/sample-identity/brand/manifest.yaml +0 -13
  114. package/local/sample-identity/organisation/manifest.yaml +0 -15
  115. package/local/sample-identity/organisation/organisation.yaml +0 -53
  116. package/local/sample-identity/purpose/manifest.yaml +0 -12
  117. package/local/sample-identity/purpose/purpose.yaml +0 -46
  118. package/local/sample-identity/story/manifest.yaml +0 -12
  119. package/local/sample-identity/story/story.yaml +0 -63
  120. package/vendor/base/src/definitions/registry/identitySeed.ts +0 -17
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Used by the intrinsic agent memory tools (PlatformMemoryTools — the Memory
7
7
  * toggles) and the Universe Copilot harness. NOT used by spatial search —
8
- * "SpatialSearch serves knowledge tools only" (MEMORY_SPEC.md); the old
8
+ * "SpatialSearch serves knowledge tools only" (docs/architecture/signal/memory/MEMORY.md); the old
9
9
  * includeMemory-in-search path is dead.
10
10
  */
11
11
 
@@ -16,7 +16,8 @@ const MEMORY_SERVICE_URL = process.env.MEMORY_SERVICE_URL || "http://localhost:4
16
16
 
17
17
  export interface GetMemoryInput {
18
18
  userId: string;
19
- workflowId: string;
19
+ /** The organisation the run works as: the scope of a person's memory (MEMORY.md § 2.10). */
20
+ org: string;
20
21
  query?: string;
21
22
  domains?: string[];
22
23
  minCertainty?: number;
@@ -39,10 +40,10 @@ export class GetMemoryService {
39
40
  * Retrieve composed understanding for a user (uses /compose endpoint)
40
41
  */
41
42
  static async compose(input: GetMemoryInput): Promise<any> {
42
- const { userId, workflowId, query, domains, accessToken } = input;
43
+ const { userId, org, query, domains, accessToken } = input;
43
44
 
44
45
  try {
45
- const params = new URLSearchParams({ userId, workflowId, mode: "full" });
46
+ const params = new URLSearchParams({ userId, org, mode: "full" });
46
47
  if (domains?.length) params.set("domains", domains.join(","));
47
48
  if (query) params.set("context", query);
48
49
 
@@ -67,11 +68,11 @@ export class GetMemoryService {
67
68
  /**
68
69
  * Get recent evidence snapshot (recency-ordered, no query)
69
70
  */
70
- static async recent(input: { userId: string; workflowId: string; limit?: number; accessToken?: string }): Promise<MemoryEvidence[]> {
71
- const { userId, workflowId, limit, accessToken } = input;
71
+ static async recent(input: { userId: string; org: string; limit?: number; accessToken?: string }): Promise<MemoryEvidence[]> {
72
+ const { userId, org, limit, accessToken } = input;
72
73
 
73
74
  try {
74
- const params = new URLSearchParams({ userId, workflowId });
75
+ const params = new URLSearchParams({ userId, org });
75
76
  if (limit) params.set("limit", String(limit));
76
77
 
77
78
  const headers: Record<string, string> = { "Content-Type": "application/json" };
@@ -96,14 +97,14 @@ export class GetMemoryService {
96
97
  * Search user evidence by query (uses /evidence/search endpoint)
97
98
  */
98
99
  static async search(input: GetMemoryInput): Promise<MemoryEvidence[]> {
99
- const { userId, workflowId, query, domains, minCertainty, limit, accessToken } = input;
100
+ const { userId, org, query, domains, minCertainty, limit, accessToken } = input;
100
101
 
101
102
  if (!query) {
102
103
  return this.retrieve(input);
103
104
  }
104
105
 
105
106
  try {
106
- const params = new URLSearchParams({ userId, workflowId, query });
107
+ const params = new URLSearchParams({ userId, org, query });
107
108
  if (domains?.length) params.set("domains", domains.join(","));
108
109
  if (minCertainty !== undefined) params.set("minCertainty", String(minCertainty));
109
110
  if (limit) params.set("limit", String(limit));
@@ -130,10 +131,10 @@ export class GetMemoryService {
130
131
  * Retrieve raw evidence (structured, no query needed — uses /evidence endpoint)
131
132
  */
132
133
  static async retrieve(input: GetMemoryInput): Promise<MemoryEvidence[]> {
133
- const { userId, workflowId, domains, minCertainty, limit, accessToken } = input;
134
+ const { userId, org, domains, minCertainty, limit, accessToken } = input;
134
135
 
135
136
  try {
136
- const params = new URLSearchParams({ userId, workflowId });
137
+ const params = new URLSearchParams({ userId, org });
137
138
  if (domains?.length) params.set("domains", domains.join(","));
138
139
  if (minCertainty !== undefined) params.set("minCertainty", String(minCertainty));
139
140
  if (limit) params.set("limit", String(limit || 20));
@@ -38,7 +38,7 @@ const MEMORY_SERVICE_URL = process.env.MEMORY_SERVICE_URL || "http://localhost:4
38
38
  // Tool schemas
39
39
  // ---------------------------------------------------------------------------
40
40
 
41
- // The ONE user-memory tool (MEMORY_SPEC v3 § Serving). The standing snapshot is no
41
+ // The ONE user-memory tool (docs/architecture/signal/memory/MEMORY.md § Serving). The standing snapshot is no
42
42
  // longer a tool at all — the agent HARNESS injects who-the-user-is + current state
43
43
  // into the system context at init (zero calls). queryMemory remains as the single
44
44
  // depth door: search the user's remembered facts when a topic needs prior context.
@@ -268,6 +268,9 @@ export function mergePlatformMemorySchema(baseSchema: any | null, flags: MemoryF
268
268
  interface MemoryCallIdentity {
269
269
  userId: string;
270
270
  workflowId: string;
271
+ /** The organisation the run works as: the scope of the person's memory (MEMORY.md § 2.10),
272
+ * and the stamp on a goal. Named by the run, else the canvas's default. */
273
+ org?: string;
271
274
  agentId: string;
272
275
  agentName?: string;
273
276
  accessToken?: string;
@@ -279,6 +282,7 @@ function resolveIdentity(params: any, context: any): MemoryCallIdentity | { erro
279
282
  const userId =
280
283
  params?.userId || context?.workflow?.variables?.userId || context?.publishingContext?.userId;
281
284
  const workflowId = context?.workflowId || context?.workflow?.id;
285
+ const org: string | undefined = context?.publishingContext?.org ?? context?.workflow?.org ?? undefined;
282
286
  if (!userId) return { error: "No user identity in execution context — cannot access memory" };
283
287
  if (!workflowId) return { error: "No workflow id in execution context — cannot access memory" };
284
288
 
@@ -296,6 +300,7 @@ function resolveIdentity(params: any, context: any): MemoryCallIdentity | { erro
296
300
  return {
297
301
  userId,
298
302
  workflowId,
303
+ org,
299
304
  agentId,
300
305
  agentName,
301
306
  accessToken: context?.auth?.accessToken,
@@ -328,7 +333,7 @@ async function memoryServerPost(path: string, body: any, accessToken?: string):
328
333
  }
329
334
 
330
335
  /**
331
- * Serve search results LEAN (MEMORY_SPEC v3): claim + certainty + domain only —
336
+ * Serve search results LEAN (docs/architecture/signal/memory/MEMORY.md § Serving): claim + certainty + domain only —
332
337
  * NO premises, NO content dupe, NO ids/timestamps/reinforcement bookkeeping.
333
338
  * Deduped by normalized claim so paraphrase clusters read as one line. The raw
334
339
  * retrieval is unchanged; the dashboard still gets full rows.
@@ -360,15 +365,21 @@ export async function executePlatformMemoryMethod(method: string, params: any, c
360
365
  logger.warn(`Memory tool ${method} refused`, { error: identity.error });
361
366
  return { success: false, method, error: identity.error };
362
367
  }
363
- const { userId, workflowId, agentId, agentName, accessToken, conversationId, executionId } = identity;
368
+ const { userId, workflowId, org, agentId, agentName, accessToken, conversationId, executionId } = identity;
364
369
 
365
370
  try {
366
371
  switch (method) {
367
372
  // ---- user memory (the ONE depth tool; the standing snapshot is harness-injected) ----
368
373
  case "queryMemory": {
374
+ // A person's memory is scoped by the org the run works as; a run that cannot name
375
+ // one gets no memory rather than someone else's (MEMORY.md § 2.10).
376
+ if (!org) {
377
+ logger.warn("Memory tool queryMemory refused: the run names no org");
378
+ return { success: false, method, error: "No org in execution context — cannot access memory" };
379
+ }
369
380
  const evidence = await GetMemoryService.search({
370
381
  userId,
371
- workflowId,
382
+ org,
372
383
  query: params?.query,
373
384
  limit: 15,
374
385
  accessToken,
@@ -379,7 +390,7 @@ export async function executePlatformMemoryMethod(method: string, params: any, c
379
390
 
380
391
  // ---- agent memory (goal-scoped) ----
381
392
  case "getGoalContext": {
382
- return await memoryServerGet("/agent/goal-context", { userId, workflowId, agentId }, accessToken);
393
+ return await memoryServerGet("/agent/goal-context", { userId, workflowId, agentId, ...(org ? { org } : {}) }, accessToken);
383
394
  }
384
395
  case "writeNote": {
385
396
  return await memoryServerPost(
@@ -387,6 +398,7 @@ export async function executePlatformMemoryMethod(method: string, params: any, c
387
398
  {
388
399
  userId,
389
400
  workflowId,
401
+ org,
390
402
  agentId,
391
403
  agentName,
392
404
  content: params?.content,
@@ -405,7 +417,8 @@ export async function executePlatformMemoryMethod(method: string, params: any, c
405
417
  // because null means CLEAR.
406
418
  const { description, acceptanceCriteria, directive, ...stateFields } = params || {};
407
419
  const state = Object.keys(stateFields).length > 0 ? stateFields : undefined;
408
- const body: Record<string, any> = { userId, workflowId, agentId, agentName, description, acceptanceCriteria, state };
420
+ // `org` is the stamp on the goal at create (MEMORY.md § 2.10); identity stays the canvas.
421
+ const body: Record<string, any> = { userId, workflowId, org, agentId, agentName, description, acceptanceCriteria, state };
409
422
  if (params && Object.prototype.hasOwnProperty.call(params, "directive")) body.directive = directive ?? null;
410
423
  return await memoryServerPost("/agent/state", body, accessToken);
411
424
  }
@@ -413,28 +426,28 @@ export async function executePlatformMemoryMethod(method: string, params: any, c
413
426
  // The judge's verdict → one ledger row. Platform-only (PLATFORM_MEMORY_METHODS).
414
427
  return await memoryServerPost(
415
428
  "/agent/attempt",
416
- { userId, workflowId, agentId, results: params?.results, deliverables: params?.deliverables || [] },
429
+ { userId, workflowId, org, agentId, results: params?.results, deliverables: params?.deliverables || [] },
417
430
  accessToken,
418
431
  );
419
432
  }
420
433
  case "searchHistory": {
421
434
  return await memoryServerGet(
422
435
  "/agent/history",
423
- { userId, workflowId, limit: String(params?.limit || 10) },
436
+ { userId, workflowId, ...(org ? { org } : {}), limit: String(params?.limit || 10) },
424
437
  accessToken,
425
438
  );
426
439
  }
427
440
  case "resumeGoal": {
428
441
  return await memoryServerPost(
429
442
  "/agent/resume",
430
- { userId, workflowId, agentId, goalId: params?.goalId },
443
+ { userId, workflowId, org, agentId, goalId: params?.goalId },
431
444
  accessToken,
432
445
  );
433
446
  }
434
447
  case "archiveGoal": {
435
448
  return await memoryServerPost(
436
449
  "/agent/archive",
437
- { userId, workflowId, agentId, summary: params?.summary, conversationId },
450
+ { userId, workflowId, org, agentId, summary: params?.summary, conversationId },
438
451
  accessToken,
439
452
  );
440
453
  }
@@ -42,7 +42,7 @@ export interface AppConfig {
42
42
  }
43
43
 
44
44
  export function AppEngine(cfg: AppConfig) {
45
- const { serverUrl, apiUrl, appId, getAccessToken, userId, conversationId, surface, analyticsDebug } = cfg;
45
+ const { serverUrl, apiUrl, appId: configuredApp, getAccessToken, userId, conversationId, surface, analyticsDebug } = cfg;
46
46
 
47
47
  // ONE STORE PER CONVERSATION. The host may reuse this engine (the native-host
48
48
  // iframe posts new config on app switch), and a template swap keeps the store by
@@ -63,11 +63,28 @@ export function AppEngine(cfg: AppConfig) {
63
63
  // Setting state during render makes React re-render BEFORE the commit, so the
64
64
  // connection effect only ever sees either the old app intact or an empty binding —
65
65
  // never the cross-app hybrid.
66
- const [boundFor, setBoundFor] = useState(appId);
67
- if (boundFor !== appId) {
68
- setBoundFor(appId);
66
+ // WHICH APP IS RUNNING, and there are two ways it changes. The host re-posts its
67
+ // config (the native-host iframe's app switch), and the SERVER can now swap it mid
68
+ // conversation — an app discovered and called IS the surface, so opening one replaces
69
+ // what is mounted (owner ruling 2026-09-07). One piece of state either way, so the
70
+ // stale-binding guard below covers both rather than only the host's.
71
+ const [appId, setAppId] = useState(configuredApp);
72
+ const [boundFor, setBoundFor] = useState(configuredApp);
73
+ // The host's own switch WINS and resets: a new config is a new intent.
74
+ if (boundFor !== configuredApp) {
75
+ setBoundFor(configuredApp);
76
+ setAppId(configuredApp);
69
77
  setBinding({});
70
78
  }
79
+ // The server's switch, same reset: a different app is a different workflow and trigger,
80
+ // and the binding is read async from the incoming app's manifest (the effect below).
81
+ // Clearing it here is what stops a session opening on the OLD app's trigger against the
82
+ // new app — the hazard the render-time reset above exists for.
83
+ const switchApp = (next: string) => {
84
+ if (next === appId) return;
85
+ setAppId(next);
86
+ setBinding({});
87
+ };
71
88
  // STATE-OWNED WIDTH (design data): the layout root's `appWidth` = the core surface's
72
89
  // constant width; an occupied panel slot's `appWidth` adds to it (computeAppWidth).
73
90
  // No declared width → the host keeps its own default. There is no other path.
@@ -177,11 +194,12 @@ export function AppEngine(cfg: AppConfig) {
177
194
  userId: userId ?? "",
178
195
  conversationId,
179
196
  chatId: conversationId,
197
+ appId,
180
198
  },
181
199
  store,
182
200
  // Open only once BOTH are ready: the user identity AND the manifest binding (read async).
183
201
  // Avoids the transient "connection missing workflowId/userId/conversationId" warning.
184
- { enabled: !!userId && !!binding.workflow },
202
+ { enabled: !!userId && !!binding.workflow, onAppSwitch: switchApp },
185
203
  );
186
204
 
187
205
  // STATUS TO THE HOST. In native-host mode the iframe owns the only real
@@ -294,14 +312,15 @@ export function AppEngine(cfg: AppConfig) {
294
312
  store.setDraft(typeof (data as any)?.text === "string" ? (data as any).text : "");
295
313
  return;
296
314
  }
297
- // Any other named server action (submit, selectCourse…): if the server is
298
- // ELICITING on this session (the native §3.3 path — an app tool call is
299
- // waiting), the submitted state ANSWERS the elicitation; one accept then
300
- // completes the waiting node AND the held tool call server-side. Otherwise
301
- // fall back to the data-plane `user_action` submit (waiter transport).
315
+ // THE SPEC'S ANSWERS, AND NOTHING ELSE (UNOVERSE_MCP_COMPLETE_GUIDE.md, "How the SDK
316
+ // answers an elicitation"): while the server is ELICITING on this session, the
317
+ // component's `submit` answers `accept` with its slice and its `cancel` answers
318
+ // `cancel`. Every other action (a state it enters, a step, a tab) is the component's
319
+ // own and goes to the server as usual. Without a pending elicitation, a submit falls
320
+ // back to the data-plane `user_action` waiter (a workflow node waiting on a component).
302
321
  if (!t) return;
303
322
  const state = (meta?.state ?? data) as Record<string, unknown>;
304
- if (conn.resolveElicitation(state)) return;
323
+ if ((t === "submit" || t === "cancel") && conn.resolveElicitation(state, t === "cancel" ? "cancel" : "accept")) return;
305
324
  conn.sendAction(t, {
306
325
  componentNodeId: meta?.nodeId,
307
326
  componentType: meta?.type,
@@ -18,6 +18,7 @@ import { dispatchAction, matches, type ComponentStore, type ResolvedTheme, type
18
18
  import { renderNode, keyframesCss, type ActionHandler, type SlotResolver } from "./render";
19
19
  import { useUnoverseTheme, ensureFontStylesheets } from "./theme";
20
20
  import { IsolatedRoot } from "./isolate";
21
+ import { stateOfUri } from "./core/apps";
21
22
 
22
23
  /** Subscribe to an instance in the store, re-rendering when a merge lands. Snapshots THIS
23
24
  * instance's slice (merge() gives it a fresh identity), so chunks for other components bail
@@ -92,10 +93,13 @@ export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, ex
92
93
  // orgs share a name. The rebuild-from-type stays as the fallback for events that
93
94
  // carry no componentUrl. (Still the plain URI, never the withdrawn `@refs` form —
94
95
  // that suffix broke Studio's local definition route.)
95
- const resolvedUri = store
96
- ? (live.componentUrl ?? (live.type ? `unoverse://components/${live.type}` : null))
97
- : (uri ?? null);
98
- const sourceData = store ? (extraData ? { ...(live.data ?? {}), ...extraData } : live.data) : data;
96
+ // THE HASH NAMES THE STATE (stateOfUri): the address is read without it, and it seeds the
97
+ // public `state` of a static instance that carries none, so `...#page` opens on the page.
98
+ const address = store ? (live.componentUrl ?? (live.type ? `unoverse://components/${live.type}` : null)) : (uri ?? null);
99
+ const { uri: resolvedUri, state: hashState } = address ? stateOfUri(address) : { uri: null as string | null };
100
+ const sourceData = store
101
+ ? (extraData ? { ...(live.data ?? {}), ...extraData } : live.data)
102
+ : (hashState && data?.state == null ? { state: hashState, ...(data ?? {}) } : data);
99
103
  // No theme passed → fetch it from the server (the SDK owns zero token values). The
100
104
  // theme REF comes from the definition itself: a template's `org` names its client's
101
105
  // theme (`<org>/light`); a universal component previews under the default set
@@ -16,14 +16,15 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, use
16
16
  import {
17
17
  collectSurfacedViews,
18
18
  cssWidth,
19
+ placeWidthStyle,
19
20
  dispatchAction,
20
21
  formatRelative,
21
22
  isViewField,
23
+ mountAliases,
22
24
  orderedClaims,
23
25
  propDefaults,
24
26
  resolveActiveLayout,
25
27
  resolveActiveView,
26
- resolveLatch,
27
28
  selectPointers,
28
29
  viewOf,
29
30
  type ComponentStore,
@@ -254,6 +255,8 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
254
255
  // guest's hand), it happens ONCE per instance (a placed slice has a view), and the
255
256
  // component owns the axis from then on: the template never writes it again.
256
257
  useEffect(() => {
258
+ // An app with places is drawn by the screen document: nothing is placed by a scan.
259
+ if (def?.places?.length) return;
257
260
  const order = def?.stateOrder;
258
261
  if (!order?.length) return;
259
262
  for (const turn of store.getResponses())
@@ -294,6 +297,10 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
294
297
  for (const secs of Object.values((tpl as { sections?: Record<string, { face?: string }[]> }).sections ?? {}))
295
298
  for (const sec of secs) if (sec?.face) views.add(sec.face);
296
299
  }
300
+ // `inline` IS NEVER A CLAIM: it is the universal fallback face, drawn in the flow. A
301
+ // streamed template's own `inline` layout (its compact chip) marked it claimed, so the
302
+ // flow skipped the chip (observed live 2026-09-06).
303
+ views.delete("inline");
297
304
  return views;
298
305
  }, [activeRoot, def, mounted]);
299
306
 
@@ -306,7 +313,11 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
306
313
  // this stays a hook, and off the SAME derivation the render path uses.
307
314
  const ladder = def ? resolveActiveView(def, store, surfacedViews) : { view: "", byGuest: false };
308
315
  useEffect(() => {
309
- if (ladder.view && !ladder.byGuest) store.cancelBelow(ladder.view, orderedClaims(def));
316
+ if (def?.places?.length) return;
317
+ if (ladder.view && !ladder.byGuest) {
318
+ const order = orderedClaims(def);
319
+ store.cancelBelow(ladder.view, order, mountAliases(def, new Set(order)));
320
+ }
310
321
  // `structureVersion`: EVERY arrival re-runs this, not only the ones that change the
311
322
  // winner. A card landing while a higher state is ALREADY active would otherwise keep
312
323
  // its claim forever — the walk never re-ran — and closing the higher state dropped
@@ -315,52 +326,6 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
315
326
  // only bumps when it changed something, so this settles in one extra pass.
316
327
  }, [ladder.view, ladder.byGuest, def, store, structureVersion]);
317
328
 
318
- /**
319
- * THE LATCH, PUBLISHED FOR CHROME (docs/MCP_COMPLETE_GUIDE.md §The Component Latch).
320
- *
321
- * `resolveLatch` derives WHICH instance the conversation addresses; the composer's pill
322
- * is ordinary design chrome, so it needs those facts as template state to bind against. Same
323
- * shape as the voice service (§4 "services feed the state"): a producer writes derived
324
- * values into the normal bucket, and `core` learns no UI concept — nothing here knows a
325
- * pill exists.
326
- *
327
- * `latchDismissed` is written as bare `true` by the ✕ (chrome cannot name a key it does
328
- * not know) and NORMALISED here to the dismissed holder's key. Keyed, so the dismissal
329
- * dies with the thing dismissed: a different component taking the latch is a different
330
- * key, and its pill comes up as it should rather than being suppressed by a stale flag.
331
- */
332
- const tstate = store.getAppState() as Record<string, unknown>;
333
- const dismissedRaw = tstate.latchDismissed;
334
- const held = def ? resolveLatch(def, store, ladder.view, typeof dismissedRaw === "string" ? dismissedRaw : undefined) : null;
335
- useEffect(() => {
336
- if (dismissedRaw === true) {
337
- // Resolve the bare flag against whoever holds it RIGHT NOW, then re-derive.
338
- const current = def ? resolveLatch(def, store, ladder.view) : null;
339
- store.mergeAppState({ latchDismissed: current ? current.key : "" });
340
- return;
341
- }
342
- const l = held?.latch as { title?: unknown } | undefined;
343
- const title = typeof l?.title === "string" ? l.title : "";
344
- // `latched` is the DISCRIMINANT chrome branches on, exactly as the voice service
345
- // publishes `callState`: ONE field for the axis, never a truthy title plus an inverse
346
- // guard on its sibling, which is the boolean soup design/state.md rules out ("a case
347
- // never re-guards its own discriminant"). Title and icon are the held case's DATA, not
348
- // its condition — which is why they are separate keys and not the condition itself.
349
- const latched = held != null;
350
- if (tstate.latched !== latched || tstate.latchTitle !== title || tstate.latchKey !== (held?.key ?? ""))
351
- store.mergeAppState({ latched, latchTitle: title, latchKey: held?.key ?? "" });
352
- }, [
353
- held?.key,
354
- held?.latch,
355
- dismissedRaw,
356
- ladder.view,
357
- def,
358
- store,
359
- tstate.latched,
360
- tstate.latchTitle,
361
- tstate.latchKey,
362
- structureVersion,
363
- ]);
364
329
 
365
330
  // Loading/error hints carry NO styling — the SDK owns zero styles.
366
331
  if (error) return <div>Unoverse error: {error}</div>;
@@ -434,8 +399,54 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
434
399
  // ComponentSlot — filter the timeline to matching component pointers (KeyService).
435
400
  // A FACTORY: a mounted template's slots select within a SCOPE (the current
436
401
  // delivery — hold: latest, basic form); the app's own slots stay unscoped.
402
+ // THE STATE THE SCREEN PLACED IT IN (docs/unoverse/design/screen/UNOVERSE_SCREEN.md §2.6): a place draws
403
+ // each interface in that face, so a guest's tap shows once the screen has placed it, and an open
404
+ // task can never draw in the conversation.
405
+ const placed = (p: string): Record<string, unknown> | undefined => {
406
+ const state = store.placedState(p);
407
+ return state ? { state } : undefined;
408
+ };
409
+ // WHAT A SLOT OR A PLACE HOLDS, drawn: its fallback when empty; else its frame around the
410
+ // leaves, a single occupant filling the frame; else the leaves as a list.
411
+ const drawPointers = (node: UnoverseNode, pointers: string[], key: React.Key | undefined, fill: boolean): ReactNode => {
412
+ if (pointers.length === 0) {
413
+ return node.fallback ? renderNode(node.fallback, {}, dispatch, theme, key ?? "fallback", undefined, def?.defs) : null;
414
+ }
415
+ // `frame` — chrome that exists ONLY because the slot matched (fallback's twin).
416
+ // The frame subtree renders with a slot resolver that serves the outer slot's
417
+ // matches to any bare nested ComponentSlot — so a focus overlay is BORN when a
418
+ // component enters the state and DIES when it leaves (§5b: derived, not stored).
419
+ if (node.frame) {
420
+ // A SURFACE'S SINGLE OCCUPANT FILLS THE SURFACE (docs/design/05): a limit-1
421
+ // surface (detail panel, focus overlay) gives its occupant the frame's full
422
+ // height — the component's `height: "full"` finally has a definite container,
423
+ // with zero per-face styling. Multi-occupant surfaces (a rail) stay content-sized.
424
+ // Multi-occupant surfaces (a rail) animate reorders; a single occupant fills its
425
+ // frame directly — a measuring wrapper there would break the height chain.
426
+ const innerSlot: SlotResolver = () =>
427
+ fill
428
+ ? pointers.map((p) => leaf(p, placed(p), fill))
429
+ : <FlipList items={pointers.map((p) => ({ key: store.split(p).nodeId, node: leaf(p, placed(p)) }))} />;
430
+ // A panel declares its width ONCE (docs/design/05): `appWidth` grows the app
431
+ // (computeAppWidth) AND sizes the frame box here — the two can never disagree.
432
+ // Named sizes resolve via theme.appSize like any token; unresolvable → no width.
433
+ // `flex: 0 0 auto` — a panel is its declared width ALWAYS. A container that
434
+ // can't fit the sum clips at the edge; panels never compress into garbage.
435
+ // ONE WIDTH LAW (core/apps placeWidthStyle), the same as a template place and
436
+ // computeAppWidth. This frame once had its own copy without the `flex` word, and the
437
+ // task surface rendered in a zero-width frame beside a full-width app the moment the
438
+ // org's flex token went (SAB's comparison, blank, 2026-09-07).
439
+ const placeStyle = placeWidthStyle((node as { appWidth?: string }).appWidth, theme.appSize);
440
+ const frame = placeStyle ? ({ ...node.frame, style: { ...(node.frame.style ?? {}), ...placeStyle } } as UnoverseNode) : node.frame;
441
+ return renderNode(frame, store.getAppState() as Record<string, unknown>, dispatch, theme, key ?? "frame", innerSlot, def?.defs);
442
+ }
443
+ return <FlipList key={key ?? "slot"} items={pointers.map((p) => ({ key: store.split(p).nodeId, node: leaf(p, placed(p)) }))} />;
444
+ };
445
+
437
446
  const makeResolveSlot = (scope?: Set<string>): SlotResolver => {
438
447
  const resolveSlot: SlotResolver = (node: UnoverseNode, key?: React.Key): ReactNode => {
448
+ // A PLACE draws what the screen document put in it (docs/unoverse/design/screen/UNOVERSE_SCREEN.md).
449
+ if (node.type === "Place") return drawPointers(node, [...store.placeItems(String((node as { name?: string }).name ?? ""))], key, node.holds === "one");
439
450
  // A TEMPLATE PLACE (the container noun folded into templates, 2026-08-28): the
440
451
  // layout places it by NAME and owns its width; the manifest's `templates:` block
441
452
  // owns what it holds. An entry with `use:` MOUNTS a template definition (its own
@@ -521,24 +532,17 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
521
532
  // `flex` is the served "all remaining space" name. Without it the frame's
522
533
  // own styles decide (and a width-less frame beside a fixed-width chat
523
534
  // collapsed to its padding — observed live 2026-08-28).
524
- const declaredW = (node as { appWidth?: string }).appWidth;
525
- const placeW = cssWidth(typeof declaredW === "string" ? (theme.appSize?.[declaredW] ?? declaredW) : undefined);
526
- // THEME-FREE FALLBACK (same law as computeAppWidth's marker): a theme without
527
- // an `appSize.flex` token must still mean "fill the remaining host space" —
528
- // an unresolved name collapsed the place to its padding (observed live
529
- // 2026-08-28, the light theme's missing `flex`).
530
- const flexFallback = !placeW && declaredW === "flex";
535
+ // ONE WIDTH LAW (core/apps placeWidthStyle): a named size or raw CSS sizes the
536
+ // place; `flex` with no token is the SDK's own word, fill the remaining host space
537
+ // (an unresolved name once collapsed the place to its padding, 2026-08-28).
538
+ const placeStyle = placeWidthStyle((node as { appWidth?: string }).appWidth, theme.appSize);
531
539
  const rawFrame = (node as { frame?: UnoverseNode }).frame;
532
540
  if (rawFrame) {
533
- const frame = placeW
534
- ? ({ ...rawFrame, style: { ...(rawFrame.style ?? {}), width: placeW, flex: "0 0 auto" } } as UnoverseNode)
535
- : flexFallback
536
- ? ({ ...rawFrame, style: { ...(rawFrame.style ?? {}), flex: "1 1 0%", minWidth: 0 } } as UnoverseNode)
537
- : rawFrame;
541
+ const frame = placeStyle ? ({ ...rawFrame, style: { ...(rawFrame.style ?? {}), ...placeStyle } } as UnoverseNode) : rawFrame;
538
542
  const innerSlot: SlotResolver = () => tree;
539
543
  return renderNode(frame, store.getAppState() as Record<string, unknown>, dispatch, theme, `${String(key ?? cname)}-frame`, innerSlot, def?.defs);
540
544
  }
541
- return <div style={{ display: "flex", flexDirection: "column", minHeight: 0, ...(placeW ? { width: placeW, flex: "0 0 auto" } : flexFallback ? { flex: "1 1 0%", minWidth: 0 } : {}) }}>{tree}</div>;
545
+ return <div style={{ display: "flex", flexDirection: "column", minHeight: 0, ...((placeStyle ?? {}) as React.CSSProperties) }}>{tree}</div>;
542
546
  }}
543
547
  />
544
548
  );
@@ -589,39 +593,7 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
589
593
  if (isViewField(claim?.field) && typeof claim?.eq === "string" && declaredClaim && claim.eq !== surfacedView) {
590
594
  return node.fallback ? renderNode(node.fallback, {}, dispatch, theme, key ?? "fallback", undefined, def?.defs) : null;
591
595
  }
592
- const pointers = selectPointers(store, node, scope);
593
- if (pointers.length === 0) {
594
- return node.fallback ? renderNode(node.fallback, {}, dispatch, theme, key ?? "fallback", undefined, def?.defs) : null;
595
- }
596
- // `frame` — chrome that exists ONLY because the slot matched (fallback's twin).
597
- // The frame subtree renders with a slot resolver that serves the outer slot's
598
- // matches to any bare nested ComponentSlot — so a focus overlay is BORN when a
599
- // component enters the state and DIES when it leaves (§5b: derived, not stored).
600
- if (node.frame) {
601
- // A SURFACE'S SINGLE OCCUPANT FILLS THE SURFACE (docs/design/05): a limit-1
602
- // surface (detail panel, focus overlay) gives its occupant the frame's full
603
- // height — the component's `height: "full"` finally has a definite container,
604
- // with zero per-face styling. Multi-occupant surfaces (a rail) stay content-sized.
605
- const fill = node.select?.limit === 1;
606
- // Multi-occupant surfaces (a rail) animate reorders; a single occupant fills its
607
- // frame directly — a measuring wrapper there would break the height chain.
608
- const innerSlot: SlotResolver = () =>
609
- fill
610
- ? pointers.map((p) => leaf(p, undefined, fill))
611
- : <FlipList items={pointers.map((p) => ({ key: store.split(p).nodeId, node: leaf(p) }))} />;
612
- // A panel declares its width ONCE (docs/design/05): `appWidth` grows the app
613
- // (computeAppWidth) AND sizes the frame box here — the two can never disagree.
614
- // Named sizes resolve via theme.appSize like any token; unresolvable → no width.
615
- // `flex: 0 0 auto` — a panel is its declared width ALWAYS. A container that
616
- // can't fit the sum clips at the edge; panels never compress into garbage.
617
- const declared = (node as { appWidth?: string }).appWidth;
618
- const appWidth = cssWidth(typeof declared === "string" ? (theme.appSize?.[declared] ?? declared) : undefined);
619
- const frame = appWidth
620
- ? ({ ...node.frame, style: { ...(node.frame.style ?? {}), width: appWidth, flex: "0 0 auto" } } as UnoverseNode)
621
- : node.frame;
622
- return renderNode(frame, store.getAppState() as Record<string, unknown>, dispatch, theme, key ?? "frame", innerSlot, def?.defs);
623
- }
624
- return <FlipList key={key ?? "slot"} items={pointers.map((p) => ({ key: store.split(p).nodeId, node: leaf(p) }))} />;
596
+ return drawPointers(node, selectPointers(store, node, scope), key, node.select?.limit === 1);
625
597
  };
626
598
  return resolveSlot;
627
599
  };
@@ -632,6 +604,8 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
632
604
  // in those sub-trees (design/templates/chatlayout/*.json). The assistant sub-tree's ComponentSlot
633
605
  // is scoped to THAT turn's components.
634
606
  const resolveTimeline = (node: UnoverseNode, key?: React.Key): ReactNode => {
607
+ // An app with places shows in the conversation exactly what the screen put there.
608
+ const inChat = def.places?.length ? new Set(store.placeItems("chat")) : null;
635
609
  return (
636
610
  <div key={key} style={styleToCss(node.style, theme)}>
637
611
  {store.getTimeline().map((turn) => {
@@ -645,6 +619,7 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
645
619
  const shown =
646
620
  turn.role === "assistant"
647
621
  ? turn.components.filter((p) => {
622
+ if (inChat) return inChat.has(p);
648
623
  const { chatId, nodeId } = store.split(p);
649
624
  const view = viewOf(store.get(chatId, nodeId) as Record<string, unknown> | undefined);
650
625
  return !view || !surfacedViews.has(view);
@@ -658,7 +633,7 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
658
633
  // Pass the turn's streaming state into each component's scope so a streamed
659
634
  // component (e.g. StreamingText) can stop its own loading dots when complete.
660
635
  const streaming = turn.role === "assistant" && turn.streamingState === "streaming";
661
- return ptrs.map((p) => leaf(p, { streaming }));
636
+ return ptrs.map((p) => leaf(p, { streaming, ...placed(p) }));
662
637
  };
663
638
  // Data scope per turn = author's static data (avatarUrl, thinkingText…)
664
639
  // + NEUTRAL projections of the timeline model. The SDK exposes facts, never