@runtypelabs/persona 4.6.1 → 4.8.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 (120) hide show
  1. package/dist/animations/glyph-cycle.cjs +2 -2
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/glyph-cycle.js +2 -2
  5. package/dist/animations/{types-CSmiKRVa.d.cts → types-BsZtXPKK.d.cts} +43 -3
  6. package/dist/animations/{types-CSmiKRVa.d.ts → types-BsZtXPKK.d.ts} +43 -3
  7. package/dist/animations/wipe.cjs +2 -2
  8. package/dist/animations/wipe.d.cts +1 -1
  9. package/dist/animations/wipe.d.ts +1 -1
  10. package/dist/chunk-5EIIHQLQ.js +1 -0
  11. package/dist/codegen.cjs +12 -12
  12. package/dist/codegen.js +14 -14
  13. package/dist/index.cjs +91 -68
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +918 -174
  16. package/dist/index.d.ts +918 -174
  17. package/dist/index.global.js +81 -57
  18. package/dist/index.global.js.map +1 -1
  19. package/dist/index.js +89 -66
  20. package/dist/index.js.map +1 -1
  21. package/dist/install.global.js +1 -1
  22. package/dist/install.global.js.map +1 -1
  23. package/dist/launcher.global.js +3 -2
  24. package/dist/launcher.global.js.map +1 -1
  25. package/dist/markdown-parsers.js +24 -24
  26. package/dist/plugin-kit.cjs +1 -1
  27. package/dist/plugin-kit.js +1 -1
  28. package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
  29. package/dist/runtype-tts.js +1 -1
  30. package/dist/session-reconnect-JKIJBHS5.js +1 -0
  31. package/dist/smart-dom-reader.cjs +17 -17
  32. package/dist/smart-dom-reader.d.cts +753 -15
  33. package/dist/smart-dom-reader.d.ts +753 -15
  34. package/dist/smart-dom-reader.js +17 -17
  35. package/dist/testing.cjs +3 -3
  36. package/dist/testing.js +3 -3
  37. package/dist/theme-editor-preview.cjs +81 -58
  38. package/dist/theme-editor-preview.d.cts +761 -15
  39. package/dist/theme-editor-preview.d.ts +761 -15
  40. package/dist/theme-editor-preview.js +81 -58
  41. package/dist/theme-editor.cjs +6 -6
  42. package/dist/theme-editor.d.cts +753 -15
  43. package/dist/theme-editor.d.ts +753 -15
  44. package/dist/theme-editor.js +10 -10
  45. package/dist/theme-reference.cjs +1 -1
  46. package/dist/theme-reference.d.cts +74 -0
  47. package/dist/theme-reference.d.ts +74 -0
  48. package/dist/theme-reference.js +1 -1
  49. package/dist/voice-worklet-player.cjs +2 -2
  50. package/dist/voice-worklet-player.js +2 -2
  51. package/dist/webmcp-polyfill.js +2 -2
  52. package/dist/widget.css +1 -1
  53. package/package.json +2 -3
  54. package/src/artifacts-session.test.ts +178 -0
  55. package/src/client.test.ts +500 -1
  56. package/src/client.ts +285 -93
  57. package/src/components/artifact-card.test.ts +333 -0
  58. package/src/components/artifact-card.ts +75 -28
  59. package/src/components/artifact-inline.test.ts +1328 -0
  60. package/src/components/artifact-inline.ts +920 -0
  61. package/src/components/artifact-pane.test.ts +1042 -0
  62. package/src/components/artifact-pane.ts +440 -131
  63. package/src/components/artifact-preview.test.ts +1155 -0
  64. package/src/components/artifact-preview.ts +994 -0
  65. package/src/components/pill-composer-builder.test.ts +6 -2
  66. package/src/components/pill-composer-builder.ts +6 -6
  67. package/src/components/reasoning-bubble.ts +1 -13
  68. package/src/components/registry.ts +38 -3
  69. package/src/components/tool-bubble.ts +1 -13
  70. package/src/defaults.ts +1 -0
  71. package/src/generated/runtype-openapi-contract.ts +55 -3
  72. package/src/index-core.ts +20 -1
  73. package/src/index.ts +8 -0
  74. package/src/markdown-parsers-loader.test.ts +158 -0
  75. package/src/markdown-parsers-loader.ts +74 -9
  76. package/src/runtime/host-layout.test.ts +163 -0
  77. package/src/runtime/host-layout.ts +110 -7
  78. package/src/runtime/init.ts +18 -61
  79. package/src/runtime/persist-state.test.ts +118 -0
  80. package/src/session.ts +76 -22
  81. package/src/styles/widget.css +773 -26
  82. package/src/theme-editor/preview.ts +2 -0
  83. package/src/theme-editor/sections.test.ts +26 -1
  84. package/src/theme-editor/sections.ts +10 -2
  85. package/src/theme-reference.ts +2 -2
  86. package/src/tool-call-display-defaults.test.ts +1 -0
  87. package/src/types/theme.ts +77 -0
  88. package/src/types.ts +516 -17
  89. package/src/ui.artifact-pane-gating.test.ts +636 -0
  90. package/src/ui.component-directive.test.ts +104 -0
  91. package/src/ui.composer-bar.test.ts +60 -2
  92. package/src/ui.detached-panel.test.ts +1049 -0
  93. package/src/ui.scroll-additive.test.ts +1 -1
  94. package/src/ui.streaming-coalescing.test.ts +312 -0
  95. package/src/ui.tool-display.test.ts +51 -0
  96. package/src/ui.ts +933 -214
  97. package/src/utils/artifact-custom-actions.ts +128 -0
  98. package/src/utils/artifact-display.test.ts +42 -0
  99. package/src/utils/artifact-display.ts +84 -0
  100. package/src/utils/artifact-file.test.ts +116 -0
  101. package/src/utils/artifact-file.ts +117 -0
  102. package/src/utils/artifact-gate.test.ts +112 -5
  103. package/src/utils/artifact-gate.ts +39 -14
  104. package/src/utils/artifact-loading-status.ts +55 -0
  105. package/src/utils/artifact-status-label.ts +190 -0
  106. package/src/utils/buttons.ts +7 -1
  107. package/src/utils/code-highlight.test.ts +186 -0
  108. package/src/utils/code-highlight.ts +400 -0
  109. package/src/utils/icons.ts +2 -0
  110. package/src/utils/roving-tablist.test.ts +152 -0
  111. package/src/utils/roving-tablist.ts +111 -0
  112. package/src/utils/spinner.ts +45 -0
  113. package/src/utils/theme.test.ts +48 -0
  114. package/src/utils/theme.ts +7 -0
  115. package/src/utils/tokens.ts +91 -0
  116. package/src/utils/tool-loading-animation.test.ts +32 -0
  117. package/src/utils/tool-loading-animation.ts +24 -0
  118. package/dist/chunk-DFBSCFYN.js +0 -1
  119. package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
  120. package/dist/session-reconnect-U77QFUR7.js +0 -1
