@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.
- package/dist/animations/glyph-cycle.cjs +2 -2
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/glyph-cycle.js +2 -2
- package/dist/animations/{types-CSmiKRVa.d.cts → types-BsZtXPKK.d.cts} +43 -3
- package/dist/animations/{types-CSmiKRVa.d.ts → types-BsZtXPKK.d.ts} +43 -3
- package/dist/animations/wipe.cjs +2 -2
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-5EIIHQLQ.js +1 -0
- package/dist/codegen.cjs +12 -12
- package/dist/codegen.js +14 -14
- package/dist/index.cjs +91 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +918 -174
- package/dist/index.d.ts +918 -174
- package/dist/index.global.js +81 -57
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +89 -66
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/launcher.global.js +3 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/markdown-parsers.js +24 -24
- package/dist/plugin-kit.cjs +1 -1
- package/dist/plugin-kit.js +1 -1
- package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
- package/dist/runtype-tts.js +1 -1
- package/dist/session-reconnect-JKIJBHS5.js +1 -0
- package/dist/smart-dom-reader.cjs +17 -17
- package/dist/smart-dom-reader.d.cts +753 -15
- package/dist/smart-dom-reader.d.ts +753 -15
- package/dist/smart-dom-reader.js +17 -17
- package/dist/testing.cjs +3 -3
- package/dist/testing.js +3 -3
- package/dist/theme-editor-preview.cjs +81 -58
- package/dist/theme-editor-preview.d.cts +761 -15
- package/dist/theme-editor-preview.d.ts +761 -15
- package/dist/theme-editor-preview.js +81 -58
- package/dist/theme-editor.cjs +6 -6
- package/dist/theme-editor.d.cts +753 -15
- package/dist/theme-editor.d.ts +753 -15
- package/dist/theme-editor.js +10 -10
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +74 -0
- package/dist/theme-reference.d.ts +74 -0
- package/dist/theme-reference.js +1 -1
- package/dist/voice-worklet-player.cjs +2 -2
- package/dist/voice-worklet-player.js +2 -2
- package/dist/webmcp-polyfill.js +2 -2
- package/dist/widget.css +1 -1
- package/package.json +2 -3
- package/src/artifacts-session.test.ts +178 -0
- package/src/client.test.ts +500 -1
- package/src/client.ts +285 -93
- package/src/components/artifact-card.test.ts +333 -0
- package/src/components/artifact-card.ts +75 -28
- package/src/components/artifact-inline.test.ts +1328 -0
- package/src/components/artifact-inline.ts +920 -0
- package/src/components/artifact-pane.test.ts +1042 -0
- package/src/components/artifact-pane.ts +440 -131
- package/src/components/artifact-preview.test.ts +1155 -0
- package/src/components/artifact-preview.ts +994 -0
- package/src/components/pill-composer-builder.test.ts +6 -2
- package/src/components/pill-composer-builder.ts +6 -6
- package/src/components/reasoning-bubble.ts +1 -13
- package/src/components/registry.ts +38 -3
- package/src/components/tool-bubble.ts +1 -13
- package/src/defaults.ts +1 -0
- package/src/generated/runtype-openapi-contract.ts +55 -3
- package/src/index-core.ts +20 -1
- package/src/index.ts +8 -0
- package/src/markdown-parsers-loader.test.ts +158 -0
- package/src/markdown-parsers-loader.ts +74 -9
- package/src/runtime/host-layout.test.ts +163 -0
- package/src/runtime/host-layout.ts +110 -7
- package/src/runtime/init.ts +18 -61
- package/src/runtime/persist-state.test.ts +118 -0
- package/src/session.ts +76 -22
- package/src/styles/widget.css +773 -26
- package/src/theme-editor/preview.ts +2 -0
- package/src/theme-editor/sections.test.ts +26 -1
- package/src/theme-editor/sections.ts +10 -2
- package/src/theme-reference.ts +2 -2
- package/src/tool-call-display-defaults.test.ts +1 -0
- package/src/types/theme.ts +77 -0
- package/src/types.ts +516 -17
- package/src/ui.artifact-pane-gating.test.ts +636 -0
- package/src/ui.component-directive.test.ts +104 -0
- package/src/ui.composer-bar.test.ts +60 -2
- package/src/ui.detached-panel.test.ts +1049 -0
- package/src/ui.scroll-additive.test.ts +1 -1
- package/src/ui.streaming-coalescing.test.ts +312 -0
- package/src/ui.tool-display.test.ts +51 -0
- package/src/ui.ts +933 -214
- package/src/utils/artifact-custom-actions.ts +128 -0
- package/src/utils/artifact-display.test.ts +42 -0
- package/src/utils/artifact-display.ts +84 -0
- package/src/utils/artifact-file.test.ts +116 -0
- package/src/utils/artifact-file.ts +117 -0
- package/src/utils/artifact-gate.test.ts +112 -5
- package/src/utils/artifact-gate.ts +39 -14
- package/src/utils/artifact-loading-status.ts +55 -0
- package/src/utils/artifact-status-label.ts +190 -0
- package/src/utils/buttons.ts +7 -1
- package/src/utils/code-highlight.test.ts +186 -0
- package/src/utils/code-highlight.ts +400 -0
- package/src/utils/icons.ts +2 -0
- package/src/utils/roving-tablist.test.ts +152 -0
- package/src/utils/roving-tablist.ts +111 -0
- package/src/utils/spinner.ts +45 -0
- package/src/utils/theme.test.ts +48 -0
- package/src/utils/theme.ts +7 -0
- package/src/utils/tokens.ts +91 -0
- package/src/utils/tool-loading-animation.test.ts +32 -0
- package/src/utils/tool-loading-animation.ts +24 -0
- package/dist/chunk-DFBSCFYN.js +0 -1
- package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
- package/dist/session-reconnect-U77QFUR7.js +0 -1
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { PersonaArtifactCard } from "./artifact-card";
|
|
5
|
+
import { componentRegistry, type ComponentRenderer } from "./registry";
|
|
6
|
+
import type { ComponentContext } from "./registry";
|
|
7
|
+
import type {
|
|
8
|
+
AgentWidgetArtifactsFeature,
|
|
9
|
+
AgentWidgetConfig,
|
|
10
|
+
PersonaArtifactStatusLabelContext,
|
|
11
|
+
} from "../types";
|
|
12
|
+
|
|
13
|
+
const makeContext = (
|
|
14
|
+
artifacts?: AgentWidgetArtifactsFeature
|
|
15
|
+
): ComponentContext =>
|
|
16
|
+
({
|
|
17
|
+
message: {},
|
|
18
|
+
config: {
|
|
19
|
+
features: { artifacts: { enabled: true, ...(artifacts ?? {}) } },
|
|
20
|
+
} as AgentWidgetConfig,
|
|
21
|
+
updateProps: () => {},
|
|
22
|
+
}) as unknown as ComponentContext;
|
|
23
|
+
|
|
24
|
+
const render = (
|
|
25
|
+
props: Record<string, unknown>,
|
|
26
|
+
artifacts?: AgentWidgetArtifactsFeature
|
|
27
|
+
): HTMLElement => PersonaArtifactCard(props, makeContext(artifacts));
|
|
28
|
+
|
|
29
|
+
const streamingProps = {
|
|
30
|
+
artifactId: "a1",
|
|
31
|
+
title: "index.html",
|
|
32
|
+
status: "streaming",
|
|
33
|
+
artifactType: "markdown",
|
|
34
|
+
file: { path: "index.html", mimeType: "text/html", language: "html" },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const NBSP = String.fromCharCode(0xa0);
|
|
38
|
+
|
|
39
|
+
const findStatusText = (root: HTMLElement): HTMLElement | null =>
|
|
40
|
+
root.querySelector(
|
|
41
|
+
".persona-tool-loading-shimmer, .persona-tool-loading-shimmer-color, .persona-tool-loading-rainbow, .persona-tool-loading-pulse"
|
|
42
|
+
) as HTMLElement | null;
|
|
43
|
+
|
|
44
|
+
describe("PersonaArtifactCard streaming status", () => {
|
|
45
|
+
it("renders shimmer animation by default with no pulsing dot", () => {
|
|
46
|
+
const root = render(streamingProps);
|
|
47
|
+
|
|
48
|
+
// No leftover pulsing dot element.
|
|
49
|
+
expect(root.querySelector(".persona-rounded-full")).toBeNull();
|
|
50
|
+
expect(root.innerHTML).not.toContain("persona-pulse ");
|
|
51
|
+
|
|
52
|
+
const statusText = findStatusText(root);
|
|
53
|
+
expect(statusText).not.toBeNull();
|
|
54
|
+
expect(statusText!.classList.contains("persona-tool-loading-shimmer")).toBe(
|
|
55
|
+
true
|
|
56
|
+
);
|
|
57
|
+
expect(statusText!.getAttribute("data-preserve-animation")).toBe("true");
|
|
58
|
+
expect(
|
|
59
|
+
statusText!.style.getPropertyValue("--persona-tool-anim-duration")
|
|
60
|
+
).toBe("2000ms");
|
|
61
|
+
|
|
62
|
+
const chars = statusText!.querySelectorAll(".persona-tool-char");
|
|
63
|
+
expect(chars.length).toBeGreaterThan(0);
|
|
64
|
+
expect((chars[0] as HTMLElement).style.getPropertyValue("--char-index")).toBe(
|
|
65
|
+
"0"
|
|
66
|
+
);
|
|
67
|
+
// Reconstruct the animated text from the character spans.
|
|
68
|
+
const text = Array.from(chars)
|
|
69
|
+
.map((c) => (c.textContent === NBSP ? " " : c.textContent))
|
|
70
|
+
.join("");
|
|
71
|
+
expect(text).toBe("Generating html...");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("renders plain text with no animation classes when loadingAnimation is none", () => {
|
|
75
|
+
const root = render(streamingProps, { loadingAnimation: "none" });
|
|
76
|
+
|
|
77
|
+
expect(findStatusText(root)).toBeNull();
|
|
78
|
+
expect(root.querySelector(".persona-tool-char")).toBeNull();
|
|
79
|
+
expect(root.textContent).toContain("Generating html...");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("applies the pulse class to the status element without char spans", () => {
|
|
83
|
+
const root = render(streamingProps, { loadingAnimation: "pulse" });
|
|
84
|
+
|
|
85
|
+
const statusText = root.querySelector(
|
|
86
|
+
".persona-tool-loading-pulse"
|
|
87
|
+
) as HTMLElement | null;
|
|
88
|
+
expect(statusText).not.toBeNull();
|
|
89
|
+
expect(statusText!.getAttribute("data-preserve-animation")).toBe("true");
|
|
90
|
+
expect(statusText!.querySelector(".persona-tool-char")).toBeNull();
|
|
91
|
+
expect(statusText!.textContent).toBe("Generating html...");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("sets color CSS vars for shimmer-color mode", () => {
|
|
95
|
+
const root = render(streamingProps, {
|
|
96
|
+
loadingAnimation: "shimmer-color",
|
|
97
|
+
loadingAnimationColor: "#111111",
|
|
98
|
+
loadingAnimationSecondaryColor: "#eeeeee",
|
|
99
|
+
loadingAnimationDuration: 3000,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const statusText = root.querySelector(
|
|
103
|
+
".persona-tool-loading-shimmer-color"
|
|
104
|
+
) as HTMLElement | null;
|
|
105
|
+
expect(statusText).not.toBeNull();
|
|
106
|
+
expect(
|
|
107
|
+
statusText!.style.getPropertyValue("--persona-tool-anim-color")
|
|
108
|
+
).toBe("#111111");
|
|
109
|
+
expect(
|
|
110
|
+
statusText!.style.getPropertyValue("--persona-tool-anim-secondary-color")
|
|
111
|
+
).toBe("#eeeeee");
|
|
112
|
+
expect(
|
|
113
|
+
statusText!.style.getPropertyValue("--persona-tool-anim-duration")
|
|
114
|
+
).toBe("3000ms");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("carries the artifact-card class so radius/border/background come from CSS", () => {
|
|
118
|
+
const root = render(streamingProps);
|
|
119
|
+
|
|
120
|
+
// Standalone card must not hardcode the rounded-xl utility class.
|
|
121
|
+
expect(root.classList.contains("persona-rounded-xl")).toBe(false);
|
|
122
|
+
// Radius/border/background now live in widget.css keyed off this class,
|
|
123
|
+
// following the --persona-artifact-card-radius → assistant-bubble chain.
|
|
124
|
+
expect(root.classList.contains("persona-artifact-card")).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("keeps border/background off inline styles so they come from CSS", () => {
|
|
128
|
+
const root = render(streamingProps);
|
|
129
|
+
|
|
130
|
+
expect(root.classList.contains("persona-artifact-card")).toBe(true);
|
|
131
|
+
// Border/bg/radius/cursor moved to widget.css keyed off the card class.
|
|
132
|
+
expect(root.style.border).toBe("");
|
|
133
|
+
expect(root.style.background).toBe("");
|
|
134
|
+
expect(root.style.backgroundColor).toBe("");
|
|
135
|
+
expect(root.style.borderRadius).toBe("");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("renders the Download button as a label button carrying the artifact id", () => {
|
|
139
|
+
const root = render({
|
|
140
|
+
artifactId: "a1",
|
|
141
|
+
title: "index.html",
|
|
142
|
+
status: "complete",
|
|
143
|
+
artifactType: "markdown",
|
|
144
|
+
file: { path: "index.html", mimeType: "text/html", language: "html" },
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const dl = root.querySelector(
|
|
148
|
+
"[data-download-artifact]"
|
|
149
|
+
) as HTMLButtonElement | null;
|
|
150
|
+
expect(dl).not.toBeNull();
|
|
151
|
+
expect(dl!.tagName).toBe("BUTTON");
|
|
152
|
+
expect(dl!.classList.contains("persona-label-btn")).toBe(true);
|
|
153
|
+
expect(dl!.textContent).toBe("Download");
|
|
154
|
+
expect(dl!.getAttribute("data-download-artifact")).toBe("a1");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("renders the complete state with a Download button and no animation", () => {
|
|
158
|
+
const root = render({
|
|
159
|
+
artifactId: "a1",
|
|
160
|
+
title: "index.html",
|
|
161
|
+
status: "complete",
|
|
162
|
+
artifactType: "markdown",
|
|
163
|
+
file: { path: "index.html", mimeType: "text/html", language: "html" },
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(findStatusText(root)).toBeNull();
|
|
167
|
+
expect(root.querySelector(".persona-tool-char")).toBeNull();
|
|
168
|
+
|
|
169
|
+
const dl = root.querySelector(
|
|
170
|
+
"[data-download-artifact]"
|
|
171
|
+
) as HTMLButtonElement | null;
|
|
172
|
+
expect(dl).not.toBeNull();
|
|
173
|
+
expect(dl!.textContent).toBe("Download");
|
|
174
|
+
// Subtitle shows the file type label, not a "Generating" status.
|
|
175
|
+
expect(root.textContent).not.toContain("Generating");
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("PersonaArtifactCard statusLabel", () => {
|
|
180
|
+
// Reconstruct the (possibly char-span animated) status label text.
|
|
181
|
+
const labelText = (root: HTMLElement): string => {
|
|
182
|
+
const label = root.querySelector(
|
|
183
|
+
".persona-artifact-status-label"
|
|
184
|
+
) as HTMLElement | null;
|
|
185
|
+
if (!label) return "";
|
|
186
|
+
const chars = label.querySelectorAll(".persona-tool-char");
|
|
187
|
+
if (chars.length === 0) return label.textContent ?? "";
|
|
188
|
+
return Array.from(chars)
|
|
189
|
+
.map((c) => (c.textContent === NBSP ? " " : c.textContent))
|
|
190
|
+
.join("");
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
it("replaces the default status text with a plain string", () => {
|
|
194
|
+
const root = render(streamingProps, { statusLabel: "Cooking up your file..." });
|
|
195
|
+
expect(labelText(root)).toBe("Cooking up your file...");
|
|
196
|
+
expect(root.textContent).not.toContain("Generating");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("calls a statusLabel function with the card surface and ctx", () => {
|
|
200
|
+
const calls: PersonaArtifactStatusLabelContext[] = [];
|
|
201
|
+
const root = render(streamingProps, {
|
|
202
|
+
statusLabel: (ctx) => {
|
|
203
|
+
calls.push(ctx);
|
|
204
|
+
return "Custom label";
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
expect(calls.length).toBe(1);
|
|
208
|
+
expect(calls[0].surface).toBe("card");
|
|
209
|
+
expect(calls[0].artifactId).toBe("a1");
|
|
210
|
+
expect(calls[0].artifactType).toBe("markdown");
|
|
211
|
+
// file: index.html -> fileTypeLabel "HTML".
|
|
212
|
+
expect(calls[0].typeLabel).toBe("HTML");
|
|
213
|
+
expect(typeof calls[0].content).toBe("function");
|
|
214
|
+
expect(labelText(root)).toBe("Custom label");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("renders a { label, detail } as an animated label + plain detail span", () => {
|
|
218
|
+
const root = render(streamingProps, {
|
|
219
|
+
statusLabel: () => ({ label: "Writing", detail: "just started" }),
|
|
220
|
+
});
|
|
221
|
+
expect(labelText(root)).toBe("Writing");
|
|
222
|
+
const detail = root.querySelector(
|
|
223
|
+
".persona-artifact-status-detail"
|
|
224
|
+
) as HTMLElement | null;
|
|
225
|
+
expect(detail).not.toBeNull();
|
|
226
|
+
expect(detail!.textContent).toBe("just started");
|
|
227
|
+
// The detail span is un-animated (no char spans / loading classes).
|
|
228
|
+
expect(detail!.querySelector(".persona-tool-char")).toBeNull();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("falls back to the default label when statusLabel throws", () => {
|
|
232
|
+
const root = render(streamingProps, {
|
|
233
|
+
statusLabel: () => {
|
|
234
|
+
throw new Error("boom");
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
expect(labelText(root)).toBe("Generating html...");
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("PersonaArtifactCard custom card actions", () => {
|
|
242
|
+
const completeProps = {
|
|
243
|
+
artifactId: "a1",
|
|
244
|
+
title: "index.html",
|
|
245
|
+
status: "complete",
|
|
246
|
+
artifactType: "markdown",
|
|
247
|
+
markdown: "# Hi",
|
|
248
|
+
file: { path: "index.html", mimeType: "text/html", language: "html" },
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
it("renders a card action button before Download on complete cards", () => {
|
|
252
|
+
const root = render(completeProps, {
|
|
253
|
+
cardActions: [
|
|
254
|
+
{ id: "save", label: "Save to Drive", icon: "star", onClick: () => {} },
|
|
255
|
+
],
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const btn = root.querySelector(
|
|
259
|
+
'[data-artifact-custom-action="save"]'
|
|
260
|
+
) as HTMLButtonElement | null;
|
|
261
|
+
expect(btn).not.toBeNull();
|
|
262
|
+
expect(btn!.tagName).toBe("BUTTON");
|
|
263
|
+
// Delegated in ui.ts: the card carries the id attribute, no direct listener.
|
|
264
|
+
expect(btn!.getAttribute("data-artifact-custom-action")).toBe("save");
|
|
265
|
+
|
|
266
|
+
const dl = root.querySelector(
|
|
267
|
+
"[data-download-artifact]"
|
|
268
|
+
) as HTMLElement | null;
|
|
269
|
+
expect(dl).not.toBeNull();
|
|
270
|
+
// Custom actions render before Download.
|
|
271
|
+
expect(
|
|
272
|
+
btn!.compareDocumentPosition(dl!) & Node.DOCUMENT_POSITION_FOLLOWING
|
|
273
|
+
).toBeTruthy();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("does not render card actions while streaming", () => {
|
|
277
|
+
const root = render(streamingProps, {
|
|
278
|
+
cardActions: [
|
|
279
|
+
{ id: "save", label: "Save to Drive", icon: "star", onClick: () => {} },
|
|
280
|
+
],
|
|
281
|
+
});
|
|
282
|
+
expect(root.querySelector("[data-artifact-custom-action]")).toBeNull();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("suppresses a card action whose visible() returns false", () => {
|
|
286
|
+
const root = render(completeProps, {
|
|
287
|
+
cardActions: [
|
|
288
|
+
{
|
|
289
|
+
id: "save",
|
|
290
|
+
label: "Save",
|
|
291
|
+
icon: "star",
|
|
292
|
+
visible: () => false,
|
|
293
|
+
onClick: () => {},
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
});
|
|
297
|
+
expect(root.querySelector("[data-artifact-custom-action]")).toBeNull();
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe("componentRegistry registration options", () => {
|
|
302
|
+
const noop: ComponentRenderer = () => document.createElement("div");
|
|
303
|
+
|
|
304
|
+
afterEach(() => {
|
|
305
|
+
componentRegistry.unregister("OptTest");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("registers the built-in artifact card with bubbleChrome:false", () => {
|
|
309
|
+
expect(componentRegistry.getOptions("PersonaArtifactCard")?.bubbleChrome).toBe(false);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("returns the options passed to register", () => {
|
|
313
|
+
componentRegistry.register("OptTest", noop, { bubbleChrome: false });
|
|
314
|
+
expect(componentRegistry.getOptions("OptTest")).toEqual({ bubbleChrome: false });
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("returns undefined when a component is registered without options", () => {
|
|
318
|
+
componentRegistry.register("OptTest", noop);
|
|
319
|
+
expect(componentRegistry.getOptions("OptTest")).toBeUndefined();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("clears prior options when re-registered without options", () => {
|
|
323
|
+
componentRegistry.register("OptTest", noop, { bubbleChrome: false });
|
|
324
|
+
componentRegistry.register("OptTest", noop);
|
|
325
|
+
expect(componentRegistry.getOptions("OptTest")).toBeUndefined();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("drops options on unregister", () => {
|
|
329
|
+
componentRegistry.register("OptTest", noop, { bubbleChrome: false });
|
|
330
|
+
componentRegistry.unregister("OptTest");
|
|
331
|
+
expect(componentRegistry.getOptions("OptTest")).toBeUndefined();
|
|
332
|
+
});
|
|
333
|
+
});
|
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import type { ComponentContext, ComponentRenderer } from "./registry";
|
|
2
|
+
import type { PersonaArtifactFileMeta, PersonaArtifactRecord } from "../types";
|
|
3
|
+
import { fileTypeLabel, basenameOf } from "../utils/artifact-file";
|
|
4
|
+
import {
|
|
5
|
+
applyArtifactStatus,
|
|
6
|
+
clearArtifactStatusTracking,
|
|
7
|
+
resolveArtifactStatusLabel
|
|
8
|
+
} from "../utils/artifact-status-label";
|
|
9
|
+
import { createLabelButton } from "../utils/buttons";
|
|
10
|
+
import { buildArtifactActionButton } from "../utils/artifact-custom-actions";
|
|
11
|
+
import type { PersonaArtifactActionContext } from "../types";
|
|
2
12
|
|
|
3
13
|
/**
|
|
4
14
|
* Default artifact card renderer.
|
|
@@ -6,26 +16,32 @@ import type { ComponentContext, ComponentRenderer } from "./registry";
|
|
|
6
16
|
*/
|
|
7
17
|
function renderDefaultArtifactCard(
|
|
8
18
|
props: Record<string, unknown>,
|
|
9
|
-
|
|
19
|
+
context: ComponentContext
|
|
10
20
|
): HTMLElement {
|
|
11
|
-
const
|
|
21
|
+
const file =
|
|
22
|
+
props.file && typeof props.file === "object" && !Array.isArray(props.file)
|
|
23
|
+
? (props.file as PersonaArtifactFileMeta)
|
|
24
|
+
: undefined;
|
|
25
|
+
const rawTitle =
|
|
12
26
|
typeof props.title === "string" && props.title
|
|
13
27
|
? props.title
|
|
14
28
|
: "Untitled artifact";
|
|
29
|
+
// File artifacts show the basename (title stays the full path on the wire).
|
|
30
|
+
const title = file ? basenameOf(file.path) : rawTitle;
|
|
15
31
|
const artifactId =
|
|
16
32
|
typeof props.artifactId === "string" ? props.artifactId : "";
|
|
17
33
|
const status = props.status === "streaming" ? "streaming" : "complete";
|
|
18
34
|
const artifactType =
|
|
19
35
|
typeof props.artifactType === "string" ? props.artifactType : "markdown";
|
|
20
|
-
const subtitle =
|
|
21
|
-
|
|
36
|
+
const subtitle = file
|
|
37
|
+
? fileTypeLabel(file)
|
|
38
|
+
: artifactType === "component"
|
|
39
|
+
? "Component"
|
|
40
|
+
: "Document";
|
|
22
41
|
|
|
23
42
|
const root = document.createElement("div");
|
|
24
43
|
root.className =
|
|
25
|
-
"persona-flex persona-w-full persona-max-w-full persona-items-center persona-gap-3 persona-
|
|
26
|
-
root.style.border = "1px solid var(--persona-border, #e5e7eb)";
|
|
27
|
-
root.style.backgroundColor = "var(--persona-surface, #ffffff)";
|
|
28
|
-
root.style.cursor = "pointer";
|
|
44
|
+
"persona-artifact-card persona-flex persona-w-full persona-max-w-full persona-items-center persona-gap-3 persona-px-4 persona-py-3";
|
|
29
45
|
root.tabIndex = 0;
|
|
30
46
|
root.setAttribute("role", "button");
|
|
31
47
|
root.setAttribute("aria-label", `Open ${title} in artifact panel`);
|
|
@@ -56,35 +72,66 @@ function renderDefaultArtifactCard(
|
|
|
56
72
|
subtitleEl.style.color = "var(--persona-muted, #9ca3af)";
|
|
57
73
|
|
|
58
74
|
if (status === "streaming") {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
75
|
+
const artifactsCfg = context?.config?.features?.artifacts;
|
|
76
|
+
// The card re-renders only at streaming-start and completion (deltas flow to
|
|
77
|
+
// the pane / inline block, not here), so live counters don't tick on the
|
|
78
|
+
// card; the string / label form still applies. Build a minimal record from
|
|
79
|
+
// the card props so the shared resolver derives the same typeLabel + ctx.
|
|
80
|
+
const record: PersonaArtifactRecord = {
|
|
81
|
+
id: artifactId,
|
|
82
|
+
artifactType: artifactType === "component" ? "component" : "markdown",
|
|
83
|
+
title: rawTitle,
|
|
84
|
+
status: "streaming",
|
|
85
|
+
...(typeof props.markdown === "string" ? { markdown: props.markdown } : {}),
|
|
86
|
+
...(file ? { file } : {})
|
|
87
|
+
};
|
|
88
|
+
const resolved = resolveArtifactStatusLabel(record, artifactsCfg, "card");
|
|
89
|
+
applyArtifactStatus(subtitleEl, resolved, artifactsCfg);
|
|
69
90
|
} else {
|
|
91
|
+
if (artifactId) clearArtifactStatusTracking(artifactId);
|
|
70
92
|
subtitleEl.textContent = subtitle;
|
|
71
93
|
}
|
|
72
94
|
|
|
73
95
|
meta.append(titleEl, subtitleEl);
|
|
74
96
|
root.append(iconBox, meta);
|
|
75
97
|
|
|
76
|
-
// Download button
|
|
98
|
+
// Custom actions + Download button render only on complete artifacts, same
|
|
99
|
+
// as Download: content-dependent actions during streaming would act on
|
|
100
|
+
// partial content, so they wait until the artifact is fully materialized.
|
|
77
101
|
if (status === "complete") {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
102
|
+
// Custom card actions carry NO direct listeners (the card re-renders during
|
|
103
|
+
// streaming and is morphed by idiomorph). Clicks are handled by event
|
|
104
|
+
// delegation in ui.ts, keyed off data-artifact-custom-action, mirroring the
|
|
105
|
+
// Download button's data-download-artifact delegation.
|
|
106
|
+
const cardActions = context?.config?.features?.artifacts?.cardActions;
|
|
107
|
+
if (cardActions && cardActions.length > 0) {
|
|
108
|
+
const ctx: PersonaArtifactActionContext = {
|
|
109
|
+
artifactId: artifactId || null,
|
|
110
|
+
title,
|
|
111
|
+
artifactType,
|
|
112
|
+
markdown:
|
|
113
|
+
typeof props.markdown === "string" ? props.markdown : undefined,
|
|
114
|
+
file,
|
|
115
|
+
};
|
|
116
|
+
for (const action of cardActions) {
|
|
117
|
+
try {
|
|
118
|
+
if (action.visible === undefined || action.visible(ctx)) {
|
|
119
|
+
const btn = buildArtifactActionButton(action);
|
|
120
|
+
btn.setAttribute("data-artifact-custom-action", action.id);
|
|
121
|
+
btn.className = `${btn.className} persona-flex-shrink-0`;
|
|
122
|
+
root.append(btn);
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
// A single bad action must not take down the whole card.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const dl = createLabelButton({
|
|
131
|
+
label: "Download",
|
|
132
|
+
className: "persona-flex-shrink-0",
|
|
133
|
+
});
|
|
81
134
|
dl.title = `Download ${title}`;
|
|
82
|
-
dl.className =
|
|
83
|
-
"persona-flex-shrink-0 persona-rounded-md persona-px-3 persona-py-1.5 persona-text-xs persona-font-medium";
|
|
84
|
-
dl.style.border = "1px solid var(--persona-border, #e5e7eb)";
|
|
85
|
-
dl.style.color = "var(--persona-text, #1f2937)";
|
|
86
|
-
dl.style.backgroundColor = "transparent";
|
|
87
|
-
dl.style.cursor = "pointer";
|
|
88
135
|
dl.setAttribute("data-download-artifact", artifactId);
|
|
89
136
|
root.append(dl);
|
|
90
137
|
}
|