@runtypelabs/persona 4.17.0 → 4.18.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 (165) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/{types-BpWGm7_6.d.cts → types-DsmTD8TO.d.cts} +12 -0
  4. package/dist/animations/{types-BpWGm7_6.d.ts → types-DsmTD8TO.d.ts} +12 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/chunk-3I2S3I5P.js +1 -0
  8. package/dist/chunk-76IELU7F.js +1 -0
  9. package/dist/chunk-EFJBUH5F.js +1 -0
  10. package/dist/chunk-PDPVORUF.js +1 -0
  11. package/dist/chunk-Q735PSZL.js +1 -0
  12. package/dist/chunk-VK6JOIYJ.js +1 -0
  13. package/dist/codegen.cjs +4 -4
  14. package/dist/codegen.js +4 -4
  15. package/dist/{context-mentions-BTSTIBFC.js → context-mentions-MJL6S6P2.js} +1 -1
  16. package/dist/context-mentions.cjs +9 -8
  17. package/dist/context-mentions.d.cts +1224 -14
  18. package/dist/context-mentions.d.ts +1224 -14
  19. package/dist/context-mentions.js +9 -8
  20. package/dist/event-stream-view-4EZ7BGFK.js +1 -0
  21. package/dist/event-stream-view.cjs +129 -0
  22. package/dist/event-stream-view.d.cts +8384 -0
  23. package/dist/event-stream-view.d.ts +8384 -0
  24. package/dist/event-stream-view.js +129 -0
  25. package/dist/history-view-BD6OXLNA.js +898 -0
  26. package/dist/history-view.cjs +898 -0
  27. package/dist/history-view.d.cts +1461 -0
  28. package/dist/history-view.d.ts +1461 -0
  29. package/dist/history-view.js +898 -0
  30. package/dist/index.cjs +77 -77
  31. package/dist/index.cjs.map +1 -1
  32. package/dist/index.d.cts +2040 -21
  33. package/dist/index.d.ts +2040 -21
  34. package/dist/index.global.js +67 -66
  35. package/dist/index.global.js.map +1 -1
  36. package/dist/index.js +77 -77
  37. package/dist/index.js.map +1 -1
  38. package/dist/launcher.global.js +5 -4
  39. package/dist/launcher.global.js.map +1 -1
  40. package/dist/plugin-kit.cjs +1 -1
  41. package/dist/plugin-kit.d.cts +0 -8
  42. package/dist/plugin-kit.d.ts +0 -8
  43. package/dist/plugin-kit.js +1 -1
  44. package/dist/smart-dom-reader.d.cts +1224 -14
  45. package/dist/smart-dom-reader.d.ts +1224 -14
  46. package/dist/theme-editor-preview.cjs +970 -73
  47. package/dist/theme-editor-preview.d.cts +1343 -15
  48. package/dist/theme-editor-preview.d.ts +1343 -15
  49. package/dist/theme-editor-preview.js +81 -81
  50. package/dist/theme-editor.cjs +7 -7
  51. package/dist/theme-editor.d.cts +1227 -15
  52. package/dist/theme-editor.d.ts +1227 -15
  53. package/dist/theme-editor.js +9 -9
  54. package/dist/theme-reference.cjs +1 -1
  55. package/dist/theme-reference.d.cts +226 -7
  56. package/dist/theme-reference.d.ts +226 -7
  57. package/dist/theme-reference.js +1 -1
  58. package/dist/webmcp-polyfill.js +2 -2
  59. package/dist/widget.css +1 -1
  60. package/package.json +18 -6
  61. package/src/client-history-rest.test.ts +1034 -0
  62. package/src/client-visitor-history.test.ts +596 -0
  63. package/src/client.ts +1040 -24
  64. package/src/components/artifact-inline.ts +8 -1
  65. package/src/components/artifact-pane.test.ts +278 -1
  66. package/src/components/artifact-pane.ts +135 -67
  67. package/src/components/artifact-preview.test.ts +14 -1
  68. package/src/components/artifact-preview.ts +9 -0
  69. package/src/components/header-builder.ts +3 -0
  70. package/src/components/header-layouts.test.ts +171 -7
  71. package/src/components/header-layouts.ts +83 -53
  72. package/src/components/header-parts.test.ts +154 -1
  73. package/src/components/header-parts.ts +266 -138
  74. package/src/components/history-confirm-dialog.ts +177 -0
  75. package/src/components/history-shell-copy.ts +99 -0
  76. package/src/components/history-view/announcer.ts +40 -0
  77. package/src/components/history-view/copy.ts +105 -0
  78. package/src/components/history-view/css.ts +1078 -0
  79. package/src/components/history-view/grouping.test.ts +142 -0
  80. package/src/components/history-view/grouping.ts +142 -0
  81. package/src/components/history-view/icons.ts +73 -0
  82. package/src/components/history-view/rows.ts +341 -0
  83. package/src/components/history-view/state.ts +309 -0
  84. package/src/components/history-view.test.ts +2660 -0
  85. package/src/components/history-view.ts +1891 -0
  86. package/src/components/launcher-surface.test.ts +133 -0
  87. package/src/components/launcher.test.ts +18 -0
  88. package/src/components/launcher.ts +7 -7
  89. package/src/components/panel.ts +17 -7
  90. package/src/components/welcome.ts +72 -2
  91. package/src/defaults.ts +36 -3
  92. package/src/event-stream-view-bundle.test.ts +69 -0
  93. package/src/event-stream-view-entry.ts +19 -0
  94. package/src/event-stream-view-loader.ts +31 -0
  95. package/src/event-stream-view.ts +19 -0
  96. package/src/generated/runtype-openapi-contract.ts +54 -4
  97. package/src/history-render.ts +325 -0
  98. package/src/history-view-bundle.test.ts +78 -0
  99. package/src/history-view-entry.ts +41 -0
  100. package/src/history-view-loader.test.ts +30 -0
  101. package/src/history-view-loader.ts +32 -0
  102. package/src/history-view.ts +28 -0
  103. package/src/index-core.ts +50 -2
  104. package/src/index-global.ts +55 -0
  105. package/src/internal/demo-history-provider.test.ts +402 -0
  106. package/src/internal/demo-history-provider.ts +689 -0
  107. package/src/internal/history-provider-registry.ts +25 -0
  108. package/src/internal/history-provider.ts +150 -0
  109. package/src/internal/runtype-history-provider.test.ts +313 -0
  110. package/src/internal/runtype-history-provider.ts +240 -0
  111. package/src/plugin-kit.test.ts +16 -0
  112. package/src/plugin-kit.ts +13 -5
  113. package/src/plugins/types.ts +177 -1
  114. package/src/session-history.test.ts +1397 -0
  115. package/src/session.ts +1200 -26
  116. package/src/styles/widget-styles.test.ts +133 -0
  117. package/src/styles/widget.css +227 -8
  118. package/src/theme-editor/preview-utils.test.ts +19 -0
  119. package/src/theme-editor/preview-utils.ts +15 -1
  120. package/src/theme-editor/preview.ts +7 -1
  121. package/src/theme-editor/role-mappings.ts +4 -1
  122. package/src/theme-editor/sections.test.ts +168 -1
  123. package/src/theme-editor/sections.ts +136 -7
  124. package/src/theme-editor/webmcp/summary.ts +16 -2
  125. package/src/theme-reference.ts +20 -6
  126. package/src/types/theme.ts +213 -7
  127. package/src/types.ts +927 -7
  128. package/src/ui.artifact-rail-resize.test.ts +235 -0
  129. package/src/ui.attachments-drop.test.ts +1 -0
  130. package/src/ui.header-update-stability.test.ts +2 -2
  131. package/src/ui.history-chunk.test.ts +72 -0
  132. package/src/ui.history-config-provider.test.ts +318 -0
  133. package/src/ui.history-controller.test.ts +711 -0
  134. package/src/ui.history-lifecycle.test.ts +456 -0
  135. package/src/ui.history-render-hooks.test.ts +904 -0
  136. package/src/ui.history-shell.test.ts +1887 -0
  137. package/src/ui.rail-overlay.test.ts +573 -0
  138. package/src/ui.rail-resize.test.ts +284 -0
  139. package/src/ui.rebuild-composer.test.ts +21 -0
  140. package/src/ui.render-welcome.test.ts +42 -0
  141. package/src/ui.shortcuts.test.ts +166 -0
  142. package/src/ui.ts +3071 -240
  143. package/src/utils/__fixtures__/client-conversation-history-wire.json +108 -0
  144. package/src/utils/artifact-file.ts +15 -0
  145. package/src/utils/artifact-gate.test.ts +4 -0
  146. package/src/utils/artifact-gate.ts +13 -17
  147. package/src/utils/artifact-resize.test.ts +28 -0
  148. package/src/utils/artifact-resize.ts +9 -2
  149. package/src/utils/buttons.ts +188 -1
  150. package/src/utils/config-merge.test.ts +91 -0
  151. package/src/utils/dropdown.ts +10 -1
  152. package/src/utils/history-messages.test.ts +284 -0
  153. package/src/utils/history-messages.ts +284 -0
  154. package/src/utils/icons.ts +3 -0
  155. package/src/utils/shortcuts.test.ts +288 -0
  156. package/src/utils/shortcuts.ts +221 -0
  157. package/src/utils/theme.test.ts +501 -4
  158. package/src/utils/theme.ts +42 -3
  159. package/src/utils/tokens.ts +153 -15
  160. package/src/utils/tooltip-timing.ts +4 -0
  161. package/src/utils/tooltip.test.ts +249 -7
  162. package/src/utils/tooltip.ts +146 -4
  163. package/src/utils/visitor-store.test.ts +425 -0
  164. package/src/utils/visitor-store.ts +337 -0
  165. package/dist/chunk-QJ4LV2KW.js +0 -1