@@ -527,4 +527,167 @@ describe("createWidgetHostLayout docked", () => {
527
527
  layout.destroy();
528
528
  });
529
529
  });
530
+
531
+ describe("detached docked", () => {
532
+ it("leaves the dock slot flush when detachedPanel is unset", () => {
533
+ const parent = document.createElement("div");
534
+ document.body.appendChild(parent);
535
+ const target = document.createElement("div");
536
+ parent.appendChild(target);
537
+
538
+ const layout = createWidgetHostLayout(target, {
539
+ launcher: {
540
+ mountMode: "docked",
541
+ autoExpand: true,
542
+ dock: { width: "320px" },
543
+ },
544
+ });
545
+
546
+ const dockSlot = layout.shell?.querySelector<HTMLElement>('[data-persona-dock-role="panel"]');
547
+ expect(dockSlot?.style.padding).toBe("");
548
+ expect(dockSlot?.style.background).toBe("");
549
+
550
+ layout.destroy();
551
+ });
552
+
553
+ it("pads the dock slot with the default inset and canvas when detached", () => {
554
+ const parent = document.createElement("div");
555
+ document.body.appendChild(parent);
556
+ const target = document.createElement("div");
557
+ parent.appendChild(target);
558
+
559
+ const layout = createWidgetHostLayout(target, {
560
+ launcher: {
561
+ mountMode: "docked",
562
+ autoExpand: true,
563
+ detachedPanel: true,
564
+ dock: { width: "320px" },
565
+ },
566
+ });
567
+
568
+ const dockSlot = layout.shell?.querySelector<HTMLElement>('[data-persona-dock-role="panel"]');
569
+ expect(dockSlot?.style.padding).toBe("16px");
570
+ expect(dockSlot?.style.background).toBe("transparent");
571
+ expect(dockSlot?.style.boxSizing).toBe("border-box");
572
+
573
+ layout.destroy();
574
+ });
575
+
576
+ it("resolves detached inset and canvas from panel theme tokens", () => {
577
+ const parent = document.createElement("div");
578
+ document.body.appendChild(parent);
579
+ const target = document.createElement("div");
580
+ parent.appendChild(target);
581
+
582
+ const layout = createWidgetHostLayout(target, {
583
+ launcher: {
584
+ mountMode: "docked",
585
+ autoExpand: true,
586
+ detachedPanel: true,
587
+ dock: { width: "320px" },
588
+ },
589
+ theme: {
590
+ components: { panel: { inset: "24px", canvasBackground: "#eee" } },
591
+ },
592
+ });
593
+
594
+ const dockSlot = layout.shell?.querySelector<HTMLElement>('[data-persona-dock-role="panel"]');
595
+ expect(dockSlot?.style.padding).toBe("24px");
596
+ expect(dockSlot?.style.background).toBe("rgb(238, 238, 238)");
597
+
598
+ layout.destroy();
599
+ });
600
+
601
+ it("shrinks the emerge host by the inset so it fits the padded slot", () => {
602
+ const parent = document.createElement("div");
603
+ document.body.appendChild(parent);
604
+ const target = document.createElement("div");
605
+ parent.appendChild(target);
606
+
607
+ const layout = createWidgetHostLayout(target, {
608
+ launcher: {
609
+ mountMode: "docked",
610
+ autoExpand: true,
611
+ detachedPanel: true,
612
+ dock: { width: "320px", reveal: "emerge" },
613
+ },
614
+ });
615
+
616
+ const host = layout.shell?.querySelector<HTMLElement>('[data-persona-dock-role="host"]');
617
+ // jsdom simplifies the calc expression (320px - 2 * 16px).
618
+ expect(host?.style.width).toBe("calc(288px)");
619
+
620
+ layout.destroy();
621
+ });
622
+
623
+ it("resolves a percentage emerge width to px before subtracting the inset", () => {
624
+ const parent = document.createElement("div");
625
+ document.body.appendChild(parent);
626
+ const target = document.createElement("div");
627
+ parent.appendChild(target);
628
+
629
+ const layout = createWidgetHostLayout(target, {
630
+ launcher: {
631
+ mountMode: "docked",
632
+ autoExpand: true,
633
+ detachedPanel: true,
634
+ dock: { width: "50%", reveal: "emerge" },
635
+ },
636
+ });
637
+ const shell = layout.shell!;
638
+ Object.defineProperty(shell, "clientWidth", { get: () => 800, configurable: true });
639
+ layout.updateConfig({
640
+ launcher: {
641
+ mountMode: "docked",
642
+ autoExpand: true,
643
+ detachedPanel: true,
644
+ dock: { width: "50%", reveal: "emerge" },
645
+ },
646
+ });
647
+
648
+ const host = shell.querySelector<HTMLElement>('[data-persona-dock-role="host"]');
649
+ // 50% of 800px resolved to px, then jsdom simplifies (400px - 2 * 16px).
650
+ expect(host?.style.width).toBe("calc(368px)");
651
+
652
+ layout.destroy();
653
+ });
654
+
655
+ it("clears detached chrome and pads nothing while the panel is collapsed", () => {
656
+ for (const reveal of ["resize", "emerge"] as const) {
657
+ const parent = document.createElement("div");
658
+ document.body.appendChild(parent);
659
+ const target = document.createElement("div");
660
+ parent.appendChild(target);
661
+
662
+ const layout = createWidgetHostLayout(target, {
663
+ launcher: {
664
+ mountMode: "docked",
665
+ autoExpand: false,
666
+ detachedPanel: true,
667
+ dock: { width: "320px", reveal },
668
+ },
669
+ });
670
+
671
+ const dockSlot = layout.shell?.querySelector<HTMLElement>('[data-persona-dock-role="panel"]');
672
+ // Collapsed: truly 0-width slot with no padding/background/box-sizing residue.
673
+ expect(dockSlot?.style.width, reveal).toBe("0px");
674
+ expect(dockSlot?.style.padding, reveal).toBe("");
675
+ expect(dockSlot?.style.background, reveal).toBe("");
676
+ expect(dockSlot?.style.boxSizing, reveal).toBe("");
677
+ // Padding rides the width transition so the canvas gutter shrinks with the column.
678
+ expect(dockSlot?.style.transition, reveal).toContain("padding");
679
+
680
+ // Opening restores the chrome; closing clears it again.
681
+ layout.syncWidgetState({ open: true, launcherEnabled: true });
682
+ expect(dockSlot?.style.padding, reveal).toBe("16px");
683
+ expect(dockSlot?.style.boxSizing, reveal).toBe("border-box");
684
+ layout.syncWidgetState({ open: false, launcherEnabled: true });
685
+ expect(dockSlot?.style.padding, reveal).toBe("");
686
+ expect(dockSlot?.style.boxSizing, reveal).toBe("");
687
+
688
+ layout.destroy();
689
+ document.body.innerHTML = "";
690
+ }
691
+ });
692
+ });
530
693
  });
