@runtypelabs/persona 3.31.0 → 3.33.0

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 (51) hide show
  1. package/README.md +2 -0
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-quh7NmYD.d.cts → types-CthJFfNx.d.cts} +7 -0
  5. package/dist/animations/{types-quh7NmYD.d.ts → types-CthJFfNx.d.ts} +7 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/codegen.cjs +4 -4
  9. package/dist/codegen.js +3 -3
  10. package/dist/index.cjs +43 -43
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +288 -8
  13. package/dist/index.d.ts +288 -8
  14. package/dist/index.global.js +76 -79
  15. package/dist/index.global.js.map +1 -1
  16. package/dist/index.js +43 -43
  17. package/dist/index.js.map +1 -1
  18. package/dist/launcher.global.js.map +1 -1
  19. package/dist/smart-dom-reader.d.cts +78 -2
  20. package/dist/smart-dom-reader.d.ts +78 -2
  21. package/dist/theme-editor.cjs +30 -30
  22. package/dist/theme-editor.d.cts +92 -5
  23. package/dist/theme-editor.d.ts +92 -5
  24. package/dist/theme-editor.js +30 -30
  25. package/dist/webmcp-polyfill.js +4 -0
  26. package/package.json +4 -3
  27. package/src/ask-user-question-tool.test.ts +148 -0
  28. package/src/ask-user-question-tool.ts +138 -0
  29. package/src/client.ts +43 -9
  30. package/src/components/messages.ts +10 -0
  31. package/src/components/suggestions.ts +49 -6
  32. package/src/index-core.ts +15 -0
  33. package/src/index-global.ts +39 -0
  34. package/src/runtime/host-layout.test.ts +158 -3
  35. package/src/runtime/host-layout.ts +95 -1
  36. package/src/session.ts +188 -32
  37. package/src/session.webmcp.test.ts +48 -0
  38. package/src/suggest-replies-tool.test.ts +445 -0
  39. package/src/suggest-replies-tool.ts +152 -0
  40. package/src/theme-editor/index.ts +2 -0
  41. package/src/theme-editor/webmcp/index.ts +2 -0
  42. package/src/theme-editor/webmcp/types.ts +16 -3
  43. package/src/types.ts +79 -2
  44. package/src/ui.suggest-replies.test.ts +237 -0
  45. package/src/ui.ts +57 -13
  46. package/src/utils/dock.test.ts +23 -1
  47. package/src/utils/dock.ts +2 -0
  48. package/src/version.ts +5 -2
  49. package/src/webmcp-bridge.test.ts +60 -0
  50. package/src/webmcp-bridge.ts +34 -1
  51. package/src/webmcp-polyfill.ts +16 -0
@@ -31,3 +31,42 @@ export {
31
31
  listRegisteredStreamAnimations,
32
32
  } from "./utils/stream-animation";
33
33
  export type { StreamAnimationPlugin, StreamAnimationContext } from "./types";
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Deferred WebMCP polyfill loading.
37
+ //
38
+ // This bundle is built with `@mcp-b/webmcp-polyfill` external — the bridge's
39
+ // default `import("@mcp-b/webmcp-polyfill")` is a bare specifier no browser
40
+ // can resolve, so register a loader that imports the self-contained
41
+ // `webmcp-polyfill.js` chunk from a sibling URL instead. Mirrors how
42
+ // `install.ts` derives `launcher.global.js` from a `jsUrl` override.
43
+ // ---------------------------------------------------------------------------
44
+
45
+ import { setWebMcpPolyfillLoader } from "./webmcp-bridge";
46
+
47
+ // Capture at module-evaluation time — `document.currentScript` is null once
48
+ // execution leaves the script's initial synchronous run.
49
+ const widgetScriptSrc: string | null =
50
+ typeof document !== "undefined"
51
+ ? ((document.currentScript as HTMLScriptElement | null)?.src ?? null)
52
+ : null;
53
+
54
+ setWebMcpPolyfillLoader(() => {
55
+ const chunkUrl = widgetScriptSrc?.replace(
56
+ /index\.global\.js($|\?)/,
57
+ "webmcp-polyfill.js$1",
58
+ );
59
+ if (!chunkUrl || chunkUrl === widgetScriptSrc) {
60
+ return Promise.reject(
61
+ new Error(
62
+ "Could not derive the webmcp-polyfill.js URL from the widget script URL " +
63
+ `(${widgetScriptSrc ?? "unavailable"}). Self-hosted deployments that ` +
64
+ "rename index.global.js should install @mcp-b/webmcp-polyfill on the " +
65
+ "page themselves before enabling config.webmcp.",
66
+ ),
67
+ );
68
+ }
69
+ // Runtime-only dynamic import; the specifier is a computed URL, so esbuild
70
+ // leaves it untouched (and must not try to bundle it).
71
+ return import(/* @vite-ignore */ chunkUrl);
72
+ });
@@ -1,6 +1,6 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
- import { afterEach, describe, expect, it } from "vitest";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
4
 
