@hosty-sdk/app 0.25.12 → 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.
Files changed (47) hide show
  1. package/README.md +181 -19
  2. package/dist/background-server.d.ts +16 -0
  3. package/dist/background-server.js +52 -0
  4. package/dist/browser-auth.d.ts +2 -0
  5. package/dist/browser-auth.js +5 -0
  6. package/dist/continuity-browser.d.ts +11 -0
  7. package/dist/continuity-browser.js +92 -0
  8. package/dist/continuity.d.ts +9 -0
  9. package/dist/continuity.js +9 -0
  10. package/dist/index.d.ts +9 -6
  11. package/dist/index.js +10 -1
  12. package/dist/next.d.ts +4 -0
  13. package/dist/next.js +29 -0
  14. package/dist/overlay-react.d.ts +9 -1
  15. package/dist/overlay-react.js +6 -4
  16. package/dist/react.d.ts +3 -0
  17. package/dist/react.js +18 -1
  18. package/dist/server.d.ts +2 -0
  19. package/dist/server.js +2 -0
  20. package/dist/session-server.d.ts +2 -0
  21. package/dist/ui-composition.d.ts +71 -0
  22. package/dist/ui-composition.js +232 -0
  23. package/dist/ui-context.d.ts +2 -0
  24. package/dist/ui-context.js +1 -0
  25. package/dist/ui-embedded.d.ts +18 -1
  26. package/dist/ui-embedded.js +123 -17
  27. package/dist/ui-frame-layer.d.ts +36 -0
  28. package/dist/ui-frame-layer.js +88 -0
  29. package/dist/ui-frame.d.ts +2 -0
  30. package/dist/ui-frame.js +43 -7
  31. package/dist/ui-host.d.ts +62 -15
  32. package/dist/ui-host.js +666 -76
  33. package/dist/ui-navigation-react.d.ts +17 -0
  34. package/dist/ui-navigation-react.js +89 -0
  35. package/dist/ui-react.d.ts +13 -3
  36. package/dist/ui-react.js +127 -50
  37. package/dist/ui-server.d.ts +3 -1
  38. package/dist/ui-server.js +14 -2
  39. package/dist/ui-transport.d.ts +2 -2
  40. package/dist/ui-transport.js +1 -1
  41. package/dist/ui-types.d.ts +88 -0
  42. package/dist/ui-types.js +1 -0
  43. package/dist/ui-workspaces.d.ts +54 -0
  44. package/dist/ui-workspaces.js +3 -0
  45. package/dist/ui.d.ts +7 -1
  46. package/dist/ui.js +22 -0
  47. package/package.json +17 -1
@@ -1,9 +1,13 @@
1
+ import { activeNavigationKey, UiCompositionModel } from "./ui-composition.js";
2
+ import { UI_COMPOSITION_CAPABILITY } from "./ui-types.js";
1
3
  import { createPresentationRequestId } from "./ui.js";
2
4
  import { UiError } from "./ui-types.js";
3
- import { DISCOVERY_TIMEOUT, failure, readUiMessage, uiRequest, validId, validTarget, waitForPresentation } from "./ui-transport.js";
5
+ import { canonicalSurfaceRoute, DISCOVERY_TIMEOUT, failure, readUiMessage, uiRequest, validId, validTarget, waitForPresentation } from "./ui-transport.js";
4
6
  /** Direct-child transport. Discovery carries no identity and never creates a fallback host. */
