@runtypelabs/persona 4.17.1 → 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.
- package/dist/chunk-3I2S3I5P.js +1 -0
- package/dist/chunk-76IELU7F.js +1 -0
- package/dist/chunk-EFJBUH5F.js +1 -0
- package/dist/chunk-PDPVORUF.js +1 -0
- package/dist/chunk-Q735PSZL.js +1 -0
- package/dist/chunk-VK6JOIYJ.js +1 -0
- package/dist/codegen.cjs +4 -4
- package/dist/codegen.js +4 -4
- package/dist/{context-mentions-BTSTIBFC.js → context-mentions-MJL6S6P2.js} +1 -1
- package/dist/context-mentions.cjs +9 -8
- package/dist/context-mentions.d.cts +1212 -14
- package/dist/context-mentions.d.ts +1212 -14
- package/dist/context-mentions.js +9 -8
- package/dist/event-stream-view-4EZ7BGFK.js +1 -0
- package/dist/event-stream-view.cjs +129 -0
- package/dist/event-stream-view.d.cts +8384 -0
- package/dist/event-stream-view.d.ts +8384 -0
- package/dist/event-stream-view.js +129 -0
- package/dist/history-view-BD6OXLNA.js +898 -0
- package/dist/history-view.cjs +898 -0
- package/dist/history-view.d.cts +1461 -0
- package/dist/history-view.d.ts +1461 -0
- package/dist/history-view.js +898 -0
- package/dist/index.cjs +77 -77
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1987 -17
- package/dist/index.d.ts +1987 -17
- package/dist/index.global.js +67 -66
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +77 -77
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +5 -4
- package/dist/launcher.global.js.map +1 -1
- package/dist/plugin-kit.cjs +1 -1
- package/dist/plugin-kit.d.cts +0 -8
- package/dist/plugin-kit.d.ts +0 -8
- package/dist/plugin-kit.js +1 -1
- package/dist/smart-dom-reader.d.cts +1212 -14
- package/dist/smart-dom-reader.d.ts +1212 -14
- package/dist/theme-editor-preview.cjs +970 -73
- package/dist/theme-editor-preview.d.cts +1331 -15
- package/dist/theme-editor-preview.d.ts +1331 -15
- package/dist/theme-editor-preview.js +81 -81
- package/dist/theme-editor.cjs +7 -7
- package/dist/theme-editor.d.cts +1215 -15
- package/dist/theme-editor.d.ts +1215 -15
- package/dist/theme-editor.js +9 -9
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +226 -7
- package/dist/theme-reference.d.ts +226 -7
- package/dist/theme-reference.js +1 -1
- package/dist/widget.css +1 -1
- package/package.json +16 -4
- package/src/client-history-rest.test.ts +1034 -0
- package/src/client-visitor-history.test.ts +596 -0
- package/src/client.ts +1040 -24
- package/src/components/artifact-inline.ts +8 -1
- package/src/components/artifact-pane.test.ts +278 -1
- package/src/components/artifact-pane.ts +135 -67
- package/src/components/artifact-preview.test.ts +14 -1
- package/src/components/artifact-preview.ts +9 -0
- package/src/components/header-builder.ts +3 -0
- package/src/components/header-layouts.test.ts +171 -7
- package/src/components/header-layouts.ts +83 -53
- package/src/components/header-parts.test.ts +154 -1
- package/src/components/header-parts.ts +266 -138
- package/src/components/history-confirm-dialog.ts +177 -0
- package/src/components/history-shell-copy.ts +99 -0
- package/src/components/history-view/announcer.ts +40 -0
- package/src/components/history-view/copy.ts +105 -0
- package/src/components/history-view/css.ts +1078 -0
- package/src/components/history-view/grouping.test.ts +142 -0
- package/src/components/history-view/grouping.ts +142 -0
- package/src/components/history-view/icons.ts +73 -0
- package/src/components/history-view/rows.ts +341 -0
- package/src/components/history-view/state.ts +309 -0
- package/src/components/history-view.test.ts +2660 -0
- package/src/components/history-view.ts +1891 -0
- package/src/components/launcher-surface.test.ts +133 -0
- package/src/components/launcher.test.ts +18 -0
- package/src/components/launcher.ts +7 -7
- package/src/components/panel.ts +17 -7
- package/src/components/welcome.ts +72 -2
- package/src/defaults.ts +36 -3
- package/src/event-stream-view-bundle.test.ts +69 -0
- package/src/event-stream-view-entry.ts +19 -0
- package/src/event-stream-view-loader.ts +31 -0
- package/src/event-stream-view.ts +19 -0
- package/src/generated/runtype-openapi-contract.ts +1 -0
- package/src/history-render.ts +325 -0
- package/src/history-view-bundle.test.ts +78 -0
- package/src/history-view-entry.ts +41 -0
- package/src/history-view-loader.test.ts +30 -0
- package/src/history-view-loader.ts +32 -0
- package/src/history-view.ts +28 -0
- package/src/index-core.ts +50 -2
- package/src/index-global.ts +55 -0
- package/src/internal/demo-history-provider.test.ts +402 -0
- package/src/internal/demo-history-provider.ts +689 -0
- package/src/internal/history-provider-registry.ts +25 -0
- package/src/internal/history-provider.ts +150 -0
- package/src/internal/runtype-history-provider.test.ts +313 -0
- package/src/internal/runtype-history-provider.ts +240 -0
- package/src/plugin-kit.test.ts +16 -0
- package/src/plugin-kit.ts +13 -5
- package/src/plugins/types.ts +177 -1
- package/src/session-history.test.ts +1397 -0
- package/src/session.ts +1200 -26
- package/src/styles/widget-styles.test.ts +133 -0
- package/src/styles/widget.css +227 -8
- package/src/theme-editor/preview-utils.test.ts +19 -0
- package/src/theme-editor/preview-utils.ts +15 -1
- package/src/theme-editor/preview.ts +7 -1
- package/src/theme-editor/role-mappings.ts +4 -1
- package/src/theme-editor/sections.test.ts +168 -1
- package/src/theme-editor/sections.ts +136 -7
- package/src/theme-editor/webmcp/summary.ts +16 -2
- package/src/theme-reference.ts +20 -6
- package/src/types/theme.ts +213 -7
- package/src/types.ts +927 -7
- package/src/ui.artifact-rail-resize.test.ts +235 -0
- package/src/ui.attachments-drop.test.ts +1 -0
- package/src/ui.header-update-stability.test.ts +2 -2
- package/src/ui.history-chunk.test.ts +72 -0
- package/src/ui.history-config-provider.test.ts +318 -0
- package/src/ui.history-controller.test.ts +711 -0
- package/src/ui.history-lifecycle.test.ts +456 -0
- package/src/ui.history-render-hooks.test.ts +904 -0
- package/src/ui.history-shell.test.ts +1887 -0
- package/src/ui.rail-overlay.test.ts +573 -0
- package/src/ui.rail-resize.test.ts +284 -0
- package/src/ui.rebuild-composer.test.ts +21 -0
- package/src/ui.render-welcome.test.ts +42 -0
- package/src/ui.shortcuts.test.ts +166 -0
- package/src/ui.ts +3071 -240
- package/src/utils/__fixtures__/client-conversation-history-wire.json +108 -0
- package/src/utils/artifact-file.ts +15 -0
- package/src/utils/artifact-gate.test.ts +4 -0
- package/src/utils/artifact-gate.ts +13 -17
- package/src/utils/artifact-resize.test.ts +28 -0
- package/src/utils/artifact-resize.ts +9 -2
- package/src/utils/buttons.ts +188 -1
- package/src/utils/config-merge.test.ts +91 -0
- package/src/utils/dropdown.ts +10 -1
- package/src/utils/history-messages.test.ts +284 -0
- package/src/utils/history-messages.ts +284 -0
- package/src/utils/icons.ts +3 -0
- package/src/utils/shortcuts.test.ts +288 -0
- package/src/utils/shortcuts.ts +221 -0
- package/src/utils/theme.test.ts +501 -4
- package/src/utils/theme.ts +42 -3
- package/src/utils/tokens.ts +153 -15
- package/src/utils/tooltip-timing.ts +4 -0
- package/src/utils/tooltip.test.ts +249 -7
- package/src/utils/tooltip.ts +146 -4
- package/src/utils/visitor-store.test.ts +425 -0
- package/src/utils/visitor-store.ts +337 -0
- 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 {
|
|
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 {
|
|
19
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
183
|
-
"
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
:
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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.
|
|
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 = ()
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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",
|