@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
@@ -0,0 +1,17 @@
1
+ import { type ReactNode } from "react";
2
+ export declare function useHostyChrome(): import("./ui-types").UiChromeSnapshot;
3
+ export declare function useHostyNavigation(): Readonly<{
4
+ status: "loading" | "ready" | "unavailable";
5
+ catalog: import("./ui-types").UiNavigationCatalog | null;
6
+ entries: readonly import("./ui-types").UiNavigationEntry[];
7
+ activeKey: string | null;
8
+ code?: string;
9
+ }>;
10
+ export declare function HostyNavigationBoundary({ children, className }: {
11
+ children: ReactNode;
12
+ className?: string;
13
+ }): import("react").JSX.Element;
14
+ export declare function HostyToolLauncher(): import("react").JSX.Element | null;
15
+ export declare function HostyNavigation({ className }: {
16
+ className?: string;
17
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,89 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useContext, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
4
+ import { ui, createPresentationRequestId } from "./ui.js";
5
+ import { INITIAL_CHROME, INITIAL_NAVIGATION } from "./ui-composition.js";
6
+ import { UiCompositionContext } from "./ui-context.js";
7
+ import { UI_COMPOSITION_CAPABILITY } from "./ui-types.js";
8
+ export function useHostyChrome() { return useSyncExternalStore(ui.subscribeChrome, ui.getChrome, () => INITIAL_CHROME); }
9
+ export function useHostyNavigation() { return useSyncExternalStore(ui.subscribeNavigation, ui.getNavigation, () => INITIAL_NAVIGATION); }
10
+ export function HostyNavigationBoundary({ children, className }) {
11
+ const chrome = useHostyChrome(), ref = useRef(null), hidden = chrome.navigation === "hide";
12
+ const focused = useRef(false);
13
+ useLayoutEffect(() => { if (hidden && focused.current) {
14
+ document.querySelector('[data-hosty-content][tabindex="-1"]')?.focus();
15
+ focused.current = false;
16
+ } }, [hidden]);
17
+ return _jsx("div", { ref: ref, onFocusCapture: () => { focused.current = true; }, onBlurCapture: event => { if (!event.currentTarget.contains(event.relatedTarget))
18
+ focused.current = false; }, className: className, "data-hosty-navigation-boundary": true, hidden: hidden, inert: hidden, style: hidden ? { display: "none" } : { display: "contents" }, children: children });
19
+ }
20
+ export function HostyToolLauncher() {
21
+ const chrome = useHostyChrome(), [error, setError] = useState(null);
22
+ if (chrome.tools.owner === "local" || !chrome.tools.enabled || chrome.tools.launcher === "hide"
23
+ || !chrome.capabilities.includes(UI_COMPOSITION_CAPABILITY))
24
+ return null;
25
+ return _jsxs("div", { className: "hosty-child-tools", children: [_jsx("button", { type: "button", disabled: chrome.tools.owner !== "host", onClick: async () => {
26
+ const result = await ui.openToolPicker({ requestId: createPresentationRequestId() });
27
+ setError(result.status === "accepted" ? null : "Tools are unavailable. Reconnect to your host or open the app separately.");
28
+ }, children: "Tools" }), error && _jsx("span", { role: "status", children: error })] });
29
+ }
30
+ export function HostyNavigation({ className = "" }) {
31
+ const composition = useContext(UiCompositionContext);
32
+ const model = useHostyNavigation(), chrome = useHostyChrome();
33
+ const [expanded, setExpanded] = useState(false), [error, setError] = useState(null);
34
+ const [narrow, setNarrow] = useState(false), dialog = useRef(null), opener = useRef(null);
35
+ useEffect(() => { const resize = () => setNarrow(window.innerWidth < 900); resize(); window.addEventListener("resize", resize); return () => window.removeEventListener("resize", resize); }, []);
36
+ useEffect(() => {
37
+ const modal = (event) => { if (event.detail !== "navigation")
38
+ setExpanded(false); };
39
+ window.addEventListener("hosty:ui:modal", modal);
40
+ return () => window.removeEventListener("hosty:ui:modal", modal);
41
+ }, []);
42
+ useEffect(() => {
43
+ if (expanded && narrow && chrome.navigation === "show") {
44
+ window.dispatchEvent(new CustomEvent("hosty:ui:modal", { detail: "navigation" }));
45
+ dialog.current?.showModal();
46
+ }
47
+ else {
48
+ if (dialog.current?.open) {
49
+ dialog.current.close();
50
+ if (chrome.navigation === "show")
51
+ opener.current?.focus();
52
+ else
53
+ document.querySelector('[data-hosty-content][tabindex="-1"]')?.focus();
54
+ }
55
+ }
56
+ }, [expanded, narrow, chrome.navigation]);
57
+ const empty = model.status === "ready" && !model.entries.length;
58
+ const links = _jsxs(_Fragment, { children: [model.status === "loading" && _jsx("p", { role: "status", children: "Loading navigation\u2026" }), model.status === "unavailable" && _jsxs("p", { role: "status", children: ["Navigation is unavailable. ", _jsx("button", { type: "button", onClick: () => void composition?.refreshNavigation(), children: "Retry navigation" })] }), model.entries.map(entry => _jsxs("a", { href: entry.standaloneHref ?? undefined, "aria-current": model.activeKey === entry.key ? "page" : undefined, "aria-disabled": !entry.route || !entry.available, title: !entry.available ? entry.state : undefined, onClick: async (event) => {
59
+ if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.button !== 0)
60
+ return;
61
+ event.preventDefault();
62
+ if (!entry.route || !entry.available)
63
+ return;
64
+ const result = await ui.navigate(entry.key, { requestId: createPresentationRequestId() });
65
+ if (result.status === "accepted") {
66
+ setExpanded(false);
67
+ setError(null);
68
+ }
69
+ else
70
+ setError(`Cannot open this page (${result.code}). Try again or open it separately.`);
71
+ }, children: [entry.icon && _jsx("img", { src: entry.icon, alt: "", width: 18, height: 18 }), _jsx("span", { children: entry.label })] }, entry.key)), error && _jsx("p", { role: "status", children: error })] });
72
+ if (empty)
73
+ return null;
74
+ return _jsxs(HostyNavigationBoundary, { children: [_jsx("style", { children: navigationStyles }), _jsx("div", { className: `hosty-navigation ${className}`, children: narrow ? _jsxs(_Fragment, { children: [_jsx("button", { ref: opener, type: "button", "aria-label": "Page navigation", "aria-expanded": expanded, onClick: () => setExpanded(true), children: "\u2630" }), _jsxs("dialog", { ref: dialog, className: "hosty-navigation-dialog", "aria-label": "Page navigation", onCancel: () => setExpanded(false), children: [_jsx("button", { type: "button", onClick: () => setExpanded(false), children: "Close navigation" }), _jsx("nav", { "aria-label": "App pages", children: links })] })] }) : _jsx("nav", { "aria-label": "App pages", children: links }) })] });
75
+ }
76
+ const navigationStyles = `
77
+ [data-hosty-navigation=hide] [data-hosty-navigation-boundary]{display:none!important}
78
+ .hosty-app-layout{display:flex;width:100%;height:100%;min-height:0;min-width:0}
79
+ .hosty-app-content{flex:1;min-width:0;min-height:0;overflow:auto;outline:none}
80
+ .hosty-navigation{flex:0 0 190px;box-sizing:border-box;padding:12px;border-right:1px solid color-mix(in srgb,currentColor 14%,transparent);font:14px system-ui}
81
+ .hosty-navigation nav,.hosty-navigation-dialog nav{display:flex;flex-direction:column;gap:4px}
82
+ .hosty-navigation a,.hosty-navigation-dialog a{display:flex;align-items:center;gap:8px;padding:9px 10px;border-radius:6px;text-decoration:none;color:inherit}
83
+ .hosty-navigation a[aria-current=page],.hosty-navigation-dialog a[aria-current=page]{background:color-mix(in srgb,currentColor 10%,transparent);font-weight:600}
84
+ .hosty-navigation a[aria-disabled=true]{opacity:.55;cursor:not-allowed}
85
+ .hosty-navigation button,.hosty-child-tools button,.hosty-navigation-dialog button{font:inherit;color:inherit;background:transparent;border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:6px;padding:7px 10px;cursor:pointer}
86
+ .hosty-navigation-dialog{position:fixed;inset:0 auto 0 0;margin:0;height:100dvh;width:min(300px,85vw);box-sizing:border-box;border:0;padding:16px;background:Canvas;color:CanvasText}
87
+ .hosty-navigation-dialog::backdrop{background:#0007}.hosty-child-tools{padding:6px 10px}
88
+ @media(max-width:899px){.hosty-app-layout{flex-direction:column}.hosty-navigation{flex:0 0 auto;border-right:0;padding:6px 10px}}
89
+ `;
@@ -2,18 +2,27 @@ import { type ReactNode } from "react";
2
2
  import { type UiClient } from "./ui.js";
3
3
  import { type UiHost } from "./ui-host.js";
4
4
  import { type HostyAppFrameProps } from "./ui-frame.js";
5
- import { type PanelTarget, type UiPanel } from "./ui-types.js";
5
+ import { type PanelTarget, type UiPanel, type UiChromeOverrides, type UiRouterAdapter } from "./ui-types.js";
6
+ export { HostyNavigation, HostyNavigationBoundary, useHostyChrome, useHostyNavigation } from "./ui-navigation-react.js";
6
7
  export { HostyAppFrame, type HostyAppFrameProps } from "./ui-frame.js";
7
8
  export declare function useHostyUi(): UiClient;
8
9
  export declare function useHostyUiHost(): UiHost | null;
9
10
  /** Internal composition root, owned by HostyOverlay rather than mounted again by domain apps. */
10
- export declare function HostyUiRoot({ children, uiPath, panelLayout, active }: {
11
+ export declare function HostyUiRoot({ children, uiPath, panelLayout, active, chrome, navigation }: {
12
+ chrome?: UiChromeOverrides;
13
+ navigation?: {
14
+ renderer: "standard" | "custom";
15
+ router?: UiRouterAdapter;
16
+ };
11
17
  children: ReactNode;
12
18
  uiPath?: string;
13
19
  panelLayout?: "auto" | "slot" | "none";
14
20
  active?: boolean;
15
21
  }): import("react").JSX.Element;
16
22
  export type HostyPanelWorkspaceProps = {
23
+ workspaceId?: string;
24
+ /** A host-owned tool window can keep dock geometry even in a narrow container. */
25
+ layout?: "responsive" | "dock";
17
26
  children: ReactNode;
18
27
  railMode?: "opened" | "catalog";
19
28
  className?: string;
@@ -26,10 +35,11 @@ export type HostyPanelWorkspaceProps = {
26
35
  onAuthRequired?: HostyAppFrameProps["onAuthRequired"];
27
36
  outbound?: {
28
37
  target: PanelTarget;
38
+ presentationId?: string;
29
39
  message: unknown;
30
40
  nonce: number;
31
41
  } | null;
32
42
  renderIcon?: (panel: UiPanel) => ReactNode;
33
43
  };
34
44
  /** A single retained frame tree for dock and drawer; only CSS placement changes at the breakpoint. */
35
- export declare function HostyPanelWorkspace({ children, railMode, className, initialWidth, initialExpanded, onWidthChange, onExpandedChange, onStartApp, onAskAssistant, onAuthRequired, outbound, renderIcon }: HostyPanelWorkspaceProps): import("react").JSX.Element;
45
+ export declare function HostyPanelWorkspace({ children, workspaceId, layout, railMode, className, initialWidth, initialExpanded, onWidthChange, onExpandedChange, onStartApp, onAskAssistant, onAuthRequired, outbound, renderIcon }: HostyPanelWorkspaceProps): import("react").JSX.Element;
package/dist/ui-react.js CHANGED
@@ -1,18 +1,23 @@
1
1
  "use client";
2
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useContext, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
4
4
  import { bindUiClient, ui } from "./ui.js";
5
5
  import { createUiHost } from "./ui-host.js";
6
6
  import { EmbeddedUiClient } from "./ui-embedded.js";
7
- import { UiHostContext } from "./ui-context.js";
8
- import { HostyAppFrame } from "./ui-frame.js";
7
+ import { UiHostContext, UiCompositionContext } from "./ui-context.js";
9
8
  import { panelKey } from "./ui-types.js";
9
+ import { HostyNavigation, HostyToolLauncher, useHostyChrome } from "./ui-navigation-react.js";
10
+ export { HostyNavigation, HostyNavigationBoundary, useHostyChrome, useHostyNavigation } from "./ui-navigation-react.js";
11
+ import { captureChromeHints } from "./ui-composition.js";
12
+ import { DEFAULT_WORKSPACE } from "./ui-workspaces.js";
13
+ import { UiFrameLayer, UiFrameLayerContext, HostyPresentationLayer, presentationElement } from "./ui-frame-layer.js";
10
14
  import { uiPath as normalizeUiPath } from "./ui-transport.js";
11
15
  export { HostyAppFrame } from "./ui-frame.js";
12
16
  export function useHostyUi() { return ui; }
13
17
  export function useHostyUiHost() { return useContext(UiHostContext); }
14
18
  /** Internal composition root, owned by HostyOverlay rather than mounted again by domain apps. */
15
- export function HostyUiRoot({ children, uiPath, panelLayout = "auto", active = true }) {
19
+ export function HostyUiRoot({ children, uiPath, panelLayout = "auto", active = true, chrome, navigation }) {
20
+ const [layer] = useState(() => new UiFrameLayer());
16
21
  const [host, setHost] = useState(null);
17
22
  const [environment, setEnvironment] = useState(null);
18
23
  const mounted = useRef(false);
@@ -23,9 +28,15 @@ export function HostyUiRoot({ children, uiPath, panelLayout = "auto", active = t
23
28
  clientRef.current?.setActive(active);
24
29
  }, [active]);
25
30
  useEffect(() => {
31
+ captureChromeHints();
32
+ const address = new URL(window.location.href);
33
+ for (const key of ["hosty_navigation", "hosty_tools", "hosty_tool_launcher"])
34
+ address.searchParams.delete(key);
35
+ window.history.replaceState(window.history.state, "", address.pathname + address.search + address.hash);
26
36
  const path = normalizeUiPath(uiPath);
27
- const client = window.parent === window ? createUiHost({ uiPath: path, active: activeRef.current }) : new EmbeddedUiClient(path);
37
+ const client = window.parent === window ? createUiHost({ uiPath: path, active: activeRef.current, chrome }) : new EmbeddedUiClient(path, chrome);
28
38
  client.setActive(activeRef.current);
39
+ client.composition.attachRouter(navigation?.router);
29
40
  clientRef.current = client;
30
41
  const release = bindUiClient(client), stop = client.subscribe(event => { if (event.type === "environment")
31
42
  setEnvironment(event.value); });
@@ -35,41 +46,89 @@ export function HostyUiRoot({ children, uiPath, panelLayout = "auto", active = t
35
46
  setHost(client);
36
47
  return () => { stop(); release(); client.dispose(); clientRef.current = null; };
37
48
  }, [uiPath]);
49
+ useEffect(() => { clientRef.current?.composition.configure(chrome ?? {}); }, [chrome]);
50
+ useEffect(() => { clientRef.current?.composition.attachRouter(navigation?.router); }, [navigation?.router]);
38
51
  const allowed = environment?.mode === "standalone" || environment?.connection === "connected" || environment?.connection === "unsupported";
39
52
  if (allowed)
40
53
  mounted.current = true;
41
- return _jsxs(UiHostContext.Provider, { value: host, children: [_jsx("div", { hidden: !allowed, inert: !allowed, style: { display: allowed ? "contents" : "none" }, children: mounted.current && (panelLayout === "auto" && host ? _jsx(HostyPanelWorkspace, { children: children }) : children) }), !allowed && _jsx("div", { role: "status", className: "hosty-panel-notice", style: { padding: 24, font: "14px system-ui" }, children: environment?.connection === "blocked" ? _jsxs(_Fragment, { children: [_jsx("strong", { children: environment.code === "actor_mismatch" ? "Use the same Hosty account in this app and its host" : "Cannot verify this app’s host" }), _jsx("p", { children: "Check your account and access in Hosty Core, then reconnect. Your existing discussion is unchanged." }), _jsx("button", { type: "button", onClick: () => window.location.reload(), children: "Reconnect" })] }) : "Connecting to the workspace…" })] });
54
+ const content = _jsxs("div", { className: "hosty-app-layout", children: [navigation?.renderer !== "custom" && _jsx(HostyNavigation, {}), _jsxs("div", { className: "hosty-app-content", "data-hosty-content": true, tabIndex: -1, children: [_jsx(HostyToolLauncher, {}), children] })] });
55
+ return _jsx(UiHostContext.Provider, { value: host, children: _jsx(UiCompositionContext.Provider, { value: clientRef.current?.composition ?? null, children: _jsxs(UiFrameLayerContext.Provider, { value: layer, children: [_jsx("style", { children: `.hosty-app-layout{display:flex;width:100%;height:100%;min-width:0;min-height:0}.hosty-app-content{flex:1;min-width:0;min-height:0;overflow:auto;outline:none}` }), _jsx("div", { hidden: !allowed, inert: !allowed, style: { display: allowed ? "contents" : "none" }, children: mounted.current && (panelLayout === "auto" && host ? _jsx(HostyPanelWorkspace, { children: content }) : content) }), !allowed && _jsx("div", { role: "status", className: "hosty-panel-notice", style: { padding: 24, font: "14px system-ui" }, children: environment?.connection === "blocked" ? _jsxs(_Fragment, { children: [_jsx("strong", { children: environment.code === "actor_mismatch" ? "Use the same Hosty account in this app and its host" : "Cannot verify this app’s host" }), _jsx("p", { children: "Check your account and access in Hosty Core, then reconnect. Your existing discussion is unchanged." }), _jsx("button", { type: "button", onClick: () => window.location.reload(), children: "Reconnect" })] }) : "Connecting to the workspace…" }), host && _jsx(HostyPresentationLayer, { layer: layer, host: host, active: !!allowed })] }) }) });
42
56
  }
43
57
  const empty = { bootstrap: null, catalog: [], tools: [], selected: null, expanded: false, width: 360, error: null };
44
58
  const noop = () => () => { };
45
59
  /** A single retained frame tree for dock and drawer; only CSS placement changes at the breakpoint. */
46
- export function HostyPanelWorkspace({ children, railMode = "opened", className = "", initialWidth, initialExpanded, onWidthChange, onExpandedChange, onStartApp, onAskAssistant, onAuthRequired, outbound, renderIcon }) {
60
+ export function HostyPanelWorkspace({ children, workspaceId = DEFAULT_WORKSPACE, layout = "responsive", railMode = "opened", className = "", initialWidth, initialExpanded, onWidthChange, onExpandedChange, onStartApp, onAskAssistant, onAuthRequired, outbound, renderIcon }) {
47
61
  const host = useHostyUiHost();
62
+ const layer = useContext(UiFrameLayerContext), chrome = useHostyChrome();
63
+ const viewport = useRef(null);
48
64
  const state = useSyncExternalStore(host?.subscribeSnapshot ?? noop, host?.getSnapshot ?? (() => empty), () => empty);
49
65
  const unsupported = state.error?.code.startsWith("unsupported") === true;
66
+ const workspace = state.workspaces?.find(w => w.workspaceId === workspaceId);
67
+ const workspaceTools = state.tools.filter(t => t.workspaceId === workspaceId);
50
68
  const root = useRef(null), content = useRef(null), body = useRef(null), close = useRef(null);
51
69
  const opener = useRef(null), panelOpener = useRef(null);
52
70
  const [size, setSize] = useState(1200), [chooser, setChooser] = useState(false), [error, setError] = useState(null);
53
71
  const chooserRef = useRef(null);
54
72
  const bodyId = useId();
55
- const workspaceOwner = useRef({});
56
- useEffect(() => host?.claimWorkspace(workspaceOwner.current), [host]);
57
- const drawer = size < 900, expanded = state.expanded && !!state.selected;
58
- const selectedKey = state.selected ? panelKey(state.selected) : null;
59
- const tabs = railMode === "catalog" ? state.catalog : state.tools.map(tool => state.catalog.find(p => panelKey(p.panel) === tool.key)).filter((p) => !!p);
73
+ useEffect(() => {
74
+ if (!host || !layer)
75
+ return;
76
+ const renderer = {
77
+ frameLayer: layer,
78
+ async present(instance) { return layer.ensure(instance.presentationId) ? "accepted" : "unavailable"; },
79
+ focus(id) { presentationElement(id)?.querySelector("iframe")?.focus(); },
80
+ collapse() { if (chooserRef.current?.open)
81
+ chooserRef.current.close(); setChooser(false); }, release(id) { layer.release(id); },
82
+ async showToolPicker(signal) {
83
+ const dialog = chooserRef.current;
84
+ if (signal.aborted || !dialog?.isConnected)
85
+ return "unavailable";
86
+ window.dispatchEvent(new CustomEvent("hosty:ui:modal", { detail: `picker:${workspaceId}` }));
87
+ if (!dialog.open)
88
+ dialog.showModal();
89
+ setChooser(true);
90
+ return "accepted";
91
+ },
92
+ };
93
+ const release = host.registerWorkspace({ workspaceId, renderer });
94
+ return () => {
95
+ for (const tool of host.getSnapshot().tools.filter(t => t.workspaceId === workspaceId))
96
+ host.closePresentation(tool.presentationId);
97
+ release();
98
+ };
99
+ }, [host, layer, workspaceId]);
100
+ useLayoutEffect(() => {
101
+ if (!layer || !viewport.current)
102
+ return;
103
+ return layer.register(workspaceId, { element: viewport.current, drawer: layout === "responsive" && size < 900, onAskAssistant, onAuthRequired, outbound });
104
+ }, [layer, workspaceId, size, layout, onAskAssistant, onAuthRequired, outbound]);
105
+ useEffect(() => {
106
+ const onModal = (event) => {
107
+ const kind = event.detail;
108
+ if (kind !== `picker:${workspaceId}`)
109
+ setChooser(false);
110
+ if (kind === "navigation" || layout === "responsive" && size < 900 && kind !== `tools:${workspaceId}` && kind !== `picker:${workspaceId}`)
111
+ host?.collapseWorkspace(workspaceId);
112
+ };
113
+ window.addEventListener("hosty:ui:modal", onModal);
114
+ return () => window.removeEventListener("hosty:ui:modal", onModal);
115
+ }, [host, workspaceId, size, layout]);
116
+ const drawer = layout === "responsive" && size < 900, expanded = !!workspace?.expanded && !!workspace.selectedId;
117
+ const tool = workspaceTools.find(t => t.presentationId === workspace?.selectedId);
118
+ const selectedKey = tool?.key ?? null;
119
+ const tabs = railMode === "catalog" ? state.catalog : [...new Set(workspaceTools.map(tool => tool.key))].map(key => state.catalog.find(p => panelKey(p.panel) === key)).filter((p) => !!p);
60
120
  const selected = state.catalog.find(p => panelKey(p.panel) === selectedKey);
61
- const tool = state.tools.find(t => t.key === selectedKey);
62
- const width = Math.min(state.width, Math.max(280, size - 480 - 60));
121
+ const width = Math.min(workspace?.width ?? 360, Math.max(280, size - 480 - 60));
63
122
  const optionsRef = useRef({ onWidthChange, onExpandedChange });
64
123
  optionsRef.current = { onWidthChange, onExpandedChange };
65
124
  useEffect(() => {
66
125
  if (!host)
67
126
  return;
68
127
  if (initialWidth !== undefined)
69
- host.setWidth(initialWidth);
128
+ host.setWidth(initialWidth, workspaceId);
70
129
  if (initialExpanded !== undefined)
71
- host.setExpanded(initialExpanded);
72
- }, [host]);
130
+ host.setExpanded(initialExpanded, workspaceId);
131
+ }, [host, workspaceId]);
73
132
  useEffect(() => {
74
133
  if (!host || railMode !== "catalog")
75
134
  return;
@@ -77,18 +136,24 @@ export function HostyPanelWorkspace({ children, railMode = "opened", className =
77
136
  let attemptedCatalog = null;
78
137
  const restore = () => {
79
138
  const current = host.getSnapshot();
80
- if (!current.expanded) {
139
+ const slot = current.workspaces?.find(w => w.workspaceId === workspaceId);
140
+ if (!slot?.expanded) {
141
+ completed = false;
81
142
  attemptedCatalog = null;
82
143
  return;
83
144
  }
84
- if (!live || pending || completed || current.selected || current.catalog === attemptedCatalog)
145
+ if (completed && !slot.selectedId) {
146
+ completed = false;
147
+ attemptedCatalog = null;
148
+ }
149
+ if (!live || pending || completed || slot.selectedId || current.catalog === attemptedCatalog)
85
150
  return;
86
151
  const first = current.catalog.find(panel => panel.available);
87
152
  if (!first)
88
153
  return;
89
154
  pending = true;
90
155
  attemptedCatalog = current.catalog;
91
- void host.selectPanel(first.panel).then(result => { completed = result.status === "opened"; })
156
+ void host.selectPanel(first.panel, workspaceId).then(result => { completed = result.status === "opened"; })
92
157
  .finally(() => { pending = false; restore(); });
93
158
  };
94
159
  // A refusal consumes only this catalog generation. A refresh can retry, including one
@@ -96,17 +161,19 @@ export function HostyPanelWorkspace({ children, railMode = "opened", className =
96
161
  const stop = host.subscribeSnapshot(restore);
97
162
  restore();
98
163
  return () => { live = false; stop(); };
99
- }, [host, railMode]);
164
+ }, [host, railMode, workspaceId]);
100
165
  const lastPrefs = useRef({ width: initialWidth ?? 360, expanded: initialExpanded ?? false });
101
166
  useEffect(() => {
102
167
  if (!host || state !== host.getSnapshot())
103
168
  return;
104
- if (state.width !== lastPrefs.current.width)
105
- optionsRef.current.onWidthChange?.(state.width);
106
- if (state.expanded !== lastPrefs.current.expanded)
107
- optionsRef.current.onExpandedChange?.(state.expanded);
108
- lastPrefs.current = { width: state.width, expanded: state.expanded };
109
- }, [host, state.width, state.expanded]);
169
+ if (!workspace)
170
+ return;
171
+ if (workspace.width !== lastPrefs.current.width)
172
+ optionsRef.current.onWidthChange?.(workspace.width);
173
+ if (workspace.expanded !== lastPrefs.current.expanded)
174
+ optionsRef.current.onExpandedChange?.(workspace.expanded);
175
+ lastPrefs.current = { width: workspace.width, expanded: workspace.expanded };
176
+ }, [host, state, workspace]);
110
177
  useLayoutEffect(() => {
111
178
  if (!root.current)
112
179
  return;
@@ -126,33 +193,40 @@ export function HostyPanelWorkspace({ children, railMode = "opened", className =
126
193
  useLayoutEffect(() => {
127
194
  if (!expanded || !drawer)
128
195
  return;
196
+ window.dispatchEvent(new CustomEvent("hosty:ui:modal", { detail: `tools:${workspaceId}` }));
129
197
  opener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
130
198
  close.current?.focus();
131
- const dismiss = () => host?.setExpanded(false);
199
+ const dismiss = (event) => { if (event.detail === workspaceId)
200
+ host?.setExpanded(false, workspaceId); };
132
201
  window.addEventListener("hosty:ui:drawer-dismiss", dismiss);
133
202
  return () => { window.removeEventListener("hosty:ui:drawer-dismiss", dismiss); if (opener.current?.isConnected)
134
203
  opener.current.focus(); };
135
- }, [expanded, drawer, host]);
204
+ }, [expanded, drawer, host, workspaceId]);
136
205
  useEffect(() => {
137
206
  if (expanded && tool?.readiness === "ready")
138
- body.current?.querySelector(".hosty-panel-frame:not([hidden]) iframe")?.focus();
139
- }, [expanded, selectedKey, tool?.readiness]);
207
+ presentationElement(workspace?.selectedId ?? "")?.querySelector("iframe")?.focus();
208
+ }, [expanded, workspace?.selectedId, tool?.readiness]);
140
209
  useEffect(() => {
141
210
  const dialog = chooserRef.current;
142
- if (!chooser || !dialog)
211
+ if (!dialog)
143
212
  return;
144
- dialog.showModal();
213
+ if (chooser && !dialog.open) {
214
+ window.dispatchEvent(new CustomEvent("hosty:ui:modal", { detail: `picker:${workspaceId}` }));
215
+ dialog.showModal();
216
+ }
217
+ else if (!chooser && dialog.open)
218
+ dialog.close();
145
219
  return () => { if (dialog.open)
146
220
  dialog.close(); };
147
- }, [chooser]);
221
+ }, [chooser, workspaceId]);
148
222
  async function select(panel) {
149
223
  if (!host)
150
224
  return;
151
225
  if (expanded && selectedKey === panelKey(panel.panel)) {
152
- host.setExpanded(false);
226
+ host.setExpanded(false, workspaceId);
153
227
  return;
154
228
  }
155
- const result = await host.selectPanel(panel.panel);
229
+ const result = await host.selectPanel(panel.panel, workspaceId);
156
230
  if (result.status !== "opened")
157
231
  setError(panel.available ? "Could not open this tool. Try again." : `${panel.appName} is ${panel.state}.`);
158
232
  else {
@@ -163,51 +237,54 @@ export function HostyPanelWorkspace({ children, railMode = "opened", className =
163
237
  const resize = (event) => {
164
238
  const start = event.clientX, old = width, handle = event.currentTarget;
165
239
  handle.setPointerCapture(event.pointerId);
166
- const move = (next) => host?.setWidth(Math.min(size - 540, old + start - next.clientX));
240
+ const move = (next) => host?.setWidth(Math.min(size - 540, old + start - next.clientX), workspaceId);
167
241
  const end = () => { handle.removeEventListener("pointermove", move); handle.removeEventListener("pointerup", end); handle.removeEventListener("lostpointercapture", end); };
168
242
  handle.addEventListener("pointermove", move);
169
243
  handle.addEventListener("pointerup", end);
170
244
  handle.addEventListener("lostpointercapture", end);
171
245
  };
172
- return _jsxs("div", { ref: root, className: `hosty-panel-workspace ${className}`, "data-hosty-panel-layout": drawer ? "drawer" : "dock", style: { "--hosty-panel-width": `${width}px`, ...(unsupported ? { display: "contents" } : {}) }, children: [_jsx("style", { children: styles }), _jsx("div", { ref: content, className: "hosty-panel-document", style: unsupported ? { display: "contents" } : undefined, inert: expanded && drawer, children: children }), _jsxs("nav", { className: "hosty-panel-rail", "aria-label": "Tools", style: unsupported ? { display: "none" } : undefined, inert: unsupported || expanded && drawer, children: [tabs.map((panel, index) => _jsxs("button", { type: "button", title: `${panel.appName}: ${panel.label}`, "aria-label": panel.label, "aria-controls": bodyId, "aria-expanded": expanded && selectedKey === panelKey(panel.panel), onClick: () => void select(panel), onKeyDown: event => {
246
+ return _jsxs("div", { ref: root, className: `hosty-panel-workspace ${className}`, "data-hosty-panel-layout": drawer ? "drawer" : "dock", style: { "--hosty-panel-width": `${width}px`, ...(unsupported ? { display: "contents" } : {}) }, children: [_jsx("style", { children: styles }), _jsx("div", { ref: content, className: "hosty-panel-document", style: unsupported ? { display: "contents" } : undefined, inert: expanded && drawer, children: children }), _jsxs("nav", { className: "hosty-panel-rail", "aria-label": "Tools", style: unsupported || !chrome.tools.enabled || chrome.tools.launcher === "hide" ? { display: "none" } : undefined, inert: unsupported || expanded && drawer, children: [tabs.map((panel, index) => _jsxs("button", { type: "button", title: `${panel.appName}: ${panel.label}`, "aria-label": panel.label, "aria-controls": bodyId, "aria-expanded": expanded && selectedKey === panelKey(panel.panel), onClick: () => void select(panel), onKeyDown: event => {
173
247
  const delta = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0;
174
248
  const next = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : delta ? (index + delta + tabs.length) % tabs.length : null;
175
249
  if (next !== null) {
176
250
  event.preventDefault();
177
251
  event.currentTarget.parentElement?.children[next]?.focus();
178
252
  }
179
- }, children: [renderIcon?.(panel) ?? panel.label.slice(0, 2), state.tools.find(t => t.key === panelKey(panel.panel))?.attention ? _jsx("span", { className: "hosty-panel-attention", "aria-label": "Needs attention", children: "\u2022" }) : null] }, panelKey(panel.panel))), _jsx("button", { type: "button", "aria-label": "Add tool", title: "Add tool", onClick: () => { setError(null); setChooser(true); void host?.refresh(); }, children: "+" })] }), expanded && drawer && _jsx("button", { type: "button", className: "hosty-panel-backdrop", "aria-label": "Return to document", onClick: () => host?.setExpanded(false) }), _jsxs("aside", { ref: body, id: bodyId, className: "hosty-panel-body", hidden: !expanded, inert: !expanded, role: drawer ? "dialog" : "complementary", "aria-modal": drawer && expanded ? true : undefined, "aria-label": selected?.label ?? "Tool", onKeyDown: event => {
253
+ }, children: [renderIcon?.(panel) ?? panel.label.slice(0, 2), workspaceTools.some(t => t.key === panelKey(panel.panel) && t.attention > 0) ? _jsx("span", { className: "hosty-panel-attention", "aria-label": "Needs attention", children: "\u2022" }) : null] }, panelKey(panel.panel))), _jsx("button", { type: "button", "aria-label": "Add tool", title: "Add tool", onClick: () => { setError(null); setChooser(true); void host?.refresh(); }, children: "+" })] }), expanded && drawer && _jsx("button", { type: "button", className: "hosty-panel-backdrop", "aria-label": "Return to document", onClick: () => host?.setExpanded(false, workspaceId) }), _jsxs("aside", { ref: body, id: bodyId, className: "hosty-panel-body", hidden: !expanded, inert: !expanded, role: drawer ? "dialog" : "complementary", "aria-modal": drawer && expanded ? true : undefined, "aria-label": selected?.label ?? "Tool", onKeyDown: event => {
180
254
  if (!drawer)
181
255
  return;
182
256
  if (event.key === "Escape") {
183
257
  event.preventDefault();
184
- host?.setExpanded(false);
258
+ host?.setExpanded(false, workspaceId);
185
259
  }
186
260
  if (event.key === "Tab") {
187
- const focusable = Array.from(body.current?.querySelectorAll('button,a[href],iframe,[tabindex="0"]') ?? []).filter(e => !e.closest("[hidden]") && !e.disabled);
261
+ const focusable = Array.from(body.current?.querySelectorAll('button,a[href],select,[tabindex="0"]') ?? []).filter(e => !e.closest("[hidden]") && !e.classList.contains("hosty-panel-focus-guard") && !e.disabled);
188
262
  const first = focusable[0], last = focusable.at(-1);
263
+ const frame = presentationElement(workspace?.selectedId ?? "")?.querySelector("iframe");
189
264
  if (event.shiftKey && document.activeElement === first) {
190
265
  event.preventDefault();
191
- last?.focus();
266
+ (frame ?? last)?.focus();
192
267
  }
193
268
  else if (!event.shiftKey && document.activeElement === last) {
194
269
  event.preventDefault();
195
- first?.focus();
270
+ (frame ?? first)?.focus();
196
271
  }
197
272
  }
198
- }, children: [drawer && expanded && _jsx("span", { tabIndex: 0, "aria-hidden": "true", className: "hosty-panel-focus-guard", onFocus: () => body.current?.querySelector(".hosty-panel-frame:not([hidden]) iframe")?.focus() }), _jsx("div", { className: "hosty-panel-resize", role: "separator", "aria-label": "Tool panel width", "aria-orientation": "vertical", tabIndex: drawer ? -1 : 0, "aria-valuemin": 280, "aria-valuemax": Math.max(280, Math.min(800, size - 540)), "aria-valuenow": width, onPointerDown: resize, onDoubleClick: () => host?.setWidth(360), onKeyDown: event => {
273
+ }, children: [drawer && expanded && _jsx("span", { tabIndex: 0, "aria-hidden": "true", className: "hosty-panel-focus-guard", onFocus: () => presentationElement(workspace?.selectedId ?? "")?.querySelector("iframe")?.focus() }), _jsx("div", { className: "hosty-panel-resize", role: "separator", "aria-label": "Tool panel width", "aria-orientation": "vertical", tabIndex: drawer ? -1 : 0, "aria-valuemin": 280, "aria-valuemax": Math.max(280, Math.min(800, size - 540)), "aria-valuenow": width, onPointerDown: resize, onDoubleClick: () => host?.setWidth(360, workspaceId), onKeyDown: event => {
199
274
  if (event.key === "Home") {
200
275
  event.preventDefault();
201
- host?.setWidth(360);
276
+ host?.setWidth(360, workspaceId);
202
277
  }
203
278
  if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
204
279
  event.preventDefault();
205
- host?.setWidth(width + (event.key === "ArrowLeft" ? 16 : -16));
280
+ host?.setWidth(width + (event.key === "ArrowLeft" ? 16 : -16), workspaceId);
206
281
  }
207
- } }), _jsxs("header", { children: [_jsx("strong", { children: selected?.label ?? "Tool" }), tool?.resolved.standaloneHref && _jsx("a", { href: tool.resolved.standaloneHref, target: "_blank", rel: "noopener noreferrer", title: "Open separately", "aria-label": "Open separately", children: "\u2197" }), _jsx("button", { ref: close, type: "button", onClick: () => host?.setExpanded(false), "aria-label": "Return to document", title: "Return to document", children: "\u00D7" })] }), tool?.resolved.state === "degraded" && _jsx("div", { className: "hosty-panel-state", role: "status", children: "This app\u2019s health check is failing. Its page may still work." }), tool?.readiness === "unreported" && _jsxs("div", { className: "hosty-panel-state", role: "status", children: ["This tool has not confirmed that it loaded. If the page is blank or refuses to connect, use ", _jsx("a", { href: tool.resolved.standaloneHref ?? undefined, target: "_blank", rel: "noopener noreferrer", children: "Open separately" }), "."] }), tool?.readiness === "auth-required" && _jsx("div", { className: "hosty-panel-state", role: "status", children: "Check access in the tool below, then retry opening the discussion." }), state.tools.map(item => _jsx("div", { className: "hosty-panel-frame", hidden: item.key !== selectedKey, inert: item.key !== selectedKey, children: item.resolved.surface ? _jsx(HostyAppFrame, { surface: { ...item.resolved.surface, panelId: item.target.panel.panelId }, title: state.catalog.find(p => panelKey(p.panel) === item.key)?.label ?? "Tool", standaloneHref: item.resolved.standaloneHref, onAskAssistant: onAskAssistant, onAuthRequired: onAuthRequired, onAttention: count => host?.setAttention(item.target.panel, count), outbound: outbound && panelKey(outbound.target.panel) === item.key && outbound.target.route === item.target.route ? outbound : null }, JSON.stringify(item.resolved.surface))
208
- : _jsxs("div", { className: "hosty-panel-notice", children: [_jsxs("p", { children: ["This tool is unavailable (", item.resolved.state, ")."] }), _jsx("button", { type: "button", onClick: () => void host?.selectPanel(item.target.panel), children: "Retry opening" })] }) }, item.key)), drawer && expanded && _jsx("span", { tabIndex: 0, "aria-hidden": "true", className: "hosty-panel-focus-guard", onFocus: () => close.current?.focus() })] }), chooser && _jsxs("dialog", { ref: chooserRef, className: "hosty-tool-chooser", onCancel: () => setChooser(false), "aria-label": "Add tool", children: [_jsxs("header", { children: [_jsx("strong", { children: "Add tool" }), _jsx("button", { type: "button", onClick: () => setChooser(false), "aria-label": "Close tool chooser", children: "\u00D7" })] }), _jsx("p", { children: "Open a tool beside your document." }), error && _jsx("p", { role: "status", children: error }), state.error && _jsx("p", { role: "status", children: state.error.message }), !state.bootstrap?.canHost && _jsx("p", { children: "This app needs optional panel access in Hosty Core to show tools here. You can still use the app." }), state.bootstrap?.canHost && !state.catalog.length && _jsx("p", { children: "No tools are available for your account." }), _jsx("ul", { children: state.catalog.map(panel => _jsxs("li", { children: [_jsxs("button", { type: "button", onClick: () => void select(panel), children: [_jsx("strong", { children: panel.label }), _jsxs("span", { children: [panel.appName, panel.available ? "" : ` · ${panel.state}`] })] }), !panel.available && panel.canStart && onStartApp && _jsx("button", { type: "button", onClick: () => void Promise.resolve(onStartApp(panel.panel.appId)).then(() => host?.refresh()), children: "Start app" })] }, panelKey(panel.panel))) }), _jsx("button", { type: "button", onClick: () => void host?.refresh(), children: "Refresh tools" })] }), error && !chooser && _jsxs("div", { className: "hosty-panel-error", role: "status", children: [error, " ", _jsx("button", { type: "button", onClick: () => { setChooser(true); setError(null); }, children: "Choose tool" })] })] });
282
+ } }), _jsxs("header", { children: [_jsx("strong", { children: selected?.label ?? "Tool" }), tool?.resolved.standaloneHref && _jsx("a", { href: tool.resolved.standaloneHref, target: "_blank", rel: "noopener noreferrer", title: "Open separately", "aria-label": "Open separately", children: "\u2197" }), _jsx("button", { ref: close, type: "button", onClick: () => host?.setExpanded(false, workspaceId), "aria-label": "Return to document", title: "Return to document", children: "\u00D7" })] }), tool?.resolved.state === "degraded" && _jsx("div", { className: "hosty-panel-state", role: "status", children: "This app\u2019s health check is failing. Its page may still work." }), tool?.readiness === "unreported" && _jsxs("div", { className: "hosty-panel-state", role: "status", children: ["This tool has not confirmed that it loaded. If the page is blank or refuses to connect, use ", _jsx("a", { href: tool.resolved.standaloneHref ?? undefined, target: "_blank", rel: "noopener noreferrer", children: "Open separately" }), "."] }), tool?.readiness === "auth-required" && _jsx("div", { className: "hosty-panel-state", role: "status", children: "Check access in the tool below, then retry opening the discussion." }), tool && workspaceTools.filter(t => t.key === tool.key).length > 1 && _jsxs("label", { className: "hosty-panel-state", children: ["Open views", _jsx("select", { "aria-label": "Tool instance", value: tool.presentationId, onChange: event => host?.focusPresentation(event.target.value), children: workspaceTools.filter(t => t.key === tool.key).map((item, index) => _jsxs("option", { value: item.presentationId, children: [selected?.label ?? "Tool", " ", index + 1] }, item.presentationId)) })] }), tool && _jsx("button", { type: "button", className: "hosty-panel-state", onClick: () => host?.closePresentation(tool.presentationId), children: "Close this tool view" }), _jsx("div", { ref: viewport, className: "hosty-panel-viewport", "aria-owns": tool ? `hosty-presentation-${tool.presentationId}` : undefined }), drawer && expanded && _jsx("span", { tabIndex: 0, "aria-hidden": "true", className: "hosty-panel-focus-guard", onFocus: () => close.current?.focus() })] }), _jsxs("dialog", { ref: chooserRef, className: "hosty-tool-chooser", onCancel: () => setChooser(false), "aria-label": "Add tool", children: [_jsxs("header", { children: [_jsx("strong", { children: "Add tool" }), _jsx("button", { type: "button", onClick: () => setChooser(false), "aria-label": "Close tool chooser", children: "\u00D7" })] }), _jsx("p", { children: "Open a tool beside your document." }), error && _jsx("p", { role: "status", children: error }), state.error && _jsx("p", { role: "status", children: state.error.message }), !state.bootstrap?.canHost && _jsx("p", { children: "This app needs optional panel access in Hosty Core to show tools here. You can still use the app." }), state.bootstrap?.canHost && !state.catalog.length && _jsx("p", { children: "No tools are available for your account." }), _jsx("ul", { children: state.catalog.map(panel => _jsxs("li", { children: [_jsxs("button", { type: "button", onClick: () => void select(panel), children: [_jsx("strong", { children: panel.label }), _jsxs("span", { children: [panel.appName, panel.available ? "" : ` · ${panel.state}`] })] }), !panel.available && panel.canStart && onStartApp && _jsx("button", { type: "button", onClick: () => void Promise.resolve(onStartApp(panel.panel.appId)).then(() => host?.refresh()), children: "Start app" })] }, panelKey(panel.panel))) }), _jsx("button", { type: "button", onClick: () => void host?.refresh(), children: "Refresh tools" })] }), error && !chooser && _jsxs("div", { className: "hosty-panel-error", role: "status", children: [error, " ", _jsx("button", { type: "button", onClick: () => { setChooser(true); setError(null); }, children: "Choose tool" })] })] });
209
283
  }
210
284
  const styles = `
285
+ .hosty-panel-viewport{flex:1;min-height:0;min-width:0}
286
+ .hosty-app-layout{display:flex;width:100%;height:100%;min-width:0;min-height:0}.hosty-app-content{flex:1;min-width:0;min-height:0;overflow:auto}
287
+
211
288
  .hosty-panel-workspace{--hp-bg:#f8f9fb;--hp-fg:#20242c;--hp-muted:#606875;--hp-border:#e0e4eb;display:flex;position:relative;min-width:0;min-height:0;width:100%;height:100dvh;overflow:hidden;background:var(--hp-bg);color:var(--hp-fg)}
212
289
  html.dark .hosty-panel-workspace,html[data-hosty-theme=dark] .hosty-panel-workspace{--hp-bg:#14171c;--hp-fg:#f0f2f5;--hp-muted:#a2aab7;--hp-border:#323843}
213
290
  .hosty-panel-document{flex:1;min-width:0;min-height:0;overflow:auto}.hosty-panel-rail{order:3;flex:0 0 48px;display:flex;flex-direction:column;align-items:center;gap:8px;padding:10px 0;border-left:1px solid var(--hp-border);overflow:auto}
@@ -219,7 +296,7 @@ html.dark .hosty-panel-workspace,html[data-hosty-theme=dark] .hosty-panel-worksp
219
296
  .hosty-panel-focus-guard{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}
220
297
  .hosty-panel-frame{position:relative;flex:1;min-height:0;overflow:hidden}.hosty-panel-frame iframe{display:block}.hosty-panel-resize{position:absolute;left:-12px;top:0;bottom:0;width:12px;cursor:col-resize;touch-action:none}.hosty-panel-resize:hover{background:color-mix(in srgb,var(--hp-fg) 8%,transparent)}
221
298
  .hosty-panel-state{padding:8px 12px;font:12px/1.4 system-ui;border-bottom:1px solid var(--hp-border);color:var(--hp-muted)}.hosty-panel-state a{text-decoration:underline}
222
- .hosty-panel-workspace[data-hosty-panel-layout=drawer] .hosty-panel-body{position:absolute;inset:0 0 0 auto;width:min(440px,100%);z-index:3;margin:0;box-shadow:-8px 0 30px #0002}.hosty-panel-workspace[data-hosty-panel-layout=drawer] .hosty-panel-resize{display:none}
223
- .hosty-panel-backdrop{position:absolute;inset:0;border:0;background:#0005;z-index:2;cursor:pointer}.hosty-panel-notice{padding:24px;font:14px/1.5 system-ui}.hosty-panel-review{position:absolute;top:8px;right:8px;z-index:2;padding:10px;background:var(--hp-bg);border:1px solid var(--hp-border);border-radius:8px}
299
+ .hosty-panel-workspace[data-hosty-panel-layout=drawer] .hosty-panel-body{position:absolute;inset:0 0 0 auto;width:min(440px,100%);z-index:102;margin:0;box-shadow:-8px 0 30px #0002}.hosty-panel-workspace[data-hosty-panel-layout=drawer] .hosty-panel-resize{display:none}
300
+ .hosty-panel-backdrop{position:absolute;inset:0;border:0;background:#0005;z-index:101;cursor:pointer}.hosty-panel-notice{padding:24px;font:14px/1.5 system-ui}.hosty-panel-review{position:absolute;top:8px;right:8px;z-index:2;padding:10px;background:var(--hp-bg);border:1px solid var(--hp-border);border-radius:8px}
224
301
  .hosty-tool-chooser{width:min(440px,calc(100vw - 32px));max-height:80dvh;border:1px solid var(--hp-border);border-radius:12px;padding:16px;background:var(--hp-bg);color:var(--hp-fg);font:14px/1.5 system-ui}.hosty-tool-chooser::backdrop{background:#0005}.hosty-tool-chooser ul{list-style:none;padding:0;display:grid;gap:8px}.hosty-tool-chooser li{display:flex;gap:8px}.hosty-tool-chooser li>button{flex:1;border:1px solid var(--hp-border);background:transparent;color:inherit;border-radius:8px;padding:12px;text-align:left;cursor:pointer}.hosty-tool-chooser li span{display:block;color:var(--hp-muted);font-size:12px}.hosty-panel-error{position:absolute;bottom:16px;right:60px;z-index:4;background:var(--hp-bg);border:1px solid var(--hp-border);border-radius:8px;padding:12px;font:13px system-ui}
225
302
  `;
@@ -1,5 +1,5 @@
1
1
  import type { ProviderServerOptions } from "@hosty-sdk/app/providers/server";
2
- import { type PanelTarget, type UiBootstrap, type UiPanel, type UiResolvedPanel } from "@hosty-sdk/app/ui/types";
2
+ import { type PanelTarget, type UiBootstrap, type UiPanel, type UiResolvedPanel, type UiNavigationCatalog, type UiNavigationTarget, type UiResolvedNavigation } from "@hosty-sdk/app/ui/types";
3
3
  /** Server transport for generic UI metadata. Credentials never appear in returned metadata. */
4
4
  export declare class UiServerClient {
5
5
  private readonly appId;
@@ -11,6 +11,8 @@ export declare class UiServerClient {
11
11
  parentOrigin?: string;
12
12
  }, token: string, signal?: AbortSignal): Promise<UiBootstrap>;
13
13
  listPanels(token: string, signal?: AbortSignal): Promise<UiPanel[]>;
14
+ navigation(token: string, signal?: AbortSignal): Promise<UiNavigationCatalog>;
15
+ resolveNavigation(target: UiNavigationTarget, token: string, signal?: AbortSignal): Promise<UiResolvedNavigation>;
14
16
  resolvePanel(target: PanelTarget, token: string, signal?: AbortSignal): Promise<UiResolvedPanel>;
15
17
  }
16
18
  type UiAuthenticatedContext = {
package/dist/ui-server.js CHANGED
@@ -35,6 +35,12 @@ export class UiServerClient {
35
35
  async listPanels(token, signal) {
36
36
  return (await this.request("panels", token, undefined, signal)).panels;
37
37
  }
38
+ navigation(token, signal) {
39
+ return this.request("navigation", token, undefined, signal);
40
+ }
41
+ resolveNavigation(target, token, signal) {
42
+ return this.request("navigation/resolve", token, target, signal);
43
+ }
38
44
  resolvePanel(target, token, signal) {
39
45
  return this.request("panels/resolve", token, target, signal);
40
46
  }
@@ -62,11 +68,17 @@ export async function createHostyUiResponse(request, context, options = {}) {
62
68
  else if (request.method === "GET" && route === "panels") {
63
69
  result = { panels: await client.listPanels(context.token, request.signal) };
64
70
  }
65
- else if (request.method === "POST" && route === "panels/resolve") {
71
+ else if (request.method === "GET" && route === "navigation") {
72
+ if (url.search)
73
+ throw new UiError("ui_request_invalid", "Unexpected navigation parameters.", 400);
74
+ result = await client.navigation(context.token, request.signal);
75
+ }
76
+ else if (request.method === "POST" && (route === "panels/resolve" || route === "navigation/resolve")) {
66
77
  if (!sameOrigin(request) || request.headers.get("content-type")?.split(";", 1)[0]?.trim() !== "application/json")
67
78
  throw new UiError("origin_denied", "Open panels from this application page.", 403);
68
79
  const body = await boundedBody(request);
69
- result = await client.resolvePanel(JSON.parse(body), context.token, request.signal);
80
+ result = route === "panels/resolve" ? await client.resolvePanel(JSON.parse(body), context.token, request.signal)
81
+ : await client.resolveNavigation(JSON.parse(body), context.token, request.signal);
70
82
  }
71
83
  else
72
84
  throw new UiError("ui_route_not_found", "Unknown panel operation.", 404);
@@ -1,4 +1,4 @@
1
- import { type PanelTarget, type PresentationResult } from "./ui-types.js";
1
+ import { type PanelTarget, type PresentationResult, type UiActionResult } from "./ui-types.js";
2
2
  export declare const UI_VERSION = 1;
3
3
  export declare const DISCOVERY_TIMEOUT = 5000;
4
4
  export declare const RESPONSE_TIMEOUT = 10000;
@@ -13,4 +13,4 @@ export declare function validTarget(value: unknown): value is PanelTarget;
13
13
  export declare function uiPath(path?: string): string;
14
14
  export declare function uiRequest<T>(path: string, suffix: string, body?: unknown): Promise<T>;
15
15
  export declare function failure(requestId: string, error: unknown): PresentationResult;
16
- export declare function waitForPresentation(operation: Promise<PresentationResult>, requestId: string, signal?: AbortSignal): Promise<PresentationResult>;
16
+ export declare function waitForPresentation<T extends PresentationResult | UiActionResult>(operation: Promise<T>, requestId: string, signal?: AbortSignal): Promise<T>;
@@ -21,7 +21,7 @@ export function canonicalSurfaceRoute(value) {
21
21
  if (!isSurfaceRoute(value))
22
22
  return null;
23
23
  const route = new URL(value, "https://surface.invalid");
24
- for (const key of ["hosty_launch", "hosty_theme", "hosty_theme_preference"])
24
+ for (const key of ["hosty_launch", "hosty_theme", "hosty_theme_preference", "hosty_navigation", "hosty_tools", "hosty_tool_launcher"])
25
25
  route.searchParams.delete(key);
26
26
  return `${route.pathname}${route.search}${route.hash}`;
27
27
  }