5
5
  import { createWidgetHostLayout } from "./host-layout";
6
6
 
@@ -194,6 +194,161 @@ describe("createWidgetHostLayout docked", () => {
194
194
  layout.destroy();
195
195
  });
196
196
 
197
+ it("clamps the dock slot to the viewport guard and pins resize/emerge sticky", () => {
198
+ for (const reveal of ["resize", "emerge"] as const) {
199
+ const parent = document.createElement("div");
200
+ document.body.appendChild(parent);
201
+ const target = document.createElement("div");
202
+ parent.appendChild(target);
203
+
204
+ const layout = createWidgetHostLayout(target, {
205
+ launcher: {
206
+ mountMode: "docked",
207
+ autoExpand: false,
208
+ dock: { width: "320px", reveal },
209
+ },
210
+ });
211
+
212
+ const dockSlot = layout.shell?.querySelector<HTMLElement>('[data-persona-dock-role="panel"]');
213
+ expect(dockSlot?.style.maxHeight, reveal).not.toBe("");
214
+ expect(dockSlot?.style.position, reveal).toBe("sticky");
215
+ expect(dockSlot?.style.top, reveal).toBe("0px");
216
+
217
+ layout.destroy();
218
+ document.body.innerHTML = "";
219
+ }
220
+ });
221
+
222
+ it("clamps push and overlay dock slots without sticky (transform/absolute contexts)", () => {
223
+ // push: the slot lives inside the translated track, where a transformed
224
+ // ancestor defeats sticky — cap only. overlay: keeps absolute positioning.
225
+ const cases = [
226
+ { reveal: "push" as const, position: "relative" },
227
+ { reveal: "overlay" as const, position: "absolute" },
228
+ ];
229
+ for (const { reveal, position } of cases) {
230
+ const parent = document.createElement("div");
231
+ document.body.appendChild(parent);
232
+ const target = document.createElement("div");
233
+ parent.appendChild(target);
234
+
235
+ const layout = createWidgetHostLayout(target, {
236
+ launcher: {
237
+ mountMode: "docked",
238
+ autoExpand: false,
239
+ dock: { width: "320px", reveal },
240
+ },
241
+ });
242
+
243
+ const dockSlot = layout.shell?.querySelector<HTMLElement>('[data-persona-dock-role="panel"]');
244
+ expect(dockSlot?.style.maxHeight, reveal).not.toBe("");
245
+ expect(dockSlot?.style.position, reveal).toBe(position);
246
+
247
+ layout.destroy();
248
+ document.body.innerHTML = "";
249
+ }
250
+ });
251
+
252
+ it("honors a custom dock.maxHeight and the false opt-out", () => {
253
+ const parent = document.createElement("div");
254
+ document.body.appendChild(parent);
255
+ const target = document.createElement("div");
256
+ parent.appendChild(target);
257
+
258
+ const dockConfig = {
259
+ mountMode: "docked" as const,
260
+ autoExpand: false,
261
+ dock: { width: "320px", maxHeight: "600px" },
262
+ };
263
+ const layout = createWidgetHostLayout(target, { launcher: dockConfig });
264
+
265
+ const dockSlot = layout.shell?.querySelector<HTMLElement>('[data-persona-dock-role="panel"]');
266
+ expect(dockSlot?.style.maxHeight).toBe("600px");
267
+
268
+ layout.updateConfig({
269
+ launcher: { ...dockConfig, dock: { width: "320px", maxHeight: false } },
270
+ });
271
+ expect(dockSlot?.style.maxHeight).toBe("");
272
+ expect(dockSlot?.style.position).toBe("relative");
273
+ expect(dockSlot?.style.top).toBe("");
274
+
275
+ layout.destroy();
276
+ });
277
+
278
+ describe("height-chain warning", () => {
279
+ const withOffsetHeight = (impl: (el: HTMLElement) => number, fn: () => void): void => {
280
+ const original = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight");
281
+ Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
282
+ configurable: true,
283
+ get(this: HTMLElement) {
284
+ return impl(this);
285
+ },
286
+ });
287
+ try {
288
+ fn();
289
+ } finally {
290
+ if (original) {
291
+ Object.defineProperty(HTMLElement.prototype, "offsetHeight", original);
292
+ }
293
+ }
294
+ };
295
+
296
+ const mountDocked = () => {
297
+ const parent = document.createElement("div");
298
+ document.body.appendChild(parent);
299
+ const target = document.createElement("div");
300
+ parent.appendChild(target);
301
+ return createWidgetHostLayout(target, {
302
+ launcher: {
303
+ mountMode: "docked",
304
+ autoExpand: true,
305
+ dock: { width: "320px" },
306
+ },
307
+ });
308
+ };
309
+
310
+ afterEach(() => {
311
+ vi.restoreAllMocks();
312
+ });
313
+
314
+ it("warns once when a percentage height does not resolve against the shell's parent", () => {
315
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
316
+ // Fixed-height probe measures (environment works); 100% probe collapses
317
+ // to 0 (no ancestor provides a definite height).
318
+ withOffsetHeight((el) => (el.style.height === "1px" ? 1 : 0), () => {
319
+ const layout = mountDocked();
320
+ layout.syncWidgetState({ open: false, launcherEnabled: true });
321
+ layout.syncWidgetState({ open: true, launcherEnabled: true });
322
+ layout.destroy();
323
+ });
324
+ const heightWarnings = warn.mock.calls.filter((c) =>
325
+ String(c[0]).includes("definite height")
326
+ );
327
+ expect(heightWarnings).toHaveLength(1);
328
+ expect(String(heightWarnings[0][0])).toContain("100dvh");
329
+ });
330
+
331
+ it("does not warn when the height chain resolves", () => {
332
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
333
+ withOffsetHeight(() => 1, () => {
334
+ const layout = mountDocked();
335
+ layout.destroy();
336
+ });
337
+ expect(
338
+ warn.mock.calls.filter((c) => String(c[0]).includes("definite height"))
339
+ ).toHaveLength(0);
340
+ });
341
+
342
+ it("does not warn when the environment cannot measure layout (jsdom default)", () => {
343
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
344
+ const layout = mountDocked();
345
+ layout.destroy();
346
+ expect(
347
+ warn.mock.calls.filter((c) => String(c[0]).includes("definite height"))
348
+ ).toHaveLength(0);
349
+ });
350
+ });
351
+
197
352
  const withInnerWidth = (width: number, fn: () => void): void => {
198
353
  const prev = window.innerWidth;
199
354
  try {
@@ -277,7 +432,7 @@ describe("createWidgetHostLayout docked", () => {
277
432
 
278
433
  const dockSlot = layout.shell?.querySelector<HTMLElement>('[data-persona-dock-role="panel"]');
279
434
  layout.syncWidgetState({ open: true, launcherEnabled: true });
280
- expect(dockSlot?.style.position).toBe("relative");
435
+ expect(dockSlot?.style.position).toBe("sticky");
281
436
  expect(layout.shell?.dataset.personaDockMobileFullscreen).toBeUndefined();
282
437
 
283
438
  layout.destroy();
@@ -325,7 +480,7 @@ describe("createWidgetHostLayout docked", () => {
325
480
 
326
481
  const dockSlot = layout.shell?.querySelector<HTMLElement>('[data-persona-dock-role="panel"]');
327
482
  layout.syncWidgetState({ open: false, launcherEnabled: true });
328
- expect(dockSlot?.style.position).toBe("relative");
483
+ expect(dockSlot?.style.position).toBe("sticky");
329
484
 
330
485
  layout.destroy();
331
486
  });
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  AgentWidgetConfig,
3
+ AgentWidgetDockConfig,
3
4
  AgentWidgetStateSnapshot,
4
5
  } from "../types";
5
6
  import { isDockedMountMode, resolveDockConfig } from "../utils/dock";
@@ -26,6 +27,83 @@ const parseDockWidthToPx = (width: string, shellClientWidth: number): number =>
26
27
  return 420;
27
28
  };
28
29
 
30
+ /**
31
+ * Viewport-overflow guard. The docked shell sizes itself with `height: 100%`,
32
+ * which only resolves when an ancestor provides a definite height; without one
33
+ * the dock column would grow with the conversation and scroll off the page.
34
+ * Clamping the slot keeps the panel viewport-sized (messages scroll
35
+ * internally) even when the page's height chain is missing. `100vh` is set
36
+ * first as a fallback for engines without `dvh` support — an invalid value
37
+ * leaves the previous one in place.
38
+ */
39
+ const applyDockSlotMaxHeight = (
40
+ dockSlot: HTMLElement,
41
+ maxHeight: string | false
42
+ ): void => {
43
+ if (maxHeight === false) {
44
+ dockSlot.style.maxHeight = "";
45
+ return;
46
+ }
47
+ dockSlot.style.maxHeight = "100vh";
48
+ dockSlot.style.maxHeight = maxHeight;
49
+ };
50
+
51
+ /**
52
+ * Sticky keeps the resize/emerge dock column pinned to the top of the viewport
53
+ * when the surrounding page is taller than the screen (e.g. a missing height
54
+ * chain or a deliberately scrolling page). With a properly sized shell it
55
+ * behaves exactly like the previous `position: relative`. Not used for push:
56
+ * there the slot lives inside the translated push track, and that transformed
57
+ * ancestor defeats sticky (verified in Chromium) — push gets the max-height
58
+ * cap only, like overlay.
59
+ */
60
+ const applyInFlowDockSlotPosition = (
61
+ dockSlot: HTMLElement,
62
+ maxHeight: string | false
63
+ ): void => {
64
+ if (maxHeight === false) {
65
+ dockSlot.style.position = "relative";
66
+ dockSlot.style.top = "";
67
+ } else {
68
+ dockSlot.style.position = "sticky";
69
+ dockSlot.style.top = "0";
70
+ }
71
+ };
72
+
73
+ /**
74
+ * Warns once per docked mount when no ancestor of the dock target provides a
75
+ * definite height, which is the common misconfiguration behind a dock panel
76
+ * that grows past the viewport. Probes with a throwaway `height: 100%` child
77
+ * of the shell's parent; a fixed-height reference probe first checks the
78
+ * environment can measure at all (display: none subtrees and jsdom measure
79
+ * everything as 0, and must not produce a false warning).
80
+ */
81
+ const warnIfDockHeightChainUnresolved = (
82
+ shell: HTMLElement,
83
+ dock: Required<AgentWidgetDockConfig>
84
+ ): void => {
85
+ const parent = shell.parentElement;
86
+ if (!parent) return;
87
+ const probe = shell.ownerDocument.createElement("div");
88
+ probe.style.cssText =
89
+ "width:0;height:1px;margin:0;padding:0;border:0;visibility:hidden;";
90
+ parent.appendChild(probe);
91
+ const measurable = probe.offsetHeight > 0;
92
+ probe.style.height = "100%";
93
+ const resolved = probe.offsetHeight > 0;
94
+ probe.remove();
95
+ if (!measurable || resolved) return;
96
+ console.warn(
97
+ "[AgentWidget] Docked mode: no ancestor of the dock target provides a definite height, " +
98
+ "so the dock panel cannot size to your layout." +
99
+ (dock.maxHeight === false
100
+ ? " The viewport guard is disabled (dock.maxHeight: false), so the panel will grow with the conversation and overflow the viewport."
101
+ : ` Falling back to clamping the panel to ${dock.maxHeight} (configurable via launcher.dock.maxHeight).`) +
102
+ " To size the panel from your layout instead, give the height chain a definite height " +
103
+ "(e.g. `html, body { height: 100% }`) down to the dock target's parent."
104
+ );
105
+ };
106
+
29
107
  const setDirectHostStyles = (host: HTMLElement, config?: AgentWidgetConfig): void => {
30
108
  const launcherEnabled = config?.launcher?.enabled ?? true;
31
109
  host.className = "persona-host";
@@ -53,6 +131,7 @@ const clearMobileFullscreenDockSlotStyles = (dockSlot: HTMLElement): void => {
53
131
  dockSlot.style.width = "";
54
132
  dockSlot.style.height = "";
55
133
  dockSlot.style.maxWidth = "";
134
+ dockSlot.style.maxHeight = "";
56
135
  dockSlot.style.minWidth = "";
57
136
  clearOverlayDockSlotStyles(dockSlot);
58
137
  };
@@ -227,6 +306,7 @@ const applyDockStyles = (
227
306
 
228
307
  shell.removeAttribute("data-persona-dock-mobile-fullscreen");
229
308
  clearMobileFullscreenDockSlotStyles(dockSlot);
309
+ applyDockSlotMaxHeight(dockSlot, dock.maxHeight);
230
310
 
231
311
  if (dock.reveal === "overlay") {
232
312
  shell.style.display = "flex";
@@ -315,6 +395,7 @@ const applyDockStyles = (
315
395
  dockSlot.style.minWidth = dock.width;
316
396
  dockSlot.style.maxWidth = dock.width;
317
397
  dockSlot.style.position = "relative";
398
+ dockSlot.style.top = "";
318
399
  dockSlot.style.overflow = "hidden";
319
400
  dockSlot.style.transition = "none";
320
401
  dockSlot.style.pointerEvents = expanded ? "auto" : "none";
@@ -348,7 +429,7 @@ const applyDockStyles = (
348
429
  dockSlot.style.maxWidth = width;
349
430
  dockSlot.style.minWidth = width;
350
431
  dockSlot.style.minHeight = "0";
351
- dockSlot.style.position = "relative";
432
+ applyInFlowDockSlotPosition(dockSlot, dock.maxHeight);
352
433
  dockSlot.style.overflow =
353
434
  isEmerge ? "hidden" : collapsedClosed ? "hidden" : "visible";
354
435
  dockSlot.style.transition = dockTransition;
@@ -429,9 +510,22 @@ const createDockedLayout = (target: HTMLElement, config?: AgentWidgetConfig): Wi
429
510
  resizeObserver.observe(shell);
430
511
  };
431
512
 
513
+ let heightChainChecked = false;
514
+
432
515
  const layout = (): void => {
433
516
  applyDockStyles(shell, pushTrack, contentSlot, dockSlot, host, config, expanded);
434
517
  syncPushResizeObserver();
518
+ // Check the height chain once, the first time the panel is actually shown
519
+ // in a layout that depends on it (mobile fullscreen is fixed-position and
520
+ // doesn't — keep checking until a dependent layout comes around).
521
+ if (
522
+ expanded &&
523
+ !heightChainChecked &&
524
+ shell.dataset.personaDockMobileFullscreen !== "true"
525
+ ) {
526
+ heightChainChecked = true;
527
+ warnIfDockHeightChainUnresolved(shell, resolveDockConfig(config));
528
+ }
435
529
  };
436
530
 
437
531
  const ownerWindow = shell.ownerDocument.defaultView;