@hosty-sdk/app 0.26.0 → 0.27.22

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/dist/ui-host.js CHANGED
@@ -1,6 +1,17 @@
1
+ import { UiCompositionModel, validChromeOverrides, withChromeHints } from "./ui-composition.js";
2
+ import { DEFAULT_WORKSPACE, ROOT_VIEW, workspace } from "./ui-workspaces.js";
3
+ import { UI_COMPOSITION_CAPABILITY } from "./ui-types.js";
1
4
  import { createPresentationRequestId } from "./ui.js";
2
5
  import { UiError, panelKey, routeWithin } from "./ui-types.js";
3
6
  import { canonicalSurfaceRoute, DISCOVERY_TIMEOUT, failure, readUiMessage, uiPath, uiRequest, validId, validTarget, waitForPresentation } from "./ui-transport.js";
7
+ function immutable(value) {
8
+ if (value && typeof value === "object" && !Object.isFrozen(value)) {
9
+ for (const child of Object.values(value))
10
+ immutable(child);
11
+ Object.freeze(value);
12
+ }
13
+ return value;
14
+ }
4
15
  let documentHost = null;
5
16
  /** One top-level composition owner. All Core traffic goes through this app's authenticated adapter. */
6
17
  export function createUiHost(options = {}) {
@@ -8,7 +19,7 @@ export function createUiHost(options = {}) {
8
19
  throw new Error("A panel host belongs to a top-level browser document.");
9
20
  if (documentHost)
10
21
  throw new Error("A Hosty panel host already owns this document.");
11
- const host = new UiHost(uiPath(options.uiPath), options.active);
22
+ const host = new UiHost(uiPath(options.uiPath), options.active, options.chrome);
12
23
  documentHost = host;
13
24
  return host;
14
25
  }
@@ -21,19 +32,35 @@ export class UiHost {
21
32
  listeners = new Set();
22
33
  events = new Set();
23
34
  frames = new Set();
35
+ viewChrome = new Map();
24
36
  ledger = new Map();
25
37
  toolQueues = new Map();
26
- selectionGeneration = 0;
38
+ moves = new Map();
39
+ rendererWork = new Map();
40
+ composition;
41
+ workspaces = new Map([[DEFAULT_WORKSPACE, workspace(DEFAULT_WORKSPACE)]]);
42
+ placement;
43
+ actions = new Map();
44
+ focusSequence = 0;
27
45
  activityGeneration = 0;
28
46
  activityReady;
29
47
  disposed = false;
30
- workspace = null;
31
48
  refreshing = null;
32
49
  onMessage = (event) => { void this.receive(event); };
33
50
  onFocus = () => { void this.refresh(); };
34
- constructor(path, active = true) {
51
+ constructor(path, active = true, chrome = {}) {
35
52
  this.path = path;
36
53
  this.active = active;
54
+ this.composition = new UiCompositionModel(path, "standalone", chrome);
55
+ this.composition.attachRouter();
56
+ this.composition.subscribeChrome(() => {
57
+ if (!this.getChrome().tools.enabled)
58
+ for (const state of this.workspaces.values())
59
+ this.collapseWorkspace(state.workspaceId);
60
+ for (const frame of this.frames)
61
+ if (frame.connected && frame.composition)
62
+ this.publishPolicy(frame);
63
+ });
37
64
  this.activityReady = active;
38
65
  window.addEventListener("message", this.onMessage);
39
66
  window.addEventListener("focus", this.onFocus);
@@ -43,13 +70,30 @@ export class UiHost {
43
70
  this.setEnvironment("blocked", "activity_required");
44
71
  }
45
72
  getEnvironment = () => this.environment;
73
+ getChrome = () => this.composition.getChrome();
74
+ subscribeChrome = (listener) => this.composition.subscribeChrome(listener);
75
+ getNavigation = () => this.composition.getNavigation();
76
+ subscribeNavigation = (listener) => this.composition.subscribeNavigation(listener);
46
77
  setActive(active) {
47
78
  if (this.disposed || this.active === active)
48
79
  return;
80
+ for (const move of this.moves.values())
81
+ move.abort();
49
82
  this.active = active;
50
83
  this.activityReady = false;
51
84
  this.activityGeneration++;
52
- this.selectionGeneration++;
85
+ for (const state of this.workspaces.values())
86
+ state.generation++;
87
+ this.invalidateRendererWork();
88
+ if (!active) {
89
+ for (const state of this.workspaces.values()) {
90
+ state.expanded = false;
91
+ state.renderer?.collapse();
92
+ }
93
+ this.publishWorkspaces();
94
+ }
95
+ this.composition.resetNavigation();
96
+ this.actions.clear();
53
97
  this.refreshing = null;
54
98
  this.toolQueues.clear();
55
99
  for (const [key, entry] of this.ledger)
@@ -71,11 +115,205 @@ export class UiHost {
71
115
  throw new UiError("activity_required", "Renew this app's activity before opening panels.");
72
116
  }
73
117
  claimWorkspace = (owner) => {
74
- if (this.workspace && this.workspace !== owner)
75
- throw new Error("Exactly one panel workspace may occupy this host.");
76
- this.workspace = owner;
77
- return () => { if (this.workspace === owner)
78
- this.workspace = null; };
118
+ const state = this.workspaces.get(DEFAULT_WORKSPACE);
119
+ if (state.renderer || state.owner && state.owner !== owner)
120
+ throw new Error("Exactly one renderer may occupy a workspace.");
121
+ state.owner = owner;
122
+ return () => { if (state.owner === owner)
123
+ state.owner = undefined; };
124
+ };
125
+ registerWorkspace = ({ workspaceId, renderer }) => {
126
+ if (!workspaceId || workspaceId.length > 256)
127
+ throw new Error("Invalid workspace ID.");
128
+ const state = this.workspaces.get(workspaceId) ?? workspace(workspaceId);
129
+ if (state.renderer && state.renderer !== renderer)
130
+ throw new Error("A workspace already has a renderer.");
131
+ state.renderer = renderer;
132
+ this.workspaces.set(workspaceId, state);
133
+ this.publishWorkspaces();
134
+ return () => {
135
+ if (!this.disposed && this.snapshot.tools.some(t => t.workspaceId === workspaceId))
136
+ throw new Error("Close or move presentations before disposing their workspace.");
137
+ if (state.renderer === renderer) {
138
+ state.generation++;
139
+ this.invalidateRendererWork();
140
+ state.renderer = undefined;
141
+ this.publishWorkspaces();
142
+ }
143
+ };
144
+ };
145
+ publishWorkspaces() {
146
+ const state = this.workspaces.get(DEFAULT_WORKSPACE);
147
+ const selected = this.snapshot.tools.find(t => t.presentationId === state.selectedId);
148
+ this.update({ selected: selected?.target.panel ?? null, selectedPresentationId: state.selectedId,
149
+ expanded: state.expanded, width: state.width,
150
+ workspaces: [...this.workspaces.values()].map(({ workspaceId, selectedId, expanded, width }) => ({ workspaceId, selectedId, expanded, width })) });
151
+ }
152
+ setPlacementResolver = (resolver) => { this.placement = resolver; };
153
+ getViewChrome = (viewId) => {
154
+ const chrome = this.viewChrome.get(viewId) ?? [...this.frames].find(frame => frame.view.viewId === viewId)?.chrome;
155
+ return chrome ? structuredClone(chrome) : undefined;
156
+ };
157
+ setViewChrome = (viewId, overrides) => {
158
+ if (!validChromeOverrides(overrides))
159
+ throw new Error("Invalid chrome policy.");
160
+ if (viewId === ROOT_VIEW) {
161
+ this.composition.setHostPolicy("root", this.getChrome().hostRevision + 1, overrides, false);
162
+ return;
163
+ }
164
+ const frame = [...this.frames].find(f => f.view.viewId === viewId);
165
+ if (!frame)
166
+ throw new Error("Unknown view.");
167
+ frame.chrome = structuredClone(overrides);
168
+ this.viewChrome.set(viewId, frame.chrome);
169
+ this.publishPolicy(frame);
170
+ };
171
+ publishPolicy(frame) {
172
+ if (!frame.composition || !frame.connected)
173
+ return;
174
+ this.post(frame, { type: "hosty:ui:chrome", revision: ++frame.policyRevision, chrome: frame.chrome,
175
+ rootDisabled: !this.getChrome().tools.enabled, endpoint: frame.surface.endpoint });
176
+ }
177
+ requireTools(frame) {
178
+ if (!this.getChrome().tools.enabled || frame?.chrome.tools?.enabled === false)
179
+ throw new UiError("tools_disabled", "Tools are disabled in this view.");
180
+ }
181
+ focusPresentation = (id) => {
182
+ const tool = this.snapshot.tools.find(t => t.presentationId === id);
183
+ if (!tool)
184
+ return false;
185
+ this.requireTools();
186
+ const state = this.workspaces.get(tool.workspaceId);
187
+ state.generation++;
188
+ state.selectedId = id;
189
+ state.expanded = true;
190
+ this.update({ tools: this.snapshot.tools.map(t => t === tool ? { ...t, focusedAt: ++this.focusSequence } : t) });
191
+ this.publishWorkspaces();
192
+ state.renderer?.focus(id);
193
+ return true;
194
+ };
195
+ collapseWorkspace = (id) => {
196
+ const state = this.workspaces.get(id);
197
+ if (!state)
198
+ return;
199
+ state.generation++;
200
+ state.expanded = false;
201
+ state.renderer?.collapse();
202
+ this.publishWorkspaces();
203
+ };
204
+ closePresentation = (id) => { this.releasePresentation(id); };
205
+ releasePresentation(id, supersede = true) {
206
+ const tool = this.snapshot.tools.find(t => t.presentationId === id);
207
+ if (!tool)
208
+ return;
209
+ this.moves.get(id)?.abort();
210
+ const state = this.workspaces.get(tool.workspaceId);
211
+ for (const frame of this.frames)
212
+ if (frame.view.presentationId === id)
213
+ frame.cleanup();
214
+ if (supersede)
215
+ state.generation++;
216
+ state.renderer?.release(id);
217
+ this.update({ tools: this.snapshot.tools.filter(t => t !== tool) });
218
+ if (state.selectedId === id) {
219
+ state.selectedId = null;
220
+ state.expanded = false;
221
+ }
222
+ this.publishWorkspaces();
223
+ }
224
+ resetPresentations() {
225
+ for (const tool of this.snapshot.tools)
226
+ this.closePresentation(tool.presentationId);
227
+ for (const state of this.workspaces.values()) {
228
+ state.selectedId = null;
229
+ state.expanded = false;
230
+ state.generation++;
231
+ state.renderer?.collapse();
232
+ }
233
+ this.publishWorkspaces();
234
+ }
235
+ movePresentation = async (id, workspaceId) => {
236
+ this.requireTools();
237
+ const tool = this.snapshot.tools.find(t => t.presentationId === id), next = this.workspaces.get(workspaceId);
238
+ if (!tool || !next?.renderer || this.moves.has(id))
239
+ return false;
240
+ const previous = this.workspaces.get(tool.workspaceId);
241
+ if (previous === next)
242
+ return true;
243
+ if (!previous.renderer?.frameLayer || previous.renderer.frameLayer !== next.renderer.frameLayer)
244
+ return false;
245
+ const activity = this.activityGeneration, generation = ++next.generation, renderer = next.renderer;
246
+ this.invalidateRendererWork();
247
+ const moved = { ...tool, workspaceId }, controller = new AbortController();
248
+ const retainedSource = () => this.snapshot.tools.find(t => t.presentationId === id && t.workspaceId === tool.workspaceId && t.resolved.surface);
249
+ this.moves.set(id, controller);
250
+ this.rendererWork.set(controller, { check: () => {
251
+ if (!retainedSource() || generation !== next.generation || renderer !== next.renderer)
252
+ throw new Error("Movement was superseded.");
253
+ } });
254
+ const stop = () => { this.rendererWork.delete(controller); };
255
+ try {
256
+ if (await renderer.present(moved, controller.signal) !== "accepted")
257
+ throw new Error("Movement was refused.");
258
+ this.checkActivity(activity);
259
+ this.requireTools();
260
+ if (controller.signal.aborted || !retainedSource() || generation !== next.generation || renderer !== next.renderer)
261
+ throw new Error("Movement was superseded.");
262
+ stop();
263
+ previous.generation++;
264
+ this.update({ tools: this.snapshot.tools.map(t => t.presentationId === id ? { ...t, workspaceId } : t) });
265
+ if (previous.selectedId === id) {
266
+ previous.selectedId = null;
267
+ previous.expanded = false;
268
+ previous.renderer?.collapse();
269
+ }
270
+ for (const frame of this.frames)
271
+ if (frame.view.presentationId === id)
272
+ frame.view = { ...frame.view, workspaceId };
273
+ this.focusPresentation(id);
274
+ return true;
275
+ }
276
+ catch {
277
+ stop();
278
+ controller.abort();
279
+ const retained = this.snapshot.tools.find(t => t.presentationId === id);
280
+ if (!retained) {
281
+ renderer.release(id);
282
+ return false;
283
+ }
284
+ const state = this.workspaces.get(retained.workspaceId), restore = state.renderer;
285
+ const restorationGeneration = state.generation;
286
+ const rollback = new AbortController();
287
+ this.moves.set(id, rollback);
288
+ this.rendererWork.set(rollback, { check: () => {
289
+ if (rollback.signal.aborted || restorationGeneration !== state.generation || restore !== state.renderer
290
+ || !this.snapshot.tools.some(t => t.presentationId === id && t.workspaceId === retained.workspaceId))
291
+ throw new Error("Restoration was superseded.");
292
+ } });
293
+ try {
294
+ if (!restore || await restore.present(retained, rollback.signal) !== "accepted")
295
+ throw new Error("Restoration was refused.");
296
+ if (!this.snapshot.tools.some(t => t.presentationId === id)) {
297
+ restore.release(id);
298
+ return false;
299
+ }
300
+ if (this.active && this.getChrome().tools.enabled && state.expanded && state.selectedId)
301
+ restore.focus(state.selectedId);
302
+ else
303
+ restore.collapse();
304
+ }
305
+ catch {
306
+ this.releasePresentation(id, restorationGeneration === state.generation);
307
+ }
308
+ finally {
309
+ this.rendererWork.delete(rollback);
310
+ }
311
+ return false;
312
+ }
313
+ finally {
314
+ stop();
315
+ this.moves.delete(id);
316
+ }
79
317
  };
80
318
  getSnapshot = () => this.snapshot;
81
319
  subscribeSnapshot = (listener) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; };
@@ -90,12 +328,42 @@ export class UiHost {
90
328
  update(patch) {
91
329
  if (this.disposed)
92
330
  return;
93
- this.snapshot = { ...this.snapshot, ...patch };
331
+ this.snapshot = immutable({ ...this.snapshot, ...patch });
332
+ this.invalidateRendererWork();
94
333
  for (const listener of this.listeners)
95
334
  listener();
96
335
  }
336
+ invalidateRendererWork() {
337
+ for (const [controller, work] of this.rendererWork)
338
+ if (!controller.signal.aborted) {
339
+ try {
340
+ work.check();
341
+ }
342
+ catch {
343
+ controller.abort();
344
+ }
345
+ }
346
+ }
347
+ async runRenderer(check, perform, frame) {
348
+ check();
349
+ const controller = new AbortController();
350
+ this.rendererWork.set(controller, { check, frame });
351
+ try {
352
+ const result = await perform(controller.signal);
353
+ check();
354
+ return result;
355
+ }
356
+ catch (error) {
357
+ check();
358
+ throw error;
359
+ }
360
+ finally {
361
+ this.rendererWork.delete(controller);
362
+ }
363
+ }
97
364
  setEnvironment(connection, code) {
98
365
  this.environment = { mode: "standalone", connection, ...(code ? { code } : {}) };
366
+ this.composition.setEnvironment(this.environment);
99
367
  this.emit({ type: "environment", value: this.environment });
100
368
  }
101
369
  async bootstrap(generation = this.activityGeneration, renewing = false) {
@@ -107,7 +375,10 @@ export class UiHost {
107
375
  const previous = this.snapshot.bootstrap;
108
376
  if (previous && (previous.userId !== value.userId || previous.coreOrigin !== value.coreOrigin || previous.app.installationId !== value.app.installationId)) {
109
377
  this.ledger.clear();
378
+ this.actions.clear();
110
379
  this.toolQueues.clear();
380
+ this.composition.resetNavigation();
381
+ this.resetPresentations();
111
382
  for (const frame of this.frames) {
112
383
  frame.connected = false;
113
384
  frame.actor = null;
@@ -131,7 +402,10 @@ export class UiHost {
131
402
  try {
132
403
  const own = await this.bootstrap(generation, true);
133
404
  this.checkActivity(generation, true);
405
+ void this.composition.refreshNavigation();
134
406
  if (!own.canHost) {
407
+ this.resetPresentations();
408
+ this.activityReady = true;
135
409
  this.setEnvironment("unsupported", "app_permission_required");
136
410
  this.update({ catalog: [], tools: [], selected: null, expanded: false });
137
411
  return;
@@ -146,9 +420,9 @@ export class UiHost {
146
420
  // Catalog disappearance/replacement is access loss, while temporary readiness loss retains drafts.
147
421
  const allowed = new Map(catalog.panels.map(p => [panelKey(p.panel), p]));
148
422
  this.update({ tools: this.snapshot.tools.map(tool => {
149
- const entry = allowed.get(tool.key);
423
+ const entry = allowed.get(panelKey(tool.target.panel));
150
424
  const invalid = !entry || !["running", "degraded", "unavailable", "starting", "stopping"].includes(entry.state);
151
- const surface = tool.resolved.surface;
425
+ const surface = tool.resolved.surface && tool.panelSurface;
152
426
  const changed = entry && surface && JSON.stringify(entry.framePolicy) !== JSON.stringify(surface.framePolicy);
153
427
  const moved = entry && surface && (entry.origin !== surface.origin || entry.endpoint !== surface.endpoint
154
428
  || !routeWithin(surface.route, entry.route));
@@ -172,51 +446,119 @@ export class UiHost {
172
446
  return operation;
173
447
  };
174
448
  openSurface = (target, options) => waitForPresentation(this.open(target, options, "local"), options.requestId, options.signal);
175
- open(target, options, source, frame) {
449
+ open(target, options, source, frame, sourceWorkspace) {
176
450
  const id = options.requestId;
177
451
  if (!this.active || !this.activityReady || this.disposed)
178
452
  return Promise.resolve({ status: "blocked", requestId: id, code: "activity_required" });
179
- const generation = this.activityGeneration;
180
- if (!validId(id) || !validTarget(target) || options.placement !== "side")
453
+ if (!validId(id) || !validTarget(target) || options.placement !== "side" || options.reuse !== undefined && !["exact", "new"].includes(options.reuse))
181
454
  return Promise.resolve({ status: "rejected", requestId: id, code: "invalid_presentation" });
182
- const key = `${source}:${id}`, input = JSON.stringify([target.panel.appId, target.panel.installationId, target.panel.panelId, target.route ?? null]);
455
+ target = { panel: { appId: target.panel.appId, installationId: target.panel.installationId, panelId: target.panel.panelId },
456
+ ...(target.route === undefined ? {} : { route: canonicalSurfaceRoute(target.route) }) };
457
+ const generation = this.activityGeneration, key = `${source}:${id}`;
458
+ const input = JSON.stringify(["open", target.panel, target.route ?? null, options.reuse ?? "exact"]);
183
459
  const existing = this.ledger.get(key);
184
- if (existing && existing.input !== input)
460
+ if (this.actions.has(key) || existing && existing.input !== input)
185
461
  return Promise.resolve({ status: "rejected", requestId: id, code: "request_conflict" });
186
462
  if (existing?.pending)
187
463
  return existing.pending;
188
- if ([...this.ledger.entries()].filter(([k, v]) => k.startsWith(source + ":") && v.pending).length >= 4)
464
+ if (existing?.result?.status === "opened")
465
+ return this.status(id, source, frame).then(result => {
466
+ if (result.status === "opened" && result.presentationId && this.snapshot.tools.some(t => t.presentationId === result.presentationId)) {
467
+ try {
468
+ this.requireTools(frame);
469
+ this.focusPresentation(result.presentationId);
470
+ }
471
+ catch (error) {
472
+ return failure(id, error);
473
+ }
474
+ }
475
+ return result;
476
+ });
477
+ if (this.pendingCount(source) >= 4)
189
478
  return Promise.resolve({ status: "blocked", requestId: id, code: "presentation_busy" });
479
+ let destination;
480
+ try {
481
+ this.requireTools(frame);
482
+ destination = this.placement?.({ source: frame?.view ?? { viewId: ROOT_VIEW, workspaceId: sourceWorkspace ?? DEFAULT_WORKSPACE },
483
+ target: structuredClone(target), presentations: structuredClone(this.snapshot.tools) }) ?? { workspaceId: frame?.view.workspaceId ?? sourceWorkspace ?? DEFAULT_WORKSPACE };
484
+ if (!this.workspaces.get(destination.workspaceId)?.renderer)
485
+ throw new UiError("presentation_unavailable", "No renderer is available for this workspace.");
486
+ if (options.reuse === "new" && destination.presentationId)
487
+ throw new UiError("invalid_presentation", "A new presentation cannot reuse an instance.");
488
+ }
489
+ catch (error) {
490
+ return Promise.resolve(failure(id, error));
491
+ }
492
+ const state = this.workspaces.get(destination.workspaceId), selection = ++state.generation;
493
+ this.invalidateRendererWork();
190
494
  const entry = { input, frame };
191
- const selectionGeneration = ++this.selectionGeneration;
192
- const currentSource = () => !frame || source === `${frame.surface.appId}:${frame.generation}:${frame.nonce}`;
193
- const toolKey = panelKey(target.panel), previous = this.toolQueues.get(toolKey);
495
+ const currentSource = () => !frame || frame.live && frame.connected && source === `${frame.surface.appId}:${frame.generation}:${frame.nonce}`;
496
+ const queueKey = `${destination.workspaceId}:${panelKey(target.panel)}`, previous = this.toolQueues.get(queueKey);
194
497
  const operation = (async () => {
195
498
  if (previous)
196
499
  await previous;
197
500
  try {
198
501
  const own = await this.bootstrap(generation);
199
- this.checkActivity(generation);
200
- if (!own.canHost)
201
- throw new UiError("app_permission_required", "Allow this app to host panels.");
202
- if (frame && (!frame.live || !frame.connected || frame.actor !== own.userId || !currentSource()))
203
- throw new UiError("actor_mismatch", "Reconnect using the same account.");
502
+ const check = () => {
503
+ this.checkActivity(generation);
504
+ this.requireTools(frame);
505
+ if (!own.canHost)
506
+ throw new UiError("app_permission_required", "Allow this app to host panels.");
507
+ if (!currentSource() || frame && frame.actor !== own.userId || this.snapshot.bootstrap?.userId !== own.userId
508
+ || this.snapshot.bootstrap?.coreOrigin !== own.coreOrigin || this.snapshot.bootstrap?.app.installationId !== own.app.installationId)
509
+ throw new UiError("actor_mismatch", "Reconnect using the same account.");
510
+ if (selection !== state.generation)
511
+ throw new UiError("presentation_superseded", "A newer action replaced this opening.");
512
+ };
513
+ check();
204
514
  const resolved = await uiRequest(this.path, "panels/resolve", target);
205
- this.checkActivity(generation);
206
- if (this.disposed || this.snapshot.bootstrap?.userId !== own.userId || this.snapshot.bootstrap?.coreOrigin !== own.coreOrigin || this.snapshot.bootstrap?.app.installationId !== own.app.installationId
207
- || frame && (!frame.live || !frame.connected || frame.actor !== own.userId || !currentSource()))
208
- throw new UiError("actor_mismatch", "The signed-in account changed.");
515
+ check();
209
516
  if (!resolved.available || !resolved.surface)
210
517
  throw new UiError(resolved.state || "destination_unavailable", "The selected panel is unavailable.");
518
+ const matches = (tool) => !this.moves.has(tool.presentationId) && tool.workspaceId === state.workspaceId && panelKey(tool.target.panel) === panelKey(resolved.target.panel)
519
+ && tool.resolved.surface?.origin === resolved.surface.origin && tool.resolved.surface?.endpoint === resolved.surface.endpoint
520
+ && canonicalSurfaceRoute(tool.currentRoute) === canonicalSurfaceRoute(resolved.target.route)
521
+ && JSON.stringify(tool.resolved.surface?.framePolicy) === JSON.stringify(resolved.surface.framePolicy);
522
+ const preferred = destination.presentationId ? this.snapshot.tools.find(t => t.presentationId === destination.presentationId) : undefined;
523
+ if (destination.presentationId && (!preferred || !matches(preferred)))
524
+ throw new UiError("destination_changed", "The selected instance does not match this target.");
525
+ const old = options.reuse === "new" ? undefined : preferred ?? this.snapshot.tools.filter(matches).sort((a, b) => b.focusedAt - a.focusedAt)[0];
211
526
  const insecure = window.location.protocol === "https:" && new URL(resolved.surface.origin).protocol === "http:";
212
- if (selectionGeneration !== this.selectionGeneration)
213
- throw new UiError("presentation_superseded", "A newer panel action replaced this opening. Retry to open it again.");
214
- const old = this.snapshot.tools.find(t => t.key === toolKey);
215
- const same = old?.resolved.surface && JSON.stringify(old.resolved.surface) === JSON.stringify(resolved.surface);
216
- const tool = { key: toolKey, target: resolved.target, resolved,
217
- readiness: insecure ? "unreported" : same ? old.readiness : "loading", attention: old?.attention ?? 0 };
218
- this.update({ tools: old ? this.snapshot.tools.map(t => t.key === toolKey ? tool : t) : [...this.snapshot.tools, tool], selected: target.panel, expanded: true });
219
- return { status: "opened", requestId: id, target: resolved.target, readiness: tool.readiness };
527
+ const tool = old ?? { key: panelKey(target.panel), presentationId: createPresentationRequestId(), workspaceId: state.workspaceId,
528
+ target: resolved.target, panelSurface: resolved.surface, resolved, readiness: insecure ? "unreported" : "loading", attention: 0, currentRoute: resolved.target.route ?? null, focusedAt: 0 };
529
+ if (!old)
530
+ this.update({ tools: [...this.snapshot.tools, tool] });
531
+ const renderer = state.renderer;
532
+ try {
533
+ const checkRenderer = () => {
534
+ check();
535
+ if (state.renderer !== renderer)
536
+ throw new UiError("presentation_superseded", "The workspace renderer changed.");
537
+ const current = this.snapshot.tools.find(t => t.presentationId === tool.presentationId);
538
+ if (!current?.resolved.surface || JSON.stringify(current.resolved.surface) !== JSON.stringify(tool.resolved.surface))
539
+ throw new UiError("destination_changed", "The presentation destination changed.");
540
+ if (!current.resolved.available)
541
+ throw new UiError(current.resolved.state, "The presentation is unavailable.");
542
+ };
543
+ if (await this.runRenderer(checkRenderer, signal => renderer.present(tool, signal), frame) !== "accepted")
544
+ throw new UiError("presentation_unavailable", "The workspace could not present this tool.");
545
+ check();
546
+ }
547
+ catch (error) {
548
+ if (!old) {
549
+ renderer.release(tool.presentationId);
550
+ this.update({ tools: this.snapshot.tools.filter(t => t.presentationId !== tool.presentationId) });
551
+ }
552
+ throw error;
553
+ }
554
+ state.selectedId = tool.presentationId;
555
+ state.expanded = true;
556
+ this.update({ tools: this.snapshot.tools.map(t => t.presentationId === tool.presentationId ? { ...t, focusedAt: ++this.focusSequence } : t) });
557
+ this.publishWorkspaces();
558
+ state.renderer?.focus(tool.presentationId);
559
+ entry.openingSurface = resolved.surface;
560
+ const readiness = this.snapshot.tools.find(t => t.presentationId === tool.presentationId)?.readiness ?? tool.readiness;
561
+ return { status: "opened", requestId: id, target: resolved.target, readiness, presentationId: tool.presentationId };
220
562
  }
221
563
  catch (error) {
222
564
  return failure(id, error);
@@ -224,21 +566,138 @@ export class UiHost {
224
566
  })();
225
567
  entry.pending = operation;
226
568
  this.ledger.set(key, entry);
227
- this.toolQueues.set(toolKey, operation);
569
+ this.toolQueues.set(queueKey, operation);
228
570
  void operation.then(result => {
229
571
  entry.pending = undefined;
230
572
  entry.result = result;
231
- if (this.toolQueues.get(toolKey) === operation)
232
- this.toolQueues.delete(toolKey);
573
+ if (this.toolQueues.get(queueKey) === operation)
574
+ this.toolQueues.delete(queueKey);
233
575
  if (!frame && this.active && generation === this.activityGeneration)
234
576
  this.emit({ type: "presentation", value: result });
235
- const keys = [...this.ledger.keys()].filter(k => k.startsWith(source + ":"));
236
- for (const oldKey of keys.slice(0, Math.max(0, keys.length - 256)))
237
- if (!this.ledger.get(oldKey)?.pending)
238
- this.ledger.delete(oldKey);
577
+ this.trimLedger(source);
239
578
  });
240
579
  return operation;
241
580
  }
581
+ pendingCount(source) {
582
+ return [...this.ledger, ...this.actions].filter(([key, value]) => key.startsWith(source + ":") && value.pending).length;
583
+ }
584
+ trimLedger(source) {
585
+ const entries = [...this.ledger, ...this.actions].filter(([key]) => key.startsWith(source + ":"));
586
+ for (const [key, value] of entries.slice(0, Math.max(0, entries.length - 256)))
587
+ if (!value.pending) {
588
+ this.ledger.delete(key);
589
+ this.actions.delete(key);
590
+ }
591
+ }
592
+ peerResult(frame, result) {
593
+ if (frame.composition || result.status !== "opened")
594
+ return result;
595
+ const { presentationId: _id, ...legacy } = result;
596
+ return legacy;
597
+ }
598
+ action(kind, input, options, perform, frame) {
599
+ const id = options.requestId, source = frame ? `${frame.surface.appId}:${frame.generation}:${frame.nonce}` : "local", key = `${source}:${id}`;
600
+ if (!validId(id))
601
+ return Promise.resolve({ status: "rejected", requestId: id, code: "invalid_presentation" });
602
+ const payload = JSON.stringify([kind, input]), existing = this.actions.get(key);
603
+ if (this.ledger.has(key) || existing && existing.input !== payload)
604
+ return Promise.resolve({ status: "rejected", requestId: id, code: "request_conflict" });
605
+ if (existing)
606
+ return waitForPresentation(existing.pending ?? Promise.resolve(existing.result), id, options.signal);
607
+ if (this.pendingCount(source) >= 4)
608
+ return Promise.resolve({ status: "blocked", requestId: id, code: "presentation_busy" });
609
+ const generation = this.activityGeneration;
610
+ const check = () => {
611
+ this.checkActivity(generation);
612
+ if (frame && (!frame.live || !frame.connected || frame.actor !== this.snapshot.bootstrap?.userId || source !== `${frame.surface.appId}:${frame.generation}:${frame.nonce}`))
613
+ throw new UiError("host_unavailable", "The originating view is no longer connected.");
614
+ };
615
+ const entry = { input: payload, frame };
616
+ const operation = Promise.resolve().then(async () => {
617
+ try {
618
+ check();
619
+ await perform(check);
620
+ return { status: "accepted", requestId: id };
621
+ }
622
+ catch (error) {
623
+ return failure(id, error);
624
+ }
625
+ });
626
+ entry.pending = operation;
627
+ this.actions.set(key, entry);
628
+ void operation.then(result => { entry.pending = undefined; entry.result = result; this.trimLedger(source); });
629
+ return waitForPresentation(operation, id, options.signal);
630
+ }
631
+ navigate = (entryKey, options) => this.action("navigate", entryKey, options, async (check) => {
632
+ const catalog = this.getNavigation().catalog, entry = catalog?.entries.find(e => e.key === entryKey);
633
+ if (!entry || !entry.route)
634
+ throw new UiError("destination_changed", "Refresh this app's navigation.");
635
+ if (await this.composition.navigateLocal(entry)) {
636
+ check();
637
+ return;
638
+ }
639
+ const resolved = await uiRequest(this.path, "navigation/resolve", { appId: catalog.appId, installationId: catalog.installationId, entryKey });
640
+ check();
641
+ if (!resolved.available || !resolved.surface)
642
+ throw new UiError(resolved.state, "This page is unavailable.");
643
+ window.location.assign(withChromeHints(new URL(resolved.surface.route, resolved.surface.origin).href, this.getChrome()));
644
+ });
645
+ openToolPicker = (options) => this.pickTools(options);
646
+ pickTools(options, frame) {
647
+ return this.action("picker", null, options, async (check) => {
648
+ this.requireTools(frame);
649
+ const state = this.workspaces.get(frame?.view.workspaceId ?? DEFAULT_WORKSPACE), renderer = state?.renderer;
650
+ if (!state || !renderer)
651
+ throw new UiError("presentation_unavailable", "A tool chooser is not available here.");
652
+ const selection = ++state.generation;
653
+ this.invalidateRendererWork();
654
+ const checkRenderer = () => {
655
+ check();
656
+ this.requireTools(frame);
657
+ if (selection !== state.generation || renderer !== state.renderer)
658
+ throw new UiError("presentation_superseded", "A newer action replaced this chooser.");
659
+ };
660
+ const own = await this.bootstrap();
661
+ checkRenderer();
662
+ if (!own.canHost)
663
+ throw new UiError("app_permission_required", "Allow this app to discover tools.");
664
+ if (await this.runRenderer(checkRenderer, signal => renderer.showToolPicker(signal), frame) !== "accepted")
665
+ throw new UiError("presentation_unavailable", "A tool chooser is not available here.");
666
+ checkRenderer();
667
+ }, frame);
668
+ }
669
+ navigateFrame(entryKey, options, frame) {
670
+ return this.action("navigate", entryKey, options, async (check) => {
671
+ await this.bootstrap();
672
+ check();
673
+ const target = { appId: frame.surface.appId, installationId: frame.surface.installationId, entryKey };
674
+ const resolved = await uiRequest(this.path, "navigation/resolve", target);
675
+ check();
676
+ if (!resolved.available || !resolved.surface)
677
+ throw new UiError(resolved.state, "This page is unavailable.");
678
+ if (frame.view.navigate) {
679
+ const navigation = frame.view.navigate(resolved.surface);
680
+ if (navigation)
681
+ await navigation;
682
+ check();
683
+ frame.surface = { ...resolved.surface, panelId: frame.surface.panelId };
684
+ }
685
+ else {
686
+ this.forgetFrameRequests(frame);
687
+ frame.onConnection?.(false);
688
+ frame.surface = { ...resolved.surface, panelId: frame.surface.panelId };
689
+ frame.connected = false;
690
+ frame.actor = null;
691
+ frame.nonce = null;
692
+ frame.generation = createPresentationRequestId();
693
+ frame.element.src = withChromeHints(new URL(resolved.surface.route, resolved.surface.origin).href, frame.chrome);
694
+ }
695
+ if (frame.view.presentationId)
696
+ this.update({ tools: this.snapshot.tools.map(tool => tool.presentationId === frame.view.presentationId
697
+ ? { ...tool, resolved: { ...tool.resolved, surface: resolved.surface }, navigationRoot: resolved.surface.route,
698
+ currentRoute: resolved.surface.route, readiness: "loading" } : tool) });
699
+ }, frame);
700
+ }
242
701
  getPresentation = (id, options) => waitForPresentation(this.status(id, "local"), id, options?.signal);
243
702
  async status(id, source, frame) {
244
703
  const generation = this.activityGeneration;
@@ -262,10 +721,11 @@ export class UiHost {
262
721
  check();
263
722
  if (!current.available)
264
723
  return { status: "blocked", requestId: id, code: current.state };
265
- const tool = this.snapshot.tools.find(t => t.key === panelKey(result.target.panel));
266
- if (!tool || tool.target.route !== result.target.route || JSON.stringify(tool.resolved.surface) !== JSON.stringify(current.surface))
724
+ const tool = this.snapshot.tools.find(t => t.presentationId === result.presentationId);
725
+ // Navigation changes the live document, not the destination accepted by this request.
726
+ if ((tool && !tool.resolved.surface) || JSON.stringify(entry?.openingSurface) !== JSON.stringify(current.surface))
267
727
  return { status: "blocked", requestId: id, code: "destination_changed" };
268
- return { ...result, readiness: tool.readiness };
728
+ return { ...result, readiness: tool && tool.currentRoute === result.target.route ? tool.readiness : result.readiness };
269
729
  }
270
730
  return result ?? { status: "unknown", requestId: id, code: "presentation_unknown" };
271
731
  }
@@ -273,34 +733,95 @@ export class UiHost {
273
733
  return failure(id, error);
274
734
  }
275
735
  }
276
- selectPanel = async (ref) => {
277
- const opened = this.snapshot.tools.find(t => t.key === panelKey(ref));
278
- return this.openSurface(opened?.resolved.surface ? opened.target : { panel: ref }, { placement: "side", requestId: createPresentationRequestId() });
736
+ selectPanel = async (ref, workspaceId = DEFAULT_WORKSPACE) => {
737
+ const opened = this.snapshot.tools.filter(t => t.workspaceId === workspaceId && t.key === panelKey(ref)).sort((a, b) => b.focusedAt - a.focusedAt)[0];
738
+ if (opened?.resolved.surface) {
739
+ const requestId = createPresentationRequestId();
740
+ try {
741
+ this.requireTools();
742
+ const state = this.workspaces.get(workspaceId);
743
+ const selection = ++state.generation;
744
+ this.invalidateRendererWork();
745
+ const generation = this.activityGeneration, own = await this.bootstrap();
746
+ if (!own.canHost)
747
+ throw new UiError("app_permission_required", "Allow this app to host tools.");
748
+ const resolved = await uiRequest(this.path, "panels/resolve", opened.target);
749
+ this.checkActivity(generation);
750
+ this.requireTools();
751
+ if (selection !== state.generation)
752
+ throw new UiError("presentation_superseded", "A newer action replaced this opening.");
753
+ if (!resolved.available || !resolved.surface)
754
+ throw new UiError(resolved.state, "The tool is unavailable.");
755
+ const surface = opened.panelSurface;
756
+ if (surface.appId !== resolved.surface.appId || surface.installationId !== resolved.surface.installationId
757
+ || surface.origin !== resolved.surface.origin || surface.endpoint !== resolved.surface.endpoint
758
+ || JSON.stringify(surface.framePolicy) !== JSON.stringify(resolved.surface.framePolicy)) {
759
+ this.update({ tools: this.snapshot.tools.map(t => t.presentationId === opened.presentationId
760
+ ? { ...t, resolved: { ...t.resolved, available: false, surface: null, state: "destination_changed" } } : t) });
761
+ throw new UiError("destination_changed", "The tool destination changed. Open its current version again.");
762
+ }
763
+ if (this.focusPresentation(opened.presentationId)) {
764
+ const readiness = this.snapshot.tools.find(t => t.presentationId === opened.presentationId)?.readiness ?? opened.readiness;
765
+ return { status: "opened", requestId, target: resolved.target, readiness, presentationId: opened.presentationId };
766
+ }
767
+ }
768
+ catch (error) {
769
+ return failure(requestId, error);
770
+ }
771
+ }
772
+ else if (opened)
773
+ this.closePresentation(opened.presentationId);
774
+ const requestId = createPresentationRequestId();
775
+ return waitForPresentation(this.open({ panel: ref }, { placement: "side", requestId }, "local", undefined, workspaceId), requestId);
279
776
  };
280
- setExpanded = (expanded) => {
281
- if (expanded && (!this.active || !this.activityReady))
282
- return;
777
+ setExpanded = (expanded, workspaceId = DEFAULT_WORKSPACE) => {
283
778
  if (!expanded)
284
- this.selectionGeneration++;
285
- this.update({ expanded });
779
+ return this.collapseWorkspace(workspaceId);
780
+ if (!this.active || !this.activityReady || !this.getChrome().tools.enabled)
781
+ return;
782
+ const state = this.workspaces.get(workspaceId);
783
+ if (state) {
784
+ state.expanded = true;
785
+ this.publishWorkspaces();
786
+ if (state.expanded && state.selectedId)
787
+ state.renderer?.focus(state.selectedId);
788
+ }
789
+ };
790
+ setWidth = (width, workspaceId = DEFAULT_WORKSPACE) => {
791
+ const state = this.workspaces.get(workspaceId);
792
+ if (state && Number.isFinite(width)) {
793
+ state.width = Math.min(800, Math.max(280, width));
794
+ this.publishWorkspaces();
795
+ }
286
796
  };
287
- setWidth = (width) => { if (Number.isFinite(width))
288
- this.update({ width: Math.min(800, Math.max(280, width)) }); };
289
- setAttention = (panel, attention) => this.update({ tools: this.snapshot.tools.map(t => t.key === panelKey(panel) ? { ...t, attention: Math.max(0, Math.floor(attention)) } : t) });
797
+ setAttention = (panel, attention) => this.update({ tools: this.snapshot.tools.map(t => (typeof panel === "string" ? t.presentationId === panel : t.key === panelKey(panel)) ? { ...t, attention: Math.max(0, Math.floor(attention)) } : t) });
290
798
  reportSurfaceState = (_state) => { };
291
799
  forgetFrameRequests(frame) {
800
+ frame.routeReport = undefined;
801
+ for (const [controller, work] of this.rendererWork)
802
+ if (work.frame === frame)
803
+ controller.abort();
292
804
  for (const [key, entry] of this.ledger)
293
805
  if (entry.frame === frame)
294
806
  this.ledger.delete(key);
807
+ for (const [key, entry] of this.actions)
808
+ if (entry.frame === frame)
809
+ this.actions.delete(key);
295
810
  }
296
- registerFrame = (element, surface, onConnection) => {
297
- const frame = { element, surface, onConnection, generation: createPresentationRequestId(), nonce: null, connected: false, actor: null, live: true, cleanup: () => { }, waitForReadiness: () => { } };
811
+ registerFrame = (element, surface, onConnection, view) => {
812
+ const descriptor = view ?? { viewId: createPresentationRequestId(), workspaceId: DEFAULT_WORKSPACE, chrome: { navigation: "hide", tools: { launcher: "hide" } } };
813
+ if (descriptor.viewId === ROOT_VIEW || [...this.frames].some(f => f.view.viewId === descriptor.viewId))
814
+ throw new Error("A view ID must be unique.");
815
+ if (!this.workspaces.has(descriptor.workspaceId))
816
+ throw new Error("Register the workspace before its views.");
817
+ const frame = { element, surface, view: descriptor, chrome: structuredClone(this.viewChrome.get(descriptor.viewId) ?? descriptor.chrome ?? {}), policyRevision: 0, composition: false, onConnection, generation: createPresentationRequestId(), nonce: null, connected: false, actor: null, live: true, cleanup: () => { }, waitForReadiness: () => { } };
298
818
  let timer;
299
819
  const forget = () => this.forgetFrameRequests(frame);
300
820
  frame.waitForReadiness = () => {
301
821
  clearTimeout(timer);
302
822
  timer = setTimeout(() => {
303
- const tool = this.snapshot.tools.find(t => t.target.panel.panelId === frame.surface.panelId && t.target.panel.appId === frame.surface.appId);
823
+ const tool = this.snapshot.tools.find(t => frame.view.presentationId ? t.presentationId === frame.view.presentationId
824
+ : t.target.panel.panelId === frame.surface.panelId && t.resolved.surface?.appId === frame.surface.appId && t.target.route === frame.surface.route);
304
825
  if (tool?.readiness === "loading")
305
826
  this.reportFrame(frame, "unreported");
306
827
  }, DISCOVERY_TIMEOUT);
@@ -331,16 +852,16 @@ export class UiHost {
331
852
  reportFrame(frame, readiness) {
332
853
  if (!frame.surface.panelId || !this.active || !this.activityReady)
333
854
  return;
334
- const tools = this.snapshot.tools.map(t => t.resolved.surface?.appId === frame.surface.appId
855
+ const tools = this.snapshot.tools.map(t => (frame.view.presentationId ? t.presentationId === frame.view.presentationId : t.resolved.surface?.appId === frame.surface.appId)
335
856
  && t.target.panel.panelId === frame.surface.panelId
336
- && t.resolved.surface.installationId === frame.surface.installationId && t.resolved.surface.endpoint === frame.surface.endpoint
337
- && t.target.route === frame.surface.route ? { ...t, readiness } : t);
857
+ && t.resolved.surface?.installationId === frame.surface.installationId && t.resolved.surface?.endpoint === frame.surface.endpoint
858
+ && (frame.view.presentationId !== undefined || t.target.route === frame.surface.route) ? { ...t, readiness } : t);
338
859
  this.update({ tools });
339
860
  for (const [key, entry] of this.ledger) {
340
861
  const result = entry.result;
341
862
  if (result?.status !== "opened")
342
863
  continue;
343
- const tool = tools.find(t => t.key === panelKey(result.target.panel) && t.target.route === result.target.route);
864
+ const tool = tools.find(t => t.presentationId === result.presentationId && t.currentRoute === result.target.route);
344
865
  if (!tool || tool.readiness === result.readiness)
345
866
  continue;
346
867
  entry.result = { ...result, readiness: tool.readiness };
@@ -350,6 +871,43 @@ export class UiHost {
350
871
  this.emit({ type: "presentation", value: entry.result });
351
872
  }
352
873
  }
874
+ reportRoute(frame, value, entryKey) {
875
+ if (!frame.view.presentationId)
876
+ return;
877
+ const route = canonicalSurfaceRoute(value);
878
+ if (frame.routeReport?.route === route && frame.routeReport.pending)
879
+ return;
880
+ const report = { route };
881
+ frame.routeReport = report;
882
+ const tool = this.snapshot.tools.find(t => t.presentationId === frame.view.presentationId);
883
+ const declared = this.snapshot.catalog.find(p => p.panel.appId === frame.surface.appId && p.panel.panelId === frame.surface.panelId);
884
+ const roots = [tool?.navigationRoot, declared?.origin === frame.surface.origin && declared.endpoint === frame.surface.endpoint ? declared.route : undefined];
885
+ const currentRoute = route && roots.some(root => root && routeWithin(route, root)) ? route : null;
886
+ this.update({ tools: this.snapshot.tools.map(t => t.presentationId === frame.view.presentationId && t.currentRoute !== currentRoute
887
+ ? { ...t, currentRoute, readiness: "unreported" } : t) });
888
+ if (currentRoute || !route || typeof entryKey !== "string" || !entryKey || entryKey.length > 256)
889
+ return;
890
+ const activity = this.activityGeneration, generation = frame.generation, nonce = frame.nonce, surface = frame.surface;
891
+ report.pending = (async () => {
892
+ try {
893
+ const resolved = await uiRequest(this.path, "navigation/resolve", {
894
+ appId: surface.appId, installationId: surface.installationId, entryKey,
895
+ });
896
+ this.checkActivity(activity);
897
+ if (!frame.live || !frame.connected || frame.generation !== generation || frame.nonce !== nonce || frame.routeReport !== report
898
+ || frame.actor !== this.snapshot.bootstrap?.userId)
899
+ return;
900
+ const next = resolved.surface;
901
+ if (!resolved.available || !next || next.appId !== surface.appId || next.installationId !== surface.installationId
902
+ || next.origin !== surface.origin || next.endpoint !== surface.endpoint || !routeWithin(route, next.route)
903
+ || JSON.stringify(next.framePolicy) !== JSON.stringify(surface.framePolicy))
904
+ return;
905
+ this.update({ tools: this.snapshot.tools.map(t => t.presentationId === frame.view.presentationId
906
+ ? { ...t, navigationRoot: next.route, currentRoute: route, readiness: "unreported" } : t) });
907
+ }
908
+ catch { /* Local navigation remains usable when its host cannot verify new metadata. */ }
909
+ })().finally(() => { report.pending = undefined; });
910
+ }
353
911
  async receive(event) {
354
912
  if (!this.active || !this.activityReady || this.disposed)
355
913
  return;
@@ -368,7 +926,8 @@ export class UiHost {
368
926
  frame.onConnection?.(false);
369
927
  }
370
928
  frame.nonce = msg.childNonce;
371
- this.post(frame, { type: "hosty:ui:offer" });
929
+ frame.composition = Array.isArray(msg.capabilities) && msg.capabilities.includes(UI_COMPOSITION_CAPABILITY);
930
+ this.post(frame, { type: "hosty:ui:offer", capabilities: [UI_COMPOSITION_CAPABILITY] });
372
931
  return;
373
932
  }
374
933
  if (msg.hostWindowId !== this.windowId || msg.generation !== frame.generation || msg.childNonce !== frame.nonce || !frame.nonce)
@@ -391,7 +950,9 @@ export class UiHost {
391
950
  frame.waitForReadiness();
392
951
  }
393
952
  this.post(frame, { type: "hosty:ui:connected", coreOrigin: own.coreOrigin, userId: own.userId,
394
- appId: own.app.appId, installationId: own.app.installationId, code: frame.connected ? null : "actor_mismatch", route: frame.surface.route });
953
+ appId: own.app.appId, installationId: own.app.installationId, code: frame.connected ? null : "actor_mismatch", route: frame.surface.route,
954
+ ...(frame.composition ? { capabilities: [UI_COMPOSITION_CAPABILITY], chrome: frame.chrome, revision: ++frame.policyRevision,
955
+ rootDisabled: !this.getChrome().tools.enabled, endpoint: frame.surface.endpoint } : {}) });
395
956
  if (frame.connected !== wasConnected)
396
957
  frame.onConnection?.(frame.connected);
397
958
  if (!frame.connected)
@@ -401,31 +962,59 @@ export class UiHost {
401
962
  if (!frame.connected)
402
963
  return;
403
964
  const source = `${frame.surface.appId}:${frame.generation}:${frame.nonce}`;
965
+ if (frame.composition && (msg.type === "hosty:ui:picker" || msg.type === "hosty:ui:navigate") && validId(msg.requestId)) {
966
+ const result = msg.type === "hosty:ui:picker" ? await this.pickTools({ requestId: msg.requestId }, frame)
967
+ : typeof msg.entryKey === "string" ? await this.navigateFrame(msg.entryKey, { requestId: msg.requestId }, frame)
968
+ : { status: "rejected", requestId: msg.requestId, code: "destination_invalid" };
969
+ if (source === `${frame.surface.appId}:${frame.generation}:${frame.nonce}`)
970
+ this.post(frame, { type: "hosty:ui:action-result", requestId: msg.requestId, result });
971
+ return;
972
+ }
973
+ if (frame.composition && msg.type === "hosty:ui:route") {
974
+ this.reportRoute(frame, msg.route, msg.entryKey);
975
+ return;
976
+ }
977
+ if (msg.type === "hosty:ui:surface-state" && frame.routeReport?.pending) {
978
+ const report = frame.routeReport;
979
+ if (canonicalSurfaceRoute(msg.route) !== report.route)
980
+ return;
981
+ await report.pending;
982
+ if (!this.active || this.disposed || !frame.live || !frame.connected || frame.routeReport !== report
983
+ || source !== `${frame.surface.appId}:${frame.generation}:${frame.nonce}`)
984
+ return;
985
+ }
404
986
  if (msg.type === "hosty:ui:open" && validId(msg.requestId)) {
405
- const result = await this.open(msg.target, { placement: msg.placement, requestId: msg.requestId }, source, frame);
987
+ const result = await this.open(msg.target, { placement: msg.placement, requestId: msg.requestId, reuse: frame.composition ? msg.reuse : undefined }, source, frame);
406
988
  if (source === `${frame.surface.appId}:${frame.generation}:${frame.nonce}`)
407
- this.post(frame, { type: "hosty:ui:result", requestId: msg.requestId, result });
989
+ this.post(frame, { type: "hosty:ui:result", requestId: msg.requestId, result: this.peerResult(frame, result) });
408
990
  }
409
991
  else if (msg.type === "hosty:ui:status" && validId(msg.requestId)) {
410
992
  const result = await this.status(msg.requestId, source, frame);
411
993
  if (source === `${frame.surface.appId}:${frame.generation}:${frame.nonce}`)
412
- this.post(frame, { type: "hosty:ui:result", requestId: msg.requestId, result });
994
+ this.post(frame, { type: "hosty:ui:result", requestId: msg.requestId, result: this.peerResult(frame, result) });
413
995
  }
414
996
  else if (msg.type === "hosty:ui:surface-state" && canonicalSurfaceRoute(msg.route) !== null
415
- && canonicalSurfaceRoute(msg.route) === canonicalSurfaceRoute(frame.surface.route)) {
997
+ && canonicalSurfaceRoute(msg.route) === canonicalSurfaceRoute(frame.view.presentationId
998
+ ? this.snapshot.tools.find(t => t.presentationId === frame.view.presentationId)?.currentRoute : frame.surface.route)) {
416
999
  if (msg.state === "ready")
417
1000
  this.reportFrame(frame, "ready");
418
1001
  else if (msg.state === "auth-required" || msg.state === "blocked")
419
1002
  this.reportFrame(frame, "auth-required");
420
1003
  }
421
1004
  else if (msg.type === "hosty:ui:dismiss") {
422
- const selected = this.snapshot.tools.find(t => this.snapshot.selected && t.key === panelKey(this.snapshot.selected));
423
- if (selected?.resolved.surface?.appId === frame.surface.appId && selected.target.panel.panelId === frame.surface.panelId && selected.target.route === frame.surface.route)
424
- window.dispatchEvent(new CustomEvent("hosty:ui:drawer-dismiss"));
1005
+ const selected = this.snapshot.tools.find(t => t.presentationId === this.workspaces.get(frame.view.workspaceId)?.selectedId);
1006
+ if (selected && (frame.view.presentationId ? selected.presentationId === frame.view.presentationId
1007
+ : selected.resolved.surface?.appId === frame.surface.appId && selected.target.panel.panelId === frame.surface.panelId && selected.target.route === frame.surface.route))
1008
+ window.dispatchEvent(new CustomEvent("hosty:ui:drawer-dismiss", { detail: frame.view.workspaceId }));
425
1009
  }
426
1010
  }
427
1011
  dispose = () => {
1012
+ if (this.disposed)
1013
+ return;
1014
+ this.resetPresentations();
428
1015
  this.disposed = true;
1016
+ this.composition.dispose();
1017
+ this.actions.clear();
429
1018
  for (const frame of this.frames)
430
1019
  frame.cleanup();
431
1020
  window.removeEventListener("message", this.onMessage);
@@ -433,6 +1022,7 @@ export class UiHost {
433
1022
  this.listeners.clear();
434
1023
  this.events.clear();
435
1024
  this.ledger.clear();
1025
+ this.viewChrome.clear();
436
1026
  if (documentHost === this)
437
1027
  documentHost = null;
438
1028
  };