@@ -5,6 +5,12 @@ import type {
5
5
  } from "../types";
6
6
  import { isDockedMountMode, resolveDockConfig } from "../utils/dock";
7
7
  import { DEFAULT_OVERLAY_Z_INDEX } from "../utils/constants";
8
+ import { createThemeObserver, getActiveTheme } from "../utils/theme";
9
+ import {
10
+ DEFAULT_PANEL_CANVAS_BACKGROUND,
11
+ DEFAULT_PANEL_INSET,
12
+ resolveTokenValue,
13
+ } from "../utils/tokens";
8
14
 
9
15
  export type WidgetHostLayoutMode = "direct" | "docked";
10
16
 
@@ -208,6 +214,51 @@ const orderDockChildren = (
208
214
  }
209
215
  };
210
216
 
217
+ /**
218
+ * Detached inset + canvas literals for the dockSlot. The panel CSS vars are set
219
+ * inline on the widget root inside the slot, so they cannot cascade up to the
220
+ * slot; resolve the token values here and apply literals.
221
+ */
222
+ const resolveDetachedSlotStyles = (
223
+ config: AgentWidgetConfig | undefined
224
+ ): { inset: string; canvasBackground: string } => {
225
+ const theme = getActiveTheme(config);
226
+ const panel = theme.components?.panel;
227
+ const resolve = (raw: string | undefined, fallback: string): string => {
228
+ if (raw == null || raw === "") return fallback;
229
+ return resolveTokenValue(theme, raw) ?? raw;
230
+ };
231
+ return {
232
+ inset: resolve(panel?.inset, DEFAULT_PANEL_INSET),
233
+ canvasBackground: resolve(panel?.canvasBackground, DEFAULT_PANEL_CANVAS_BACKGROUND),
234
+ };
235
+ };
236
+
237
+ type DetachedSlotStyles = { inset: string; canvasBackground: string };
238
+
239
+ /**
240
+ * Detached docked mode pads the slot so the canvas shows around the panel card.
241
+ * Padding, never margin, keeps parseDockWidthToPx push math truthful; box-sizing
242
+ * border-box keeps the slot outer width (and max-height) inclusive of the
243
+ * padding so the panel shrinks inside rather than overflowing.
244
+ */
245
+ const applyDetachedDockSlotChrome = (
246
+ dockSlot: HTMLElement,
247
+ detached: boolean,
248
+ inset: string,
249
+ canvasBackground: string
250
+ ): void => {
251
+ if (!detached) {
252
+ dockSlot.style.padding = "";
253
+ dockSlot.style.background = "";
254
+ dockSlot.style.boxSizing = "";
255
+ return;
256
+ }
257
+ dockSlot.style.boxSizing = "border-box";
258
+ dockSlot.style.padding = inset;
259
+ dockSlot.style.background = canvasBackground;
260
+ };
261
+
211
262
  const applyDockStyles = (
212
263
  shell: HTMLElement,
213
264
  pushTrack: HTMLElement,
@@ -215,10 +266,16 @@ const applyDockStyles = (
215
266
  dockSlot: HTMLElement,
216
267
  host: HTMLElement,
217
268
  config: AgentWidgetConfig | undefined,
218
- expanded: boolean
269
+ expanded: boolean,
270
+ // Cached slot styles; null when not detached. Resolved once per config/scheme
271
+ // change so getActiveTheme never runs on the resize/ResizeObserver path.
272
+ detachedSlotStyles: DetachedSlotStyles | null
219
273
  ): void => {
220
274
  const dock = resolveDockConfig(config);
221
275
  const usePush = dock.reveal === "push";
276
+ const detached = detachedSlotStyles != null;
277
+ const detachedInset = detachedSlotStyles?.inset ?? "";
278
+ const detachedCanvas = detachedSlotStyles?.canvasBackground ?? "";
222
279
 
223
280
  migrateDockChildren(shell, pushTrack, contentSlot, dockSlot, usePush);
224
281
  orderDockChildren(shell, pushTrack, contentSlot, dockSlot, dock.side, usePush);
@@ -260,6 +317,8 @@ const applyDockStyles = (
260
317
  clearMobileFullscreenDockSlotStyles(dockSlot);
261
318
  resetContentSlotFlexSizing(contentSlot);
262
319
  clearEmergeDockStyles(host, dockSlot);
320
+ // Mobile fullscreen is flush regardless of detachedPanel.
321
+ applyDetachedDockSlotChrome(dockSlot, false, "", "");
263
322
 
264
323
  shell.style.display = "flex";
265
324
  shell.style.flexDirection = "column";
@@ -314,6 +373,9 @@ const applyDockStyles = (
314
373
  shell.removeAttribute("data-persona-dock-mobile-fullscreen");
315
374
  clearMobileFullscreenDockSlotStyles(dockSlot);
316
375
  applyDockSlotMaxHeight(dockSlot, dock.maxHeight);
376
+ // Chrome only when open: a collapsed detached slot with padding + border-box
377
+ // floors at 2*inset, painting a permanent canvas strip past the 0px width.
378
+ applyDetachedDockSlotChrome(dockSlot, detached && expanded, detachedInset, detachedCanvas);
317
379
 
318
380
  if (dock.reveal === "overlay") {
319
381
  shell.style.display = "flex";
@@ -435,8 +497,10 @@ const applyDockStyles = (
435
497
  }
436
498
 
437
499
  const width = expanded ? dock.width : "0px";
500
+ // Detached collapse snaps padding off as width animates to 0; animate padding
501
+ // alongside so the canvas gutter shrinks with the column.
438
502
  const dockTransition = dock.animate
439
- ? "width 180ms ease, min-width 180ms ease, max-width 180ms ease, flex-basis 180ms ease"
503
+ ? `width 180ms ease, min-width 180ms ease, max-width 180ms ease, flex-basis 180ms ease${detached ? ", padding 180ms ease" : ""}`
440
504
  : "none";
441
505
  const collapsedClosed = !expanded;
442
506
 
@@ -454,9 +518,17 @@ const applyDockStyles = (
454
518
 
455
519
  if (isEmerge) {
456
520
  dockSlot.style.alignItems = dock.side === "right" ? "flex-start" : "flex-end";
457
- host.style.width = dock.width;
458
- host.style.minWidth = dock.width;
459
- host.style.maxWidth = dock.width;
521
+ // Emerge pins the host to a fixed width and reveals it as the slot grows.
522
+ // When detached, shrink that fixed width by the padding so the host fits
523
+ // the padded slot content-box instead of overflowing one side. Resolve a
524
+ // percentage dock.width to px first: a raw % re-resolves against the
525
+ // padding-reduced content box and un-pins as the slot animates open.
526
+ const emergeHostWidth = detached
527
+ ? `calc(${parseDockWidthToPx(dock.width, shell.clientWidth)}px - (2 * ${detachedInset}))`
528
+ : dock.width;
529
+ host.style.width = emergeHostWidth;
530
+ host.style.minWidth = emergeHostWidth;
531
+ host.style.maxWidth = emergeHostWidth;
460
532
  host.style.boxSizing = "border-box";
461
533
  }
462
534
  }
@@ -518,12 +590,38 @@ const createDockedLayout = (target: HTMLElement, config?: AgentWidgetConfig): Wi
518
590
  resizeObserver = null;
519
591
  };
520
592
 
593
+ // Resolved once per config/scheme change; the resize paths reuse this so
594
+ // getActiveTheme never runs per frame.
595
+ let detachedSlotStyles: DetachedSlotStyles | null = null;
596
+ const refreshDetachedSlotStyles = (): void => {
597
+ detachedSlotStyles =
598
+ config?.launcher?.detachedPanel === true ? resolveDetachedSlotStyles(config) : null;
599
+ };
600
+ refreshDetachedSlotStyles();
601
+
602
+ const reapplyDockStyles = (): void => {
603
+ applyDockStyles(shell, pushTrack, contentSlot, dockSlot, host, config, expanded, detachedSlotStyles);
604
+ };
605
+
606
+ // Scheme-dependent canvas tokens are baked to literals at layout time, so an
607
+ // OS dark-mode flip leaves a stale canvas until we recompute and re-apply.
608
+ let cleanupSchemeObserver: (() => void) | null = null;
609
+ const syncSchemeObserver = (): void => {
610
+ cleanupSchemeObserver?.();
611
+ cleanupSchemeObserver = null;
612
+ if (config?.launcher?.detachedPanel !== true || config?.colorScheme !== "auto") return;
613
+ cleanupSchemeObserver = createThemeObserver(() => {
614
+ refreshDetachedSlotStyles();
615
+ reapplyDockStyles();
616
+ });
617
+ };
618
+
521
619
  const syncPushResizeObserver = (): void => {
522
620
  disconnectResizeObserver();
523
621
  if (resolveDockConfig(config).reveal !== "push") return;
524
622
  if (typeof ResizeObserver === "undefined") return;
525
623
  resizeObserver = new ResizeObserver(() => {
526
- applyDockStyles(shell, pushTrack, contentSlot, dockSlot, host, config, expanded);
624
+ reapplyDockStyles();
527
625
  });
528
626
  resizeObserver.observe(shell);
529
627
  };
@@ -531,7 +629,7 @@ const createDockedLayout = (target: HTMLElement, config?: AgentWidgetConfig): Wi
531
629
  let heightChainChecked = false;
532
630
 
533
631
  const layout = (): void => {
534
- applyDockStyles(shell, pushTrack, contentSlot, dockSlot, host, config, expanded);
632
+ reapplyDockStyles();
535
633
  syncPushResizeObserver();
536
634
  // Check the height chain once, the first time the panel is actually shown
537
635
  // in a layout that depends on it (mobile fullscreen is fixed-position and
@@ -562,6 +660,7 @@ const createDockedLayout = (target: HTMLElement, config?: AgentWidgetConfig): Wi
562
660
  }
563
661
 
564
662
  layout();
663
+ syncSchemeObserver();
565
664
 
566
665
  return {
567
666
  mode: "docked",
@@ -578,10 +677,14 @@ const createDockedLayout = (target: HTMLElement, config?: AgentWidgetConfig): Wi
578
677
  if ((config?.launcher?.enabled ?? true) === false) {
579
678
  expanded = true;
580
679
  }
680
+ refreshDetachedSlotStyles();
581
681
  layout();
682
+ syncSchemeObserver();
582
683
  },
583
684
  destroy() {
584
685
  ownerWindow?.removeEventListener("resize", onViewportResize);
686
+ cleanupSchemeObserver?.();
687
+ cleanupSchemeObserver = null;
585
688
  disconnectResizeObserver();
586
689
  if (originalParent.isConnected) {
587
690
  if (originalNextSibling && originalNextSibling.parentNode === originalParent) {
@@ -19,71 +19,28 @@ const ensureTarget = (target: string | HTMLElement): HTMLElement => {
19
19
  return target;
20
20
  };
21
21
 
22
- const widgetCssHref = (): string | null => {
23
- try {
24
- // This works in ESM builds but not in IIFE builds
25
- if (typeof import.meta !== "undefined" && import.meta.url) {
26
- return new URL("../widget.css", import.meta.url).href;
27
- }
28
- } catch {
29
- // Fallback for IIFE builds where CSS should be loaded separately
30
- }
31
- return null;
32
- };
33
-
22
+ // CSS arrives via the installer's link[data-persona] or a consumer import of
23
+ // @runtypelabs/persona/widget.css; the widget never self-locates its stylesheet.
24
+ // A new URL(..., import.meta.url) here breaks consumer bundlers (webpack
25
+ // resolves the literal at build time), so shadow roots only clone the head link.
34
26
  const mountStyles = (root: ShadowRoot | HTMLElement, ownerDocument: Document) => {
35
- const href = widgetCssHref();
36
-
37
- const adoptExistingStylesheet = () => {
38
- if (!(root instanceof ShadowRoot)) {
39
- return;
40
- }
41
-
42
- if (root.querySelector('link[data-persona]')) {
43
- return;
44
- }
45
-
46
- const globalLink = ownerDocument.head.querySelector<HTMLLinkElement>(
47
- 'link[data-persona]'
48
- );
49
- if (!globalLink) {
50
- return;
51
- }
27
+ if (!(root instanceof ShadowRoot)) {
28
+ return;
29
+ }
52
30
 
53
- const clonedLink = globalLink.cloneNode(true) as HTMLLinkElement;
54
- root.insertBefore(clonedLink, root.firstChild);
55
- };
31
+ if (root.querySelector('link[data-persona]')) {
32
+ return;
33
+ }
56
34
 
57
- if (root instanceof ShadowRoot) {
58
- // For shadow DOM, we need to load CSS into the shadow root
59
- if (href) {
60
- const link = ownerDocument.createElement("link");
61
- link.rel = "stylesheet";
62
- link.href = href;
63
- link.setAttribute("data-persona", "true");
64
- root.insertBefore(link, root.firstChild);
65
- } else {
66
- adoptExistingStylesheet();
67
- }
68
- // If href is null (IIFE build), CSS should already be loaded globally
69
- } else {
70
- // For non-shadow DOM, check if CSS is already loaded
71
- const existing = ownerDocument.head.querySelector<HTMLLinkElement>(
72
- "link[data-persona]"
73
- );
74
- if (!existing) {
75
- if (href) {
76
- // ESM build - load CSS dynamically
77
- const link = ownerDocument.createElement("link");
78
- link.rel = "stylesheet";
79
- link.href = href;
80
- link.setAttribute("data-persona", "true");
81
- ownerDocument.head.appendChild(link);
82
- }
83
- // IIFE build - CSS should be loaded via <link> tag before script
84
- // If not found, we'll assume it's loaded globally or warn in dev
85
- }
35
+ const globalLink = ownerDocument.head.querySelector<HTMLLinkElement>(
36
+ 'link[data-persona]'
37
+ );
38
+ if (!globalLink) {
39
+ return;
86
40
  }
41
+
42
+ const clonedLink = globalLink.cloneNode(true) as HTMLLinkElement;
43
+ root.insertBefore(clonedLink, root.firstChild);
87
44
  };
88
45
 
89
46
  export type AgentWidgetInitHandle = AgentWidgetController & { host: HTMLElement };
@@ -16,6 +16,23 @@ const baseConfig = () => ({
16
16
  const inject = (controller: ReturnType<typeof createAgentExperience>) =>
17
17
  controller.injectAssistantMessage({ content: "hello world" });
18
18
 
19
+ const deferred = () => {
20
+ let resolve!: () => void;
21
+ let reject!: (error: unknown) => void;
22
+ const promise = new Promise<void>((resolvePromise, rejectPromise) => {
23
+ resolve = resolvePromise;
24
+ reject = rejectPromise;
25
+ });
26
+ return { promise, resolve, reject };
27
+ };
28
+
29
+ const flushMicrotasks = async (times = 4) => {
30
+ for (let index = 0; index < times; index += 1) {
31
+ // eslint-disable-next-line no-await-in-loop
32
+ await Promise.resolve();
33
+ }
34
+ };
35
+
19
36
  describe("persistState gates storage adapter", () => {
20
37
  afterEach(() => {
21
38
  document.body.innerHTML = "";
@@ -149,4 +166,105 @@ describe("persistState gates storage adapter", () => {
149
166
  expect(controller.getMessages()).toEqual([]);
150
167
  controller.destroy();
151
168
  });
169
+
170
+ it("serializes async saves and preserves the newest transcript", async () => {
171
+ const mount = document.createElement("div");
172
+ document.body.appendChild(mount);
173
+ const firstSave = deferred();
174
+ const payloads: Array<{ messages?: Array<{ content?: string }> }> = [];
175
+ const save = vi.fn((state: { messages?: Array<{ content?: string }> }) => {
176
+ payloads.push(state);
177
+ return save.mock.calls.length === 1 ? firstSave.promise : Promise.resolve();
178
+ });
179
+ const controller = createAgentExperience(mount, {
180
+ ...baseConfig(),
181
+ storageAdapter: { save },
182
+ });
183
+
184
+ controller.injectAssistantMessage({ content: "first" });
185
+ controller.injectAssistantMessage({ content: "second" });
186
+
187
+ expect(save).toHaveBeenCalledTimes(1);
188
+ firstSave.resolve();
189
+ await flushMicrotasks();
190
+
191
+ expect(save).toHaveBeenCalledTimes(2);
192
+ expect(payloads[1].messages?.map((message) => message.content)).toEqual([
193
+ "first",
194
+ "second",
195
+ ]);
196
+ controller.destroy();
197
+ });
198
+
199
+ it("orders clear after pending saves so stale state cannot be resurrected", async () => {
200
+ const mount = document.createElement("div");
201
+ document.body.appendChild(mount);
202
+ const firstSave = deferred();
203
+ const operations: string[] = [];
204
+ const save = vi.fn(() => {
205
+ operations.push("save");
206
+ return save.mock.calls.length === 1 ? firstSave.promise : Promise.resolve();
207
+ });
208
+ const clear = vi.fn(() => {
209
+ operations.push("clear");
210
+ return Promise.resolve();
211
+ });
212
+ const controller = createAgentExperience(mount, {
213
+ ...baseConfig(),
214
+ storageAdapter: { save, clear },
215
+ });
216
+
217
+ inject(controller);
218
+ controller.clearChat();
219
+
220
+ expect(operations).toEqual(["save"]);
221
+ firstSave.resolve();
222
+ await flushMicrotasks(20);
223
+
224
+ expect(operations).toEqual(["save", "save", "clear"]);
225
+ expect(clear).toHaveBeenCalledTimes(1);
226
+ controller.destroy();
227
+ });
228
+
229
+ it("continues queued storage mutations after an async save rejects", async () => {
230
+ const mount = document.createElement("div");
231
+ document.body.appendChild(mount);
232
+ const firstSave = deferred();
233
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
234
+ const save = vi.fn(() =>
235
+ save.mock.calls.length === 1 ? firstSave.promise : Promise.resolve()
236
+ );
237
+ const controller = createAgentExperience(mount, {
238
+ ...baseConfig(),
239
+ storageAdapter: { save },
240
+ });
241
+
242
+ controller.injectAssistantMessage({ content: "first" });
243
+ controller.injectAssistantMessage({ content: "second" });
244
+ firstSave.reject(new Error("storage unavailable"));
245
+ await flushMicrotasks(8);
246
+
247
+ expect(save).toHaveBeenCalledTimes(2);
248
+ expect(errorSpy).toHaveBeenCalledWith(
249
+ "[AgentWidget] Failed to persist state:",
250
+ expect.any(Error)
251
+ );
252
+ errorSpy.mockRestore();
253
+ controller.destroy();
254
+ });
255
+
256
+ it("keeps synchronous adapters synchronous", () => {
257
+ const mount = document.createElement("div");
258
+ document.body.appendChild(mount);
259
+ const save = vi.fn();
260
+ const controller = createAgentExperience(mount, {
261
+ ...baseConfig(),
262
+ storageAdapter: { save },
263
+ });
264
+
265
+ inject(controller);
266
+
267
+ expect(save).toHaveBeenCalledTimes(1);
268
+ controller.destroy();
269
+ });
152
270
  });