5
7
  export class EmbeddedUiClient {
6
8
  path;
9
+ composition;
10
+ localActions = new Map();
7
11
  environment = { mode: "embedded", connection: "connecting" };
8
12
  nonce = createPresentationRequestId();
9
13
  listeners = new Set();
@@ -20,8 +24,19 @@ export class EmbeddedUiClient {
20
24
  onFocus = () => { void this.recheck(); };
21
25
  onKey = (event) => { if (this.active && !event.defaultPrevented && event.key === "Escape")
22
26
  this.post({ type: "hosty:ui:dismiss" }); };
23
- constructor(path) {
27
+ constructor(path, chrome = {}) {
24
28
  this.path = path;
29
+ this.composition = new UiCompositionModel(path, "embedded", chrome);
30
+ this.composition.attachRouter();
31
+ this.composition.subscribeNavigation(() => {
32
+ if (this.getChrome().capabilities.includes(UI_COMPOSITION_CAPABILITY) && this.environment.connection === "connected") {
33
+ const route = this.composition.currentRoute();
34
+ if (this.state && canonicalSurfaceRoute(this.state.route) === canonicalSurfaceRoute(route))
35
+ this.reportSurfaceState(this.state);
36
+ else
37
+ this.reportRoute(route);
38
+ }
39
+ });
25
40
  window.addEventListener("message", this.onMessage);
26
41
  window.addEventListener("focus", this.onFocus);
27
42
  window.addEventListener("keydown", this.onKey);
@@ -30,12 +45,18 @@ export class EmbeddedUiClient {
30
45
  this.setEnvironment("unsupported", "unsupported_nesting");
31
46
  }
32
47
  getEnvironment = () => this.environment;
48
+ getChrome = () => this.composition.getChrome();
49
+ subscribeChrome = (listener) => this.composition.subscribeChrome(listener);
50
+ getNavigation = () => this.composition.getNavigation();
51
+ subscribeNavigation = (listener) => this.composition.subscribeNavigation(listener);
33
52
  setActive(active) {
34
53
  if (this.active === active)
35
54
  return;
36
55
  if (!active)
37
56
  this.post({ type: "hosty:ui:auth-required" });
38
57
  this.active = active;
58
+ this.composition.resetNavigation();
59
+ this.localActions.clear();
39
60
  this.channel = null;
40
61
  this.verifying = null;
41
62
  this.nonce = createPresentationRequestId();
@@ -72,11 +93,12 @@ export class EmbeddedUiClient {
72
93
  if (this.disposed)
73
94
  return;
74
95
  this.environment = { mode: "embedded", connection, ...(code ? { code } : {}) };
96
+ this.composition.setEnvironment(this.environment);
75
97
  this.emit({ type: "environment", value: this.environment });
76
98
  }
77
99
  probe() {
78
100
  if (this.active && !this.disposed && window.parent === window.top && !this.channel)
79
- window.parent.postMessage({ type: "hosty:ui:probe", version: 1, versions: [1], childNonce: this.nonce }, "*");
101
+ window.parent.postMessage({ type: "hosty:ui:probe", version: 1, versions: [1], capabilities: [UI_COMPOSITION_CAPABILITY], childNonce: this.nonce }, "*");
80
102
  }
81
103
  discover() {
82
104
  clearTimeout(this.deadline);
@@ -154,7 +176,7 @@ export class EmbeddedUiClient {
154
176
  if (previous && (previous.origin !== event.origin || previous.hostWindowId !== msg.hostWindowId || previous.generation !== msg.generation))
155
177
  this.retirePending();
156
178
  this.channel = { origin: event.origin, hostWindowId: msg.hostWindowId, generation: msg.generation, own };
157
- this.post({ type: "hosty:ui:accept", coreOrigin: own.coreOrigin, userId: own.userId, appId: own.app.appId, installationId: own.app.installationId });
179
+ this.post({ type: "hosty:ui:accept", capabilities: [UI_COMPOSITION_CAPABILITY], coreOrigin: own.coreOrigin, userId: own.userId, appId: own.app.appId, installationId: own.app.installationId });
158
180
  }
159
181
  catch (error) {
160
182
  if (this.disposed || !this.active || this.verifying !== key)
@@ -179,16 +201,29 @@ export class EmbeddedUiClient {
179
201
  }
180
202
  clearTimeout(this.deadline);
181
203
  this.setEnvironment("connected");
204
+ if (Array.isArray(msg.capabilities) && msg.capabilities.includes(UI_COMPOSITION_CAPABILITY)) {
205
+ this.composition.setHostPolicy(channel.generation, msg.revision, msg.chrome, msg.rootDisabled === true, msg.endpoint);
206
+ }
207
+ else
208
+ this.composition.setLegacyHost();
209
+ void this.composition.refreshNavigation();
182
210
  if (this.state)
183
211
  this.reportSurfaceState(this.state);
184
212
  return;
185
213
  }
186
214
  if (this.environment.connection !== "connected")
187
215
  return;
188
- if (msg.type === "hosty:ui:result" && validId(msg.requestId) && validResult(msg.result, msg.requestId)) {
189
- const result = msg.result;
190
- this.pending.get(msg.requestId)?.resolve(result);
191
- this.emit({ type: "presentation", value: result });
216
+ if (msg.type === "hosty:ui:chrome" && this.getChrome().capabilities.includes(UI_COMPOSITION_CAPABILITY)) {
217
+ this.composition.setHostPolicy(channel.generation, msg.revision, msg.chrome, msg.rootDisabled === true, msg.endpoint);
218
+ return;
219
+ }
220
+ if ((msg.type === "hosty:ui:result" || msg.type === "hosty:ui:action-result") && validId(msg.requestId) && validResult(msg.result, msg.requestId)) {
221
+ const result = msg.result, pending = this.pending.get(msg.requestId);
222
+ if (result.status === "accepted" && pending?.kind !== "action" || result.status === "opened" && pending?.kind === "action")
223
+ return;
224
+ pending?.resolve(result);
225
+ if (result.status !== "accepted")
226
+ this.emit({ type: "presentation", value: result });
192
227
  }
193
228
  }
194
229
  async request(requestId, message, signal) {
@@ -199,7 +234,9 @@ export class EmbeddedUiClient {
199
234
  if (signal?.aborted)
200
235
  return { status: "uncertain", requestId, code: "presentation_unknown" };
201
236
  const target = message.target;
202
- const input = target ? JSON.stringify([target.panel.appId, target.panel.installationId, target.panel.panelId, target.route ?? null]) : null;
237
+ const input = message.type === "hosty:ui:status" ? null : JSON.stringify([message.type, target?.panel ?? null, target?.route ?? null, message.reuse ?? "exact", message.entryKey ?? null]);
238
+ if (this.localActions.has(requestId))
239
+ return { status: "rejected", requestId, code: "request_conflict" };
203
240
  const existing = this.pending.get(requestId);
204
241
  if (existing) {
205
242
  if (input !== null && existing.input !== null && input !== existing.input)
@@ -214,11 +251,11 @@ export class EmbeddedUiClient {
214
251
  }
215
252
  return this.wait(requestId, existing, signal);
216
253
  }
217
- if (this.pending.size >= 4)
254
+ if (this.pending.size + [...this.localActions.values()].filter(value => value.pending).length >= 4)
218
255
  return { status: "blocked", requestId, code: "presentation_busy" };
219
256
  let settle;
220
257
  const operation = new Promise(resolve => { settle = resolve; });
221
- const entry = { input, operation, waiters: 0, sent: false, resolve: result => {
258
+ const entry = { kind: message.type === "hosty:ui:picker" || message.type === "hosty:ui:navigate" ? "action" : "presentation", input, operation, waiters: 0, sent: false, resolve: result => {
222
259
  if (this.pending.get(requestId) === entry)
223
260
  this.pending.delete(requestId);
224
261
  settle(result);
@@ -247,17 +284,86 @@ export class EmbeddedUiClient {
247
284
  this.pending.delete(requestId);
248
285
  });
249
286
  }
250
- openSurface = (target, options) => validTarget(target) && options.placement === "side"
251
- ? this.request(options.requestId, { type: "hosty:ui:open", target, placement: "side" }, options.signal)
252
- : Promise.resolve({ status: "rejected", requestId: options.requestId, code: "invalid_presentation" });
287
+ openSurface = (target, options) => {
288
+ if (!this.getChrome().tools.enabled)
289
+ return Promise.resolve({ status: "blocked", requestId: options.requestId, code: "tools_disabled" });
290
+ if (options.reuse !== undefined && !this.getChrome().capabilities.includes(UI_COMPOSITION_CAPABILITY))
291
+ return Promise.resolve({ status: "unsupported", requestId: options.requestId, code: "unsupported_composition" });
292
+ return validTarget(target) && options.placement === "side"
293
+ ? this.request(options.requestId, { type: "hosty:ui:open", target, placement: "side", ...(options.reuse ? { reuse: options.reuse } : {}) }, options.signal)
294
+ : Promise.resolve({ status: "rejected", requestId: options.requestId, code: "invalid_presentation" });
295
+ };
253
296
  getPresentation = (requestId, options) => this.request(requestId, { type: "hosty:ui:status" }, options?.signal);
297
+ openToolPicker = (options) => {
298
+ if (!this.getChrome().tools.enabled)
299
+ return Promise.resolve({ status: "blocked", requestId: options.requestId, code: "tools_disabled" });
300
+ if (!this.getChrome().capabilities.includes(UI_COMPOSITION_CAPABILITY))
301
+ return Promise.resolve({ status: "unsupported", requestId: options.requestId, code: "unsupported_composition" });
302
+ return this.request(options.requestId, { type: "hosty:ui:picker" }, options.signal);
303
+ };
304
+ navigate = (entryKey, options) => {
305
+ const id = options.requestId, existing = this.localActions.get(id);
306
+ if (!validId(id))
307
+ return Promise.resolve({ status: "rejected", requestId: id, code: "invalid_presentation" });
308
+ if (existing)
309
+ return existing.key === entryKey ? waitForPresentation(existing.result, id, options.signal) : Promise.resolve({ status: "rejected", requestId: id, code: "request_conflict" });
310
+ const entry = this.getNavigation().entries.find(e => e.key === entryKey);
311
+ if (!entry?.route)
312
+ return Promise.resolve({ status: "blocked", requestId: id, code: "destination_changed" });
313
+ if (!this.composition.isLocalEntry(entry)) {
314
+ if (!this.getChrome().capabilities.includes(UI_COMPOSITION_CAPABILITY))
315
+ return Promise.resolve({ status: "unsupported", requestId: id, code: "unsupported_composition" });
316
+ return this.request(id, { type: "hosty:ui:navigate", entryKey }, options.signal);
317
+ }
318
+ if (this.pending.has(id))
319
+ return Promise.resolve({ status: "rejected", requestId: id, code: "request_conflict" });
320
+ if (this.pending.size + [...this.localActions.values()].filter(value => value.pending).length >= 4)
321
+ return Promise.resolve({ status: "blocked", requestId: id, code: "presentation_busy" });
322
+ const nonce = this.nonce;
323
+ const operation = Promise.resolve().then(async () => {
324
+ try {
325
+ if (!this.active || this.disposed || nonce !== this.nonce)
326
+ throw new UiError("activity_required", "Sign in to navigate.");
327
+ if (options.signal?.aborted)
328
+ return { status: "uncertain", requestId: id, code: "presentation_unknown" };
329
+ await this.composition.navigateLocal(entry);
330
+ if (!this.active || this.disposed || nonce !== this.nonce)
331
+ throw new UiError("activity_required", "Sign in to navigate.");
332
+ return { status: "accepted", requestId: id };
333
+ }
334
+ catch (error) {
335
+ return failure(id, error);
336
+ }
337
+ });
338
+ const action = { key: entryKey, result: operation, pending: true };
339
+ this.localActions.set(id, action);
340
+ void operation.finally(() => {
341
+ action.pending = false;
342
+ for (const [key, value] of this.localActions) {
343
+ if (this.localActions.size <= 256)
344
+ break;
345
+ if (!value.pending)
346
+ this.localActions.delete(key);
347
+ }
348
+ });
349
+ return waitForPresentation(operation, id, options.signal);
350
+ };
351
+ reportRoute(route) {
352
+ const entryKey = activeNavigationKey(this.getNavigation().entries, this.composition.currentEndpoint(), route);
353
+ this.post({ type: "hosty:ui:route", route, ...(entryKey ? { entryKey } : {}) });
354
+ }
254
355
  reportSurfaceState = (state) => {
255
356
  this.state = state;
256
- if (this.environment.connection === "connected")
357
+ if (this.environment.connection === "connected") {
358
+ if (this.getChrome().capabilities.includes(UI_COMPOSITION_CAPABILITY))
359
+ this.reportRoute(state.route);
257
360
  this.post({ type: "hosty:ui:surface-state", ...state });
361
+ }
258
362
  };
259
363
  dispose = () => {
260
364
  this.disposed = true;
365
+ this.composition.dispose();
366
+ this.localActions.clear();
261
367
  clearTimeout(this.deadline);
262
368
  this.retries.forEach(clearTimeout);
263
369
  window.removeEventListener("message", this.onMessage);
@@ -273,6 +379,6 @@ function validResult(value, id) {
273
379
  if (!value || typeof value !== "object")
274
380
  return false;
275
381
  const v = value;
276
- return v.requestId === id && (v.status === "opened" ? validTarget(v.target) && ["loading", "ready", "unreported", "auth-required"].includes(v.readiness)
277
- : ["blocked", "unsupported", "rejected", "uncertain", "unknown"].includes(v.status) && "code" in v && typeof v.code === "string");
382
+ return v.requestId === id && (v.status === "accepted" || (v.status === "opened" ? validTarget(v.target) && ["loading", "ready", "unreported", "auth-required"].includes(v.readiness)
383
+ : ["blocked", "unsupported", "rejected", "uncertain", "unknown"].includes(v.status) && "code" in v && typeof v.code === "string"));
278
384
  }
@@ -0,0 +1,36 @@
1
+ import { type HostyAppFrameProps } from "./ui-frame.js";
2
+ import type { UiHost } from "./ui-host.js";
3
+ import type { PanelTarget } from "./ui-types.js";
4
+ export type UiViewport = {
5
+ element: HTMLElement;
6
+ drawer: boolean;
7
+ onAskAssistant?: HostyAppFrameProps["onAskAssistant"];
8
+ onAuthRequired?: HostyAppFrameProps["onAuthRequired"];
9
+ outbound?: {
10
+ target: PanelTarget;
11
+ presentationId?: string;
12
+ message: unknown;
13
+ nonce: number;
14
+ } | null;
15
+ };
16
+ /** One stable React frame tree for every dock/window rectangle in this document. */
17
+ export declare class UiFrameLayer {
18
+ root: HTMLDivElement | null;
19
+ readonly containers: Map<string, HTMLDivElement>;
20
+ ensure(id: string): boolean;
21
+ release(id: string): void;
22
+ readonly viewports: Map<string, UiViewport>;
23
+ private revision;
24
+ private readonly listeners;
25
+ getSnapshot: () => number;
26
+ subscribe: (listener: () => void) => () => void;
27
+ update: () => void;
28
+ register(id: string, viewport: UiViewport): () => void;
29
+ }
30
+ export declare const UiFrameLayerContext: import("react").Context<UiFrameLayer | null>;
31
+ export declare function presentationElement(id: string): HTMLElement | null;
32
+ export declare function HostyPresentationLayer({ layer, host, active }: {
33
+ layer: UiFrameLayer;
34
+ host: UiHost;
35
+ active: boolean;
36
+ }): import("react").JSX.Element;
@@ -0,0 +1,88 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { createContext, useEffect, useLayoutEffect, useSyncExternalStore } from "react";
4
+ import { HostyAppFrame } from "./ui-frame.js";
5
+ import { createPortal } from "react-dom";
6
+ import { panelKey } from "./ui-types.js";
7
+ /** One stable React frame tree for every dock/window rectangle in this document. */
8
+ export class UiFrameLayer {
9
+ root = null;
10
+ containers = new Map();
11
+ ensure(id) {
12
+ if (!this.root?.isConnected)
13
+ return false;
14
+ if (!this.containers.has(id)) {
15
+ const element = document.createElement("div");
16
+ element.dataset.hostyPresentation = id;
17
+ element.id = `hosty-presentation-${id}`;
18
+ element.className = "hosty-panel-frame";
19
+ element.hidden = true;
20
+ element.inert = true;
21
+ element.style.display = "none";
22
+ this.root.append(element);
23
+ this.containers.set(id, element);
24
+ this.update();
25
+ }
26
+ return true;
27
+ }
28
+ release(id) { this.containers.get(id)?.remove(); this.containers.delete(id); this.update(); }
29
+ viewports = new Map();
30
+ revision = 0;
31
+ listeners = new Set();
32
+ getSnapshot = () => this.revision;
33
+ subscribe = (listener) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; };
34
+ update = () => { this.revision++; for (const listener of this.listeners)
35
+ listener(); };
36
+ register(id, viewport) {
37
+ this.viewports.set(id, viewport);
38
+ this.update();
39
+ const observer = new ResizeObserver(this.update);
40
+ observer.observe(viewport.element);
41
+ return () => { observer.disconnect(); if (this.viewports.get(id) === viewport)
42
+ this.viewports.delete(id); this.update(); };
43
+ }
44
+ }
45
+ export const UiFrameLayerContext = createContext(null);
46
+ export function presentationElement(id) {
47
+ return document.querySelector(`[data-hosty-presentation="${id}"]`);
48
+ }
49
+ export function HostyPresentationLayer({ layer, host, active }) {
50
+ const state = useSyncExternalStore(host.subscribeSnapshot, host.getSnapshot, host.getSnapshot);
51
+ useSyncExternalStore(layer.subscribe, layer.getSnapshot, layer.getSnapshot);
52
+ useEffect(() => {
53
+ window.addEventListener("resize", layer.update);
54
+ window.addEventListener("scroll", layer.update, true);
55
+ return () => { window.removeEventListener("resize", layer.update); window.removeEventListener("scroll", layer.update, true); };
56
+ }, [layer]);
57
+ useLayoutEffect(() => {
58
+ for (const [id, element] of layer.containers) {
59
+ const tool = state.tools.find(t => t.presentationId === id);
60
+ if (!tool) {
61
+ layer.release(id);
62
+ continue;
63
+ }
64
+ const viewport = layer.viewports.get(tool.workspaceId), workspace = state.workspaces?.find(w => w.workspaceId === tool.workspaceId);
65
+ const visible = !!(active && viewport && workspace?.expanded && workspace.selectedId === id);
66
+ const rect = viewport?.element.getBoundingClientRect();
67
+ element.hidden = !visible;
68
+ element.inert = !visible;
69
+ Object.assign(element.style, { position: "fixed", left: `${rect?.left ?? 0}px`, top: `${rect?.top ?? 0}px`,
70
+ width: `${rect?.width ?? 0}px`, height: `${rect?.height ?? 0}px`, zIndex: viewport?.drawer ? "103" : "10", display: visible ? "block" : "none" });
71
+ }
72
+ });
73
+ return _jsxs(_Fragment, { children: [_jsx("div", { ref: element => { layer.root = element; }, className: "hosty-presentation-layer" }), state.tools.map(tool => {
74
+ const viewport = layer.viewports.get(tool.workspaceId), workspace = state.workspaces?.find(w => w.workspaceId === tool.workspaceId);
75
+ const surface = tool.resolved.surface, container = layer.containers.get(tool.presentationId);
76
+ if (!container)
77
+ return null;
78
+ const focusControls = (last) => {
79
+ const controls = Array.from(viewport?.element.closest("aside")?.querySelectorAll('button,a[href],select') ?? [])
80
+ .filter(element => !element.disabled);
81
+ (last ? controls.at(-1) : controls[0])?.focus();
82
+ };
83
+ return createPortal(_jsxs(_Fragment, { children: [viewport?.drawer && _jsx("span", { tabIndex: 0, "aria-hidden": "true", className: "hosty-panel-focus-guard", onFocus: () => focusControls(true) }), surface ? _jsx(HostyAppFrame, { surface: { ...surface, panelId: tool.target.panel.panelId }, view: { viewId: `tool-${tool.presentationId}`, workspaceId: tool.workspaceId, presentationId: tool.presentationId, chrome: { navigation: "hide", tools: { launcher: "hide" } } }, title: state.catalog.find(p => panelKey(p.panel) === tool.key)?.label ?? "Tool", standaloneHref: tool.resolved.standaloneHref, onAskAssistant: viewport?.onAskAssistant, onAuthRequired: viewport?.onAuthRequired, onAttention: count => host.setAttention(tool.presentationId, count), outbound: viewport?.outbound && panelKey(viewport.outbound.target.panel) === tool.key
84
+ && (viewport.outbound.presentationId ? viewport.outbound.presentationId === tool.presentationId
85
+ : workspace?.selectedId === tool.presentationId && viewport.outbound.target.route === tool.currentRoute) ? viewport.outbound : null })
86
+ : _jsxs("div", { className: "hosty-panel-notice", role: "status", children: ["This tool is unavailable (", tool.resolved.state, ")."] }), viewport?.drawer && _jsx("span", { tabIndex: 0, "aria-hidden": "true", className: "hosty-panel-focus-guard", onFocus: () => focusControls(false) })] }), container, tool.presentationId);
87
+ })] });
88
+ }
@@ -1,4 +1,5 @@
1
1
  import { type HostyResolvedTheme, type HostyThemePreference } from "./theme.js";
2
+ import type { UiViewDescriptor } from "./ui-workspaces.js";
2
3
  import type { UiFrameSurface } from "./ui-types.js";
3
4
  export declare function parseFrameMcpReview(event: {
4
5
  source: unknown;
@@ -10,6 +11,7 @@ export declare function parseFrameMcpReview(event: {
10
11
  } | null;
11
12
  export declare function frameSandbox(policy: UiFrameSurface["framePolicy"]): string;
12
13
  export type HostyAppFrameProps = {
14
+ view?: UiViewDescriptor;
13
15
  surface: UiFrameSurface;
14
16
  title: string;
15
17
  src?: string;
package/dist/ui-frame.js CHANGED
@@ -5,6 +5,8 @@ import { UiHostContext } from "./ui-context.js";
5
5
  import { appendThemeLaunchParams, parseActiveFrameAskAssistant, parseActiveFrameAttention, parseActiveFrameAuthRequired, parseActiveFramePermissionReview } from "./embedder.js";
6
6
  import { createShellThemeMessage } from "./theme.js";
7
7
  import { permissionReviewUrl } from "./permissions.js";
8
+ import { withChromeHints } from "./ui-composition.js";
9
+ import { createPresentationRequestId } from "./ui.js";
8
10
  export function parseFrameMcpReview(event, frame, src, appId) {
9
11
  if (!frame || event.source !== frame || !event.data || typeof event.data !== "object")
10
12
  return null;
@@ -24,7 +26,11 @@ export function frameSandbox(policy) {
24
26
  }
25
27
  /** Shared frame coordinator. No delegated-token responder and no authority inferred from messages. */
26
28
  export function HostyAppFrame(props) {
27
- const { surface, title, className, standaloneHref, theme, themePreference } = props;
29
+ const { title, className, standaloneHref, theme, themePreference } = props;
30
+ const inputId = JSON.stringify([props.surface, props.src]);
31
+ const [destination, setDestination] = useState(null);
32
+ const surface = destination?.inputId === inputId ? destination.surface : props.surface;
33
+ const [defaultViewId] = useState(createPresentationRequestId);
28
34
  const host = useContext(UiHostContext);
29
35
  const ref = useRef(null);
30
36
  const callbacks = useRef(props);
@@ -47,20 +53,44 @@ export function HostyAppFrame(props) {
47
53
  return () => observer.disconnect();
48
54
  }, []);
49
55
  const resolvedTheme = theme ?? appearance.theme, preference = themePreference ?? appearance.preference;
50
- const source = props.src ?? new URL(surface.route, surface.origin).href;
56
+ const source = destination?.inputId === inputId ? new URL(surface.route, surface.origin).href
57
+ : props.src ?? new URL(surface.route, surface.origin).href;
51
58
  // Theme changes are messages; they must not replace a document with an unsent draft.
52
- const [launch] = useState(() => {
59
+ const [launch, setLaunch] = useState(() => {
53
60
  const url = new URL(source);
54
61
  url.searchParams.set("hosty_launch", "embedded");
55
- return appendThemeLaunchParams(url.href, resolvedTheme, preference);
62
+ return appendThemeLaunchParams(withChromeHints(url.href, host?.getViewChrome?.(props.view?.viewId ?? defaultViewId)
63
+ ?? props.view?.chrome ?? { navigation: "hide", tools: { launcher: "hide" } }), resolvedTheme, preference);
56
64
  });
57
65
  const frameId = JSON.stringify([surface, source]);
58
66
  useLayoutEffect(() => {
59
67
  const frame = ref.current;
60
68
  if (!frame || !host)
61
69
  return;
62
- return host.registerFrame(frame, surface, ready => setConnection(current => ({ ready, generation: current.generation + (ready ? 1 : 0) })));
63
- }, [host, frameId]); // Caller keys the frame by this immutable surface.
70
+ const view = callbacks.current.view;
71
+ return host.registerFrame(frame, surface, ready => setConnection(current => ({ ready, generation: current.generation + (ready ? 1 : 0) })), { viewId: defaultViewId, workspaceId: "default", chrome: { navigation: "hide", tools: { launcher: "hide" } }, ...view,
72
+ navigate: next => {
73
+ const current = callbacks.current;
74
+ if (current.view?.navigate)
75
+ return current.view.navigate(next);
76
+ // Replace the document through React so every message handler follows its verified origin.
77
+ const url = new URL(next.route, next.origin);
78
+ url.searchParams.set("hosty_launch", "embedded");
79
+ setLoaded(false);
80
+ setReview(null);
81
+ setDestination({ inputId: JSON.stringify([current.surface, current.src]), surface: { ...next, panelId: surface.panelId } });
82
+ setLaunch(appendThemeLaunchParams(withChromeHints(url.href, host.getViewChrome(current.view?.viewId ?? defaultViewId)
83
+ ?? current.view?.chrome ?? { navigation: "hide", tools: { launcher: "hide" } }), resolvedTheme, preference));
84
+ } });
85
+ }, [host, frameId]);
86
+ const policy = JSON.stringify(props.view?.chrome ?? {});
87
+ const previousPolicy = useRef({ viewId: props.view?.viewId, policy });
88
+ useEffect(() => {
89
+ const previous = previousPolicy.current;
90
+ previousPolicy.current = { viewId: props.view?.viewId, policy };
91
+ if (host && props.view && ref.current && previous.viewId === props.view.viewId && previous.policy !== policy)
92
+ host.setViewChrome(props.view.viewId, JSON.parse(policy));
93
+ }, [host, props.view?.viewId, policy]);
64
94
  useEffect(() => {
65
95
  if (loaded)
66
96
  ref.current?.contentWindow?.postMessage(createShellThemeMessage(resolvedTheme, preference), surface.origin);
@@ -93,9 +123,15 @@ export function HostyAppFrame(props) {
93
123
  return () => window.removeEventListener("message", handle);
94
124
  }, [host, frameId, source, surface]);
95
125
  const outboundReady = host ? connection.ready : loaded;
126
+ const sent = useRef(null);
96
127
  useEffect(() => {
97
- if (outboundReady && props.outbound)
128
+ if (outboundReady && props.outbound) {
129
+ const key = `${connection.generation}:${props.outbound.nonce}`;
130
+ if (sent.current === key)
131
+ return;
98
132
  ref.current?.contentWindow?.postMessage(props.outbound.message, surface.origin);
133
+ sent.current = key;
134
+ }
99
135
  }, [outboundReady, connection.generation, props.outbound?.nonce, surface.origin]);
100
136
  const insecure = typeof window !== "undefined" && window.location.protocol === "https:" && new URL(surface.origin).protocol === "http:";
101
137
  if (insecure)
package/dist/ui-host.d.ts CHANGED
@@ -1,17 +1,20 @@
1
+ import { UiCompositionModel } from "./ui-composition.js";
2
+ import { type UiPresentation, type UiWorkspaceSnapshot, type UiWorkspaceRenderer, type UiViewDescriptor, type UiPlacementResolver } from "./ui-workspaces.js";
3
+ export type { UiWorkspaceRenderer, UiViewDescriptor, UiPlacementResolver, UiPresentation } from "./ui-workspaces.js";
4
+ import { type UiChromeOverrides, type UiActionOptions, type UiActionResult } from "./ui-types.js";
1
5
  import { type UiClient } from "./ui.js";
2
- import { type PanelRef, type PanelTarget, type PresentationOptions, type PresentationResult, type UiBootstrap, type UiEnvironment, type UiEvent, type UiFrameSurface, type UiPanel, type UiResolvedPanel, type SurfaceReadiness } from "./ui-types.js";
3
- export type UiTool = {
4
- key: string;
5
- target: PanelTarget;
6
- resolved: UiResolvedPanel;
7
- readiness: SurfaceReadiness;
8
- attention: number;
6
+ import { type PanelRef, type PanelTarget, type PresentationOptions, type PresentationResult, type UiBootstrap, type UiEnvironment, type UiEvent, type UiFrameSurface, type UiPanel } from "./ui-types.js";
7
+ export type UiTool = UiPresentation & {
8
+ panelSurface: UiFrameSurface;
9
+ navigationRoot?: string;
9
10
  };
10
11
  export type UiHostSnapshot = {
11
12
  bootstrap: UiBootstrap | null;
12
13
  catalog: UiPanel[];
13
14
  tools: UiTool[];
14
15
  selected: PanelRef | null;
16
+ selectedPresentationId?: string | null;
17
+ workspaces?: UiWorkspaceSnapshot[];
15
18
  expanded: boolean;
16
19
  width: number;
17
20
  error: {
@@ -23,6 +26,7 @@ export type UiHostSnapshot = {
23
26
  export declare function createUiHost(options?: {
24
27
  uiPath?: string;
25
28
  active?: boolean;
29
+ chrome?: UiChromeOverrides;
26
30
  }): UiHost;
27
31
  export declare class UiHost implements UiClient {
28
32
  private readonly path;
@@ -33,48 +37,91 @@ export declare class UiHost implements UiClient {
33
37
  private readonly listeners;
34
38
  private readonly events;
35
39
  private readonly frames;
40
+ private readonly viewChrome;
36
41
  private readonly ledger;
37
42
  private readonly toolQueues;
38
- private selectionGeneration;
43
+ private readonly moves;
44
+ private readonly rendererWork;
45
+ readonly composition: UiCompositionModel;
46
+ private readonly workspaces;
47
+ private placement?;
48
+ private readonly actions;
49
+ private focusSequence;
39
50
  private activityGeneration;
40
51
  private activityReady;
41
52
  private disposed;
42
- private workspace;
43
53
  private refreshing;
44
54
  private readonly onMessage;
45
55
  private readonly onFocus;
46
- constructor(path: string, active?: boolean);
56
+ constructor(path: string, active?: boolean, chrome?: UiChromeOverrides);
47
57
  getEnvironment: () => UiEnvironment;
58
+ getChrome: () => import("./ui-types").UiChromeSnapshot;
59
+ subscribeChrome: (listener: () => void) => () => void;
60
+ getNavigation: () => Readonly<{
61
+ status: "loading" | "ready" | "unavailable";
62
+ catalog: import("./ui-types").UiNavigationCatalog | null;
63
+ entries: readonly import("./ui-types").UiNavigationEntry[];
64
+ activeKey: string | null;
65
+ code?: string;
66
+ }>;
67
+ subscribeNavigation: (listener: () => void) => () => void;
48
68
  setActive(active: boolean): void;
49
69
  private checkActivity;
50
70
  claimWorkspace: (owner: object) => () => void;
71
+ registerWorkspace: ({ workspaceId, renderer }: {
72
+ workspaceId: string;
73
+ renderer: UiWorkspaceRenderer;
74
+ }) => () => void;
75
+ private publishWorkspaces;
76
+ setPlacementResolver: (resolver?: UiPlacementResolver) => void;
77
+ getViewChrome: (viewId: string) => UiChromeOverrides | undefined;
78
+ setViewChrome: (viewId: string, overrides: UiChromeOverrides) => void;
79
+ private publishPolicy;
80
+ private requireTools;
81
+ focusPresentation: (id: string) => boolean;
82
+ collapseWorkspace: (id: string) => void;
83
+ closePresentation: (id: string) => void;
84
+ private releasePresentation;
85
+ private resetPresentations;
86
+ movePresentation: (id: string, workspaceId: string) => Promise<boolean>;
51
87
  getSnapshot: () => UiHostSnapshot;
52
88
  subscribeSnapshot: (listener: () => void) => () => void;
53
89
  subscribe: (listener: (event: UiEvent) => void) => () => void;
54
90
  private emit;
55
91
  private update;
92
+ private invalidateRendererWork;
93
+ private runRenderer;
56
94
  private setEnvironment;
57
95
  private bootstrap;
58
96
  refresh: () => Promise<void>;
59
97
  openSurface: (target: PanelTarget, options: PresentationOptions) => Promise<PresentationResult>;
60
98
  private open;
99
+ private pendingCount;
100
+ private trimLedger;
101
+ private peerResult;
102
+ private action;
103
+ navigate: (entryKey: string, options: UiActionOptions) => Promise<UiActionResult>;
104
+ openToolPicker: (options: UiActionOptions) => Promise<UiActionResult>;
105
+ private pickTools;
106
+ private navigateFrame;
61
107
  getPresentation: (id: string, options?: {
62
108
  signal?: AbortSignal;
63
109
  }) => Promise<PresentationResult>;
64
110
  private status;
65
- selectPanel: (ref: PanelRef) => Promise<PresentationResult>;
66
- setExpanded: (expanded: boolean) => void;
67
- setWidth: (width: number) => void;
68
- setAttention: (panel: PanelRef, attention: number) => void;
111
+ selectPanel: (ref: PanelRef, workspaceId?: string) => Promise<PresentationResult>;
112
+ setExpanded: (expanded: boolean, workspaceId?: string) => void;
113
+ setWidth: (width: number, workspaceId?: string) => void;
114
+ setAttention: (panel: PanelRef | string, attention: number) => void;
69
115
  reportSurfaceState: (_state: {
70
116
  route: string;
71
117
  state: "ready" | "blocked";
72
118
  code?: string;
73
119
  }) => void;
74
120
  private forgetFrameRequests;
75
- registerFrame: (element: HTMLIFrameElement, surface: UiFrameSurface, onConnection?: (connected: boolean) => void) => (() => void);
121
+ registerFrame: (element: HTMLIFrameElement, surface: UiFrameSurface, onConnection?: (connected: boolean) => void, view?: UiViewDescriptor) => (() => void);
76
122
  private post;
77
123
  private reportFrame;
124
+ private reportRoute;
78
125
  private receive;
79
126
  dispose: () => void;
80
127
  }