@@ -18,7 +18,8 @@ import { renderLucideIcon } from "../utils/icons";
18
18
  import {
19
19
  renderArtifactPreviewBody,
20
20
  runArtifactBodyTransition,
21
- type ArtifactBodyLayout
21
+ type ArtifactBodyLayout,
22
+ type ArtifactPreviewBodyHandle
22
23
  } from "./artifact-preview";
23
24
  import { PersonaArtifactCard } from "./artifact-card";
24
25
 
@@ -510,7 +511,12 @@ function renderDefaultArtifactInline(
510
511
  // card-sync updater so later records (title/status changes) re-render the card.
511
512
  const renderCardChild = (rec: PersonaArtifactRecord): HTMLElement =>
512
513
  PersonaArtifactCard(cardPropsFromRecord(rec), context);
514
+ // The live preview body, once built, so mountCard can release its
515
+ // file-preview timers/listeners when the collapse discards it.
516
+ let liveBody: ArtifactPreviewBodyHandle | null = null;
513
517
  const mountCard = (rec: PersonaArtifactRecord): void => {
518
+ liveBody?.destroy();
519
+ liveBody = null;
514
520
  root.classList.add("persona-artifact-inline--card");
515
521
  root.style.removeProperty(BODY_HEIGHT_VAR);
516
522
  root.replaceChildren(renderCardChild(rec));
@@ -534,6 +540,7 @@ function renderDefaultArtifactInline(
534
540
  // is behavior-identical to passing no `resolveViewMode` at all.
535
541
  resolveViewMode: () => userViewMode ?? bodyLayout.viewMode
536
542
  });
543
+ liveBody = handle;
537
544
 
538
545
  // Body wrapper is always present so the frame's padding lives here (chrome
539
546
  // sits flush to the frame edge); required even when chrome is disabled.
@@ -2,13 +2,26 @@
2
2
 
3
3
  import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
4
4
 
5
- import { createArtifactPane } from "./artifact-pane";
5
+ import { createArtifactPane as createArtifactPaneImpl } from "./artifact-pane";
6
6
  import type {
7
7
  AgentWidgetConfig,
8
8
  PersonaArtifactCustomAction,
9
9
  PersonaArtifactRecord,
10
10
  } from "../types";
11
11
 
12
+ // Track every pane and destroy it after each test: a dropped pane's file
13
+ // preview holds pending overlay timers that would otherwise fire after the
14
+ // test environment is torn down ("document is not defined" uncaught errors).
15
+ const livePanes: ReturnType<typeof createArtifactPaneImpl>[] = [];
16
+ const createArtifactPane: typeof createArtifactPaneImpl = (...args) => {
17
+ const pane = createArtifactPaneImpl(...args);
18
+ livePanes.push(pane);
19
+ return pane;
20
+ };
21
+ afterEach(() => {
22
+ for (const pane of livePanes.splice(0)) pane.destroy();
23
+ });
24
+
12
25
  beforeAll(() => {
13
26
  // jsdom does not implement matchMedia; the pane's layout code touches it.
14
27
  if (!window.matchMedia) {
@@ -536,6 +549,270 @@ describe("artifact-pane default-toolbar copy button", () => {
536
549
  });
537
550
  });
538
551
 
552
+ describe("artifact-pane copy success feedback", () => {
553
+ const docCopyConfig = (
554
+ layoutOver: Record<string, unknown> = {}
555
+ ): AgentWidgetConfig =>
556
+ ({
557
+ sanitize: false,
558
+ features: {
559
+ artifacts: {
560
+ enabled: true,
561
+ layout: {
562
+ toolbarPreset: "document",
563
+ documentToolbarShowCopyLabel: true,
564
+ ...layoutOver,
565
+ },
566
+ },
567
+ },
568
+ }) as AgentWidgetConfig;
569
+
570
+ const setClipboard = (writeText: ReturnType<typeof vi.fn>): void => {
571
+ Object.defineProperty(navigator, "clipboard", {
572
+ configurable: true,
573
+ value: { writeText },
574
+ });
575
+ };
576
+
577
+ beforeEach(() => {
578
+ document.body.innerHTML = "";
579
+ vi.useFakeTimers();
580
+ });
581
+
582
+ afterEach(() => {
583
+ vi.useRealTimers();
584
+ });
585
+
586
+ it("flashes check + Copied for 2s after a successful default copy", async () => {
587
+ const writeText = vi.fn().mockResolvedValue(undefined);
588
+ setClipboard(writeText);
589
+ const pane = createArtifactPane(docCopyConfig(), { onSelect: () => {} });
590
+ document.body.appendChild(pane.element);
591
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
592
+
593
+ const btn = pane.element.querySelector(
594
+ ".persona-artifact-doc-copy-btn"
595
+ ) as HTMLButtonElement;
596
+ const originalGlyph = btn.querySelector("svg");
597
+ expect(btn.querySelector("span")?.textContent).toBe("Copy");
598
+
599
+ btn.click();
600
+ await vi.advanceTimersByTimeAsync(0);
601
+ expect(writeText).toHaveBeenCalledTimes(1);
602
+ expect(btn.querySelector("span")?.textContent).toBe("Copied");
603
+ expect(btn.querySelector("svg")).not.toBe(originalGlyph);
604
+
605
+ await vi.advanceTimersByTimeAsync(2000);
606
+ expect(btn.querySelector("span")?.textContent).toBe("Copy");
607
+ expect(btn.querySelector("svg")).toBe(originalGlyph);
608
+ });
609
+
610
+ it("flashes after the primary click resolves through the copy-menu handler", async () => {
611
+ const handler = vi.fn().mockResolvedValue(undefined);
612
+ const pane = createArtifactPane(
613
+ docCopyConfig({
614
+ documentToolbarShowCopyChevron: true,
615
+ documentToolbarCopyMenuItems: [{ id: "download", label: "Download" }],
616
+ onDocumentToolbarCopyMenuSelect: handler,
617
+ }),
618
+ { onSelect: () => {} }
619
+ );
620
+ document.body.appendChild(pane.element);
621
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
622
+
623
+ const btn = pane.element.querySelector(
624
+ ".persona-artifact-doc-copy-btn"
625
+ ) as HTMLButtonElement;
626
+ btn.click();
627
+ await vi.advanceTimersByTimeAsync(0);
628
+ expect(handler).toHaveBeenCalledWith(
629
+ expect.objectContaining({ actionId: "primary", artifactId: "a1" })
630
+ );
631
+ expect(btn.querySelector("span")?.textContent).toBe("Copied");
632
+ });
633
+
634
+ it("built-in download menu id saves the artifact when there is no handler", async () => {
635
+ const urlApi = URL as unknown as {
636
+ createObjectURL?: (b: Blob) => string;
637
+ revokeObjectURL?: (u: string) => void;
638
+ };
639
+ const origCreate = urlApi.createObjectURL;
640
+ const origRevoke = urlApi.revokeObjectURL;
641
+ const created = vi.fn(() => "blob:persona-test");
642
+ urlApi.createObjectURL = created;
643
+ urlApi.revokeObjectURL = vi.fn();
644
+ const anchorClick = vi
645
+ .spyOn(HTMLAnchorElement.prototype, "click")
646
+ .mockImplementation(() => {});
647
+ try {
648
+ const pane = createArtifactPane(
649
+ docCopyConfig({
650
+ documentToolbarShowCopyChevron: true,
651
+ documentToolbarCopyMenuItems: [{ id: "download", label: "Download" }],
652
+ }),
653
+ { onSelect: () => {} }
654
+ );
655
+ document.body.appendChild(pane.element);
656
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
657
+ // Dropdown init is deferred to rAF when the shell mounts after creation.
658
+ await vi.advanceTimersByTimeAsync(20);
659
+
660
+ const item = Array.from(
661
+ document.querySelectorAll<HTMLButtonElement>(".persona-dropdown-menu button")
662
+ ).find((b) => b.textContent?.includes("Download"));
663
+ expect(item).toBeTruthy();
664
+ item!.click();
665
+ await vi.advanceTimersByTimeAsync(0);
666
+
667
+ expect(created).toHaveBeenCalledTimes(1);
668
+ expect(anchorClick).toHaveBeenCalledTimes(1);
669
+ } finally {
670
+ anchorClick.mockRestore();
671
+ urlApi.createObjectURL = origCreate;
672
+ urlApi.revokeObjectURL = origRevoke;
673
+ }
674
+ });
675
+
676
+ it("handler returning false falls through to the built-in download", async () => {
677
+ const urlApi = URL as unknown as {
678
+ createObjectURL?: (b: Blob) => string;
679
+ revokeObjectURL?: (u: string) => void;
680
+ };
681
+ const origCreate = urlApi.createObjectURL;
682
+ const origRevoke = urlApi.revokeObjectURL;
683
+ const created = vi.fn(() => "blob:persona-test");
684
+ urlApi.createObjectURL = created;
685
+ urlApi.revokeObjectURL = vi.fn();
686
+ const anchorClick = vi
687
+ .spyOn(HTMLAnchorElement.prototype, "click")
688
+ .mockImplementation(() => {});
689
+ const handler = vi.fn().mockResolvedValue(false);
690
+ const consumingHandler = vi.fn().mockResolvedValue(undefined);
691
+ try {
692
+ const paneFor = (h: typeof handler) =>
693
+ createArtifactPane(
694
+ docCopyConfig({
695
+ documentToolbarShowCopyChevron: true,
696
+ documentToolbarCopyMenuItems: [{ id: "download", label: "Download" }],
697
+ onDocumentToolbarCopyMenuSelect: h,
698
+ }),
699
+ { onSelect: () => {} }
700
+ );
701
+ const pane = paneFor(handler);
702
+ document.body.appendChild(pane.element);
703
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
704
+ await vi.advanceTimersByTimeAsync(20);
705
+ const item = Array.from(
706
+ document.querySelectorAll<HTMLButtonElement>(".persona-dropdown-menu button")
707
+ ).find((b) => b.textContent?.includes("Download"));
708
+ item!.click();
709
+ await vi.advanceTimersByTimeAsync(0);
710
+ // Handler saw the enriched payload, declined, and the widget downloaded.
711
+ expect(handler).toHaveBeenCalledWith(
712
+ expect.objectContaining({
713
+ actionId: "download",
714
+ artifactId: "a1",
715
+ suggestedFilename: "cat.html",
716
+ mime: "text/html",
717
+ content: HTML_RAW,
718
+ })
719
+ );
720
+ expect(anchorClick).toHaveBeenCalledTimes(1);
721
+
722
+ // A handler that consumes the action (returns undefined) suppresses it.
723
+ document.body.innerHTML = "";
724
+ const pane2 = paneFor(consumingHandler);
725
+ document.body.appendChild(pane2.element);
726
+ pane2.update({ artifacts: [fileRecord()], selectedId: "a1" });
727
+ await vi.advanceTimersByTimeAsync(20);
728
+ const item2 = Array.from(
729
+ document.querySelectorAll<HTMLButtonElement>(".persona-dropdown-menu button")
730
+ ).find((b) => b.textContent?.includes("Download"));
731
+ item2!.click();
732
+ await vi.advanceTimersByTimeAsync(0);
733
+ expect(consumingHandler).toHaveBeenCalledTimes(1);
734
+ expect(anchorClick).toHaveBeenCalledTimes(1);
735
+ } finally {
736
+ anchorClick.mockRestore();
737
+ urlApi.createObjectURL = origCreate;
738
+ urlApi.revokeObjectURL = origRevoke;
739
+ }
740
+ });
741
+
742
+ it("renders the copy menu as a split button with an accurate chevron state", async () => {
743
+ const pane = createArtifactPane(
744
+ docCopyConfig({
745
+ documentToolbarShowCopyChevron: true,
746
+ documentToolbarCopyMenuItems: [{ id: "download", label: "Download" }],
747
+ }),
748
+ { onSelect: () => {} }
749
+ );
750
+ document.body.appendChild(pane.element);
751
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
752
+ await vi.advanceTimersByTimeAsync(20);
753
+
754
+ const wrap = pane.element.querySelector(".persona-split-btn") as HTMLElement;
755
+ expect(wrap).toBeTruthy();
756
+ const primary = wrap.querySelector(
757
+ "button.persona-split-btn-primary.persona-artifact-doc-copy-btn"
758
+ );
759
+ expect(primary).toBeTruthy();
760
+ const chevron = wrap.querySelector(
761
+ "button.persona-split-btn-chevron"
762
+ ) as HTMLButtonElement;
763
+ expect(chevron.getAttribute("aria-haspopup")).toBe("true");
764
+ expect(chevron.getAttribute("aria-expanded")).toBe("false");
765
+
766
+ chevron.click();
767
+ expect(chevron.getAttribute("aria-expanded")).toBe("true");
768
+ chevron.click();
769
+ expect(chevron.getAttribute("aria-expanded")).toBe("false");
770
+ });
771
+
772
+ it("shows the refresh button only while a handler is configured", async () => {
773
+ const withoutHandler = createArtifactPane(docCopyConfig(), { onSelect: () => {} });
774
+ withoutHandler.update({ artifacts: [fileRecord()], selectedId: "a1" });
775
+ const hiddenRefresh = withoutHandler.element.querySelector(
776
+ '[aria-label="Refresh"]'
777
+ ) as HTMLButtonElement;
778
+ expect(hiddenRefresh.classList.contains("persona-hidden")).toBe(true);
779
+
780
+ const onRefresh = vi.fn();
781
+ const pane = createArtifactPane(
782
+ docCopyConfig({ onDocumentToolbarRefresh: onRefresh }),
783
+ { onSelect: () => {} }
784
+ );
785
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
786
+ const refresh = pane.element.querySelector(
787
+ '[aria-label="Refresh"]'
788
+ ) as HTMLButtonElement;
789
+ expect(refresh.classList.contains("persona-hidden")).toBe(false);
790
+ refresh.click();
791
+ await vi.advanceTimersByTimeAsync(0);
792
+ expect(onRefresh).toHaveBeenCalledTimes(1);
793
+
794
+ // Live config sync drives the same visibility.
795
+ pane.setRefreshButtonVisible(false);
796
+ expect(refresh.classList.contains("persona-hidden")).toBe(true);
797
+ });
798
+
799
+ it("stays unconfirmed when the clipboard write fails", async () => {
800
+ const writeText = vi.fn().mockRejectedValue(new Error("denied"));
801
+ setClipboard(writeText);
802
+ const pane = createArtifactPane(docCopyConfig(), { onSelect: () => {} });
803
+ document.body.appendChild(pane.element);
804
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
805
+
806
+ const btn = pane.element.querySelector(
807
+ ".persona-artifact-doc-copy-btn"
808
+ ) as HTMLButtonElement;
809
+ btn.click();
810
+ await vi.advanceTimersByTimeAsync(0);
811
+ expect(writeText).toHaveBeenCalledTimes(1);
812
+ expect(btn.querySelector("span")?.textContent).toBe("Copy");
813
+ });
814
+ });
815
+
539
816
  const makeToolbarActionsConfig = (
540
817
  actions: PersonaArtifactCustomAction[]
541
818
  ): AgentWidgetConfig =>
@@ -4,7 +4,13 @@ import type {
4
4
  PersonaArtifactRecord,
5
5
  PersonaArtifactCustomAction,
6
6
  } from "../types";
7
- import { fileKindOf, fileTypeLabel, basenameOf } from "../utils/artifact-file";
7
+ import {
8
+ fileKindOf,
9
+ fileTypeLabel,
10
+ basenameOf,
11
+ downloadInfoFor,
12
+ triggerArtifactDownload,
13
+ } from "../utils/artifact-file";
8
14
  import {
9
15
  buildArtifactActionButton,
10
16
  artifactRecordActionContext,
@@ -15,8 +21,13 @@ import {
15
21
  type ArtifactPreviewBodyHandle,
16
22
  } from "./artifact-preview";
17
23
  import { renderLucideIcon } from "../utils/icons";
18
- import { createDropdownMenu, type DropdownMenuHandle } from "../utils/dropdown";
19
- import { createIconButton, createLabelButton, createToggleGroup } from "../utils/buttons";
24
+ import {
25
+ createIconButton,
26
+ createLabelButton,
27
+ createSplitButton,
28
+ createToggleGroup,
29
+ type SplitButtonHandle,
30
+ } from "../utils/buttons";
20
31
  import { createRovingTablist } from "../utils/roving-tablist";
21
32
 
22
33
  export type ArtifactPaneApi = {
@@ -31,6 +42,8 @@ export type ArtifactPaneApi = {
31
42
  setExpandToggleVisible: (visible: boolean) => void;
32
43
  /** Show/hide the default-preset copy button; no-op in the document preset (copy is always shown there). */
33
44
  setCopyButtonVisible: (visible: boolean) => void;
45
+ /** Show/hide the document-preset refresh button; driven from the parent's config on every sync (visible only with an onDocumentToolbarRefresh handler). */
46
+ setRefreshButtonVisible: (visible: boolean) => void;
34
47
  /** Replace the toolbar custom-action list and re-render it; driven from the parent's config on every sync so live config updates apply. */
35
48
  setCustomActions: (actions: PersonaArtifactCustomAction[]) => void;
36
49
  /** Update the tab-strip edge fade live; driven from the parent's config on every sync. */
@@ -54,6 +67,8 @@ export type ArtifactPaneApi = {
54
67
  * reveal the pane renders the current recorded state.
55
68
  */
56
69
  setVisible: (visible: boolean) => void;
70
+ /** Release the current preview's timers/listeners. Call when the pane is discarded. */
71
+ destroy: () => void;
57
72
  };
58
73
 
59
74
  /**
@@ -99,7 +114,7 @@ export function createArtifactPane(
99
114
  backdrop?.classList.add("persona-hidden");
100
115
  shell.classList.remove("persona-artifact-drawer-open");
101
116
  // Hide portaled copy menu
102
- copyMenuDropdown?.hide();
117
+ copySplit?.closeMenu();
103
118
  };
104
119
 
105
120
  if (backdrop) {
@@ -167,8 +182,7 @@ export function createArtifactPane(
167
182
 
168
183
  let copyWrap: HTMLElement | null = null;
169
184
  let copyBtn: HTMLButtonElement;
170
- let copyMenuChevronBtn: HTMLButtonElement | null = null;
171
- let copyMenuDropdown: DropdownMenuHandle | null = null;
185
+ let copySplit: SplitButtonHandle | null = null;
172
186
 
173
187
  if (documentChrome && (showCopyLabel || showCopyChevron) && !showCopyMenu) {
174
188
  copyBtn = showCopyLabel
@@ -179,21 +193,22 @@ export function createArtifactPane(
179
193
  if (chev) copyBtn.appendChild(chev);
180
194
  }
181
195
  } else if (documentChrome && showCopyMenu) {
182
- copyWrap = createElement(
183
- "div",
184
- "persona-relative persona-inline-flex persona-items-center persona-gap-0 persona-rounded-md"
185
- );
186
- copyBtn = showCopyLabel
187
- ? createLabelButton({ icon: "copy", label: "Copy", iconSize: 14, className: "persona-artifact-doc-copy-btn" })
188
- : createIconButton({ icon: "copy", label: "Copy", className: "persona-artifact-doc-copy-btn" });
189
- copyMenuChevronBtn = createIconButton({
190
- icon: "chevron-down",
191
- label: "More copy options",
192
- size: 14,
193
- className: "persona-artifact-doc-copy-menu-chevron persona-artifact-doc-icon-btn",
194
- aria: { "aria-haspopup": "true", "aria-expanded": "false" }
196
+ copySplit = createSplitButton({
197
+ label: "Copy",
198
+ icon: "copy",
199
+ iconSize: 14,
200
+ iconOnly: !showCopyLabel,
201
+ primaryClassName: "persona-artifact-doc-copy-btn",
202
+ chevronLabel: "More copy options",
203
+ chevronClassName: "persona-artifact-doc-copy-menu-chevron persona-artifact-doc-icon-btn",
204
+ menuItems: (copyMenuItems ?? []).map((item) => ({ id: item.id, label: item.label })),
205
+ position: "bottom-right",
206
+ // Widget root for CSS var inheritance, escaping overflow: hidden.
207
+ portal: () => (shell.closest("[data-persona-root]") as HTMLElement | null) ?? document.body,
208
+ onSelect: (actionId) => void handleCopyMenuSelect(actionId),
195
209
  });
196
- copyWrap.append(copyBtn, copyMenuChevronBtn);
210
+ copyWrap = copySplit.element;
211
+ copyBtn = copySplit.primaryButton;
197
212
  } else if (documentChrome) {
198
213
  copyBtn = createIconButton({ icon: "copy", label: "Copy", className: "persona-artifact-doc-icon-btn" });
199
214
  } else {
@@ -208,6 +223,12 @@ export function createArtifactPane(
208
223
  const refreshBtn = documentChrome
209
224
  ? createIconButton({ icon: "refresh-cw", label: "Refresh", className: "persona-artifact-doc-icon-btn" })
210
225
  : createIconButton({ icon: "refresh-cw", label: "Refresh" });
226
+ // The refresh glyph universally reads "reload this preview", so the button
227
+ // only shows when a handler gives it that meaning; revealed live via
228
+ // setRefreshButtonVisible when a config update supplies one.
229
+ if (!layout?.onDocumentToolbarRefresh) {
230
+ refreshBtn.classList.add("persona-hidden");
231
+ }
211
232
  const closeIconBtn = documentChrome
212
233
  ? createIconButton({ icon: "x", label: closeButtonLabel, className: "persona-artifact-doc-icon-btn" })
213
234
  : createIconButton({ icon: "x", label: closeButtonLabel });
@@ -234,80 +255,119 @@ export function createArtifactPane(
234
255
  let customActionList: PersonaArtifactCustomAction[] =
235
256
  config.features?.artifacts?.toolbarActions ?? [];
236
257
 
237
- const getSelectedArtifactText = (): { markdown: string; jsonPayload: string; id: string | null } => {
258
+ const getSelectedArtifactText = () => {
238
259
  const sel = records.find((r) => r.id === selectedId) ?? records[records.length - 1];
239
260
  const id = sel?.id ?? null;
240
261
  const markdown = sel?.artifactType === "markdown" ? sel.markdown ?? "" : "";
241
262
  const jsonPayload = sel
242
263
  ? JSON.stringify({ component: sel.component, props: sel.props }, null, 2)
243
264
  : "";
244
- return { markdown, jsonPayload, id };
265
+ const { filename, mime, content } = downloadInfoFor({
266
+ title: sel?.title,
267
+ markdown: sel?.markdown ?? "",
268
+ file: sel?.file,
269
+ });
270
+ return {
271
+ markdown,
272
+ jsonPayload,
273
+ id,
274
+ file: sel?.file,
275
+ suggestedFilename: filename,
276
+ mime,
277
+ content,
278
+ };
245
279
  };
246
280
 
247
- const defaultCopy = async () => {
281
+ /** Built-in `download` menu action: same blob-anchor path as the card button. */
282
+ const performDownload = () => {
283
+ const { suggestedFilename, mime, content } = getSelectedArtifactText();
284
+ if (!content) return;
285
+ triggerArtifactDownload({ filename: suggestedFilename, mime, content });
286
+ };
287
+
288
+ const menuPayload = (actionId: string) => {
289
+ const { id, ...rest } = getSelectedArtifactText();
290
+ return { actionId, artifactId: id, ...rest };
291
+ };
292
+
293
+ const defaultCopy = async (): Promise<boolean> => {
248
294
  const sel = records.find((r) => r.id === selectedId) ?? records[records.length - 1];
249
295
  try {
250
296
  await navigator.clipboard.writeText(artifactCopyText(sel));
297
+ return true;
251
298
  } catch {
252
- /* ignore */
299
+ return false;
253
300
  }
254
301
  };
255
302
 
303
+ // Success affordance matching the message-copy action: swap the copy glyph
304
+ // (and "Copy" label when present) to a check for 2s, then restore. Only
305
+ // called after the copy path resolved, so failures stay unconfirmed.
306
+ let copyRestore: (() => void) | null = null;
307
+ const flashCopied = () => {
308
+ copyRestore?.();
309
+ const glyph = copyBtn.querySelector("svg");
310
+ const size = Number(glyph?.getAttribute("width")) || 16;
311
+ const check = renderLucideIcon("check", size, "currentColor", 2);
312
+ if (!glyph || !check) return;
313
+ glyph.replaceWith(check);
314
+ const labelSpan = copyBtn.querySelector("span");
315
+ if (labelSpan) labelSpan.textContent = "Copied";
316
+ const timer = setTimeout(() => copyRestore?.(), 2000);
317
+ copyRestore = () => {
318
+ clearTimeout(timer);
319
+ check.replaceWith(glyph);
320
+ if (labelSpan) labelSpan.textContent = "Copy";
321
+ copyRestore = null;
322
+ };
323
+ };
324
+
256
325
  copyBtn.addEventListener("click", async () => {
257
326
  const handler = layout?.onDocumentToolbarCopyMenuSelect;
258
327
  if (handler && showCopyMenu) {
259
- const { markdown, jsonPayload, id } = getSelectedArtifactText();
260
328
  try {
261
- await handler({ actionId: "primary", artifactId: id, markdown, jsonPayload });
329
+ // `false` = not handled: fall through to the built-in copy.
330
+ const result = await handler(menuPayload("primary"));
331
+ if (result === false) {
332
+ if (await defaultCopy()) flashCopied();
333
+ return;
334
+ }
335
+ flashCopied();
262
336
  } catch {
263
337
  /* ignore */
264
338
  }
265
339
  return;
266
340
  }
267
- await defaultCopy();
341
+ if (await defaultCopy()) flashCopied();
268
342
  });
269
343
 
270
- if (copyMenuChevronBtn && copyMenuItems?.length) {
271
- // Resolve the portal target: widget root for CSS var inheritance, escaping overflow: hidden
272
- const resolvePortal = (): HTMLElement => shell.closest("[data-persona-root]") as HTMLElement ?? document.body;
273
-
274
- const initDropdown = () => {
275
- copyMenuDropdown = createDropdownMenu({
276
- items: copyMenuItems.map((item) => ({ id: item.id, label: item.label })),
277
- onSelect: async (actionId) => {
278
- const { markdown, jsonPayload, id } = getSelectedArtifactText();
279
- const handler = layout?.onDocumentToolbarCopyMenuSelect;
280
- try {
281
- if (handler) {
282
- await handler({ actionId, artifactId: id, markdown, jsonPayload });
283
- } else if (actionId === "markdown" || actionId === "md") {
284
- await navigator.clipboard.writeText(markdown);
285
- } else if (actionId === "json" || actionId === "source") {
286
- await navigator.clipboard.writeText(jsonPayload);
287
- } else {
288
- await navigator.clipboard.writeText(markdown || jsonPayload);
289
- }
290
- } catch {
291
- /* ignore */
292
- }
293
- },
294
- anchor: copyWrap ?? copyMenuChevronBtn!,
295
- position: 'bottom-right',
296
- portal: resolvePortal(),
297
- });
298
- };
299
-
300
- // Defer init until shell is in the DOM (may not be attached yet)
301
- if (shell.isConnected) {
302
- initDropdown();
303
- } else {
304
- requestAnimationFrame(initDropdown);
344
+ // Hoisted: referenced by the split button built above.
345
+ async function handleCopyMenuSelect(actionId: string): Promise<void> {
346
+ const handler = layout?.onDocumentToolbarCopyMenuSelect;
347
+ try {
348
+ if (handler) {
349
+ // Custom menus can hold non-copy actions (download, publish),
350
+ // so the integrator's handler owns the outcome and feedback;
351
+ // returning `false` falls through to the built-ins below.
352
+ const result = await handler(menuPayload(actionId));
353
+ if (result !== false) return;
354
+ }
355
+ if (actionId === "download") {
356
+ performDownload();
357
+ return;
358
+ }
359
+ const { markdown, jsonPayload } = getSelectedArtifactText();
360
+ if (actionId === "markdown" || actionId === "md") {
361
+ await navigator.clipboard.writeText(markdown);
362
+ } else if (actionId === "json" || actionId === "source") {
363
+ await navigator.clipboard.writeText(jsonPayload);
364
+ } else {
365
+ await navigator.clipboard.writeText(markdown || jsonPayload);
366
+ }
367
+ flashCopied();
368
+ } catch {
369
+ /* ignore */
305
370
  }
306
-
307
- copyMenuChevronBtn.addEventListener("click", (e) => {
308
- e.stopPropagation();
309
- copyMenuDropdown?.toggle();
310
- });
311
371
  }
312
372
 
313
373
  refreshBtn.addEventListener("click", async () => {
@@ -613,6 +673,7 @@ export function createArtifactPane(
613
673
  records[records.length - 1];
614
674
  if (!sel) {
615
675
  content.replaceChildren();
676
+ preview?.destroy();
616
677
  preview = null;
617
678
  updateFileToggleVisibility(false);
618
679
  return;
@@ -750,6 +811,9 @@ export function createArtifactPane(
750
811
  if (documentChrome) return;
751
812
  copyBtn.classList.toggle("persona-hidden", !visible);
752
813
  },
814
+ setRefreshButtonVisible(visible: boolean) {
815
+ refreshBtn.classList.toggle("persona-hidden", !visible);
816
+ },
753
817
  setCustomActions(actions: PersonaArtifactCustomAction[]) {
754
818
  customActionList = actions;
755
819
  renderCustomActions();
@@ -780,6 +844,10 @@ export function createArtifactPane(
780
844
  // comments in artifact-preview.ts). Laziness only blocks building NEW
781
845
  // previews while hidden; an already-mounted one stays put.
782
846
  if (next && dirty) flush();
847
+ },
848
+ destroy() {
849
+ preview?.destroy();
850
+ preview = null;
783
851
  }
784
852
  };
785
853
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { afterEach, describe, expect, it, vi } from "vitest";
4
4
  import {
5
- renderArtifactPreviewBody,
5
+ renderArtifactPreviewBody as renderArtifactPreviewBodyImpl,
6
6
  runArtifactBodyTransition,
7
7
  type ArtifactBodyLayout,
8
8
  } from "./artifact-preview";
@@ -14,6 +14,19 @@ import type {
14
14
  } from "../types";
15
15
  import type { ComponentRenderer } from "./registry";
16
16
 
17
+ // Track every handle and destroy it after each test: a dropped file preview
18
+ // holds pending overlay timers that would otherwise fire after the test
19
+ // environment is torn down ("document is not defined" uncaught errors).
20
+ const liveHandles: ReturnType<typeof renderArtifactPreviewBodyImpl>[] = [];
21
+ const renderArtifactPreviewBody: typeof renderArtifactPreviewBodyImpl = (...args) => {
22
+ const handle = renderArtifactPreviewBodyImpl(...args);
23
+ liveHandles.push(handle);
24
+ return handle;
25
+ };
26
+ afterEach(() => {
27
+ for (const handle of liveHandles.splice(0)) handle.destroy();
28
+ });
29
+
17
30
  const layout = (o: Partial<ArtifactBodyLayout> = {}): ArtifactBodyLayout => ({
18
31
  streamingView: "source",
19
32
  viewMode: "rendered",