@runtypelabs/persona 4.7.0 → 4.9.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/README.md +1 -0
- 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-4ROVJ1gA.d.cts} +85 -3
- package/dist/animations/{types-CSmiKRVa.d.ts → types-4ROVJ1gA.d.ts} +85 -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/chunk-IO5VVUKP.js +3 -0
- package/dist/chunk-IPVK3KOM.js +1 -0
- package/dist/chunk-UPO4GUFC.js +1 -0
- package/dist/codegen.cjs +13 -13
- package/dist/codegen.js +14 -14
- package/dist/context-mentions-7S5KVUTG.js +169 -0
- package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
- package/dist/context-mentions-inline.cjs +4 -0
- package/dist/context-mentions-inline.d.cts +203 -0
- package/dist/context-mentions-inline.d.ts +203 -0
- package/dist/context-mentions-inline.js +4 -0
- package/dist/context-mentions.cjs +295 -0
- package/dist/context-mentions.d.cts +7025 -0
- package/dist/context-mentions.d.ts +7025 -0
- package/dist/context-mentions.js +295 -0
- package/dist/index.cjs +99 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1507 -182
- package/dist/index.d.ts +1507 -182
- package/dist/index.global.js +88 -56
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +99 -68
- 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.d.cts +17 -0
- package/dist/plugin-kit.d.ts +17 -0
- 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 +18 -17
- package/dist/smart-dom-reader.d.cts +1260 -16
- package/dist/smart-dom-reader.d.ts +1260 -16
- package/dist/smart-dom-reader.js +18 -17
- package/dist/testing.cjs +3 -3
- package/dist/testing.js +3 -3
- package/dist/theme-editor-preview.cjs +260 -58
- package/dist/theme-editor-preview.d.cts +1234 -15
- package/dist/theme-editor-preview.d.ts +1234 -15
- package/dist/theme-editor-preview.js +87 -58
- package/dist/theme-editor.cjs +10 -10
- package/dist/theme-editor.d.cts +1226 -15
- package/dist/theme-editor.d.ts +1226 -15
- package/dist/theme-editor.js +10 -10
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +76 -0
- package/dist/theme-reference.d.ts +76 -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 +16 -5
- package/src/artifacts-session.test.ts +178 -0
- package/src/client.test.ts +255 -1
- package/src/client.ts +157 -60
- 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/composer-parts.ts +3 -12
- package/src/components/context-mention-button.test.ts +70 -0
- package/src/components/context-mention-button.ts +82 -0
- package/src/components/context-mention-chip.ts +134 -0
- package/src/components/context-mention-menu.test.ts +508 -0
- package/src/components/context-mention-menu.ts +0 -0
- package/src/components/message-bubble.test.ts +175 -0
- package/src/components/message-bubble.ts +177 -19
- package/src/components/panel.ts +7 -10
- 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/context-mentions-bundle.test.ts +163 -0
- package/src/context-mentions-entry.ts +185 -0
- package/src/context-mentions-inline-entry.test.ts +136 -0
- package/src/context-mentions-inline-entry.ts +226 -0
- package/src/context-mentions-inline-loader.test.ts +30 -0
- package/src/context-mentions-inline-loader.ts +36 -0
- package/src/context-mentions-inline.ts +15 -0
- package/src/context-mentions-loader.ts +32 -0
- package/src/context-mentions.ts +16 -0
- package/src/defaults.ts +1 -0
- package/src/generated/runtype-openapi-contract.ts +55 -3
- package/src/index-core.ts +46 -1
- package/src/index-global.ts +51 -0
- package/src/index.ts +8 -0
- package/src/markdown-parsers-loader.test.ts +158 -0
- package/src/markdown-parsers-loader.ts +83 -27
- package/src/plugin-kit.test.ts +40 -0
- package/src/plugin-kit.ts +39 -5
- 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/session.mentions.test.ts +175 -0
- package/src/session.test.ts +52 -4
- package/src/session.ts +197 -27
- package/src/smart-dom-reader.test.ts +129 -2
- package/src/smart-dom-reader.ts +127 -1
- package/src/styles/context-mention-menu-css.ts +176 -0
- package/src/styles/widget.css +1002 -152
- package/src/theme-editor/preview.ts +2 -0
- package/src/theme-editor/sections.test.ts +26 -1
- package/src/theme-editor/sections.ts +13 -5
- package/src/theme-reference.ts +2 -2
- package/src/tool-call-display-defaults.test.ts +1 -0
- package/src/types/theme.ts +79 -0
- package/src/types.ts +1041 -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.mention-submit.test.ts +235 -0
- package/src/ui.tool-display.test.ts +51 -0
- package/src/ui.ts +998 -159
- 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/chunk-loader.test.ts +97 -0
- package/src/utils/chunk-loader.ts +88 -0
- package/src/utils/code-highlight.test.ts +186 -0
- package/src/utils/code-highlight.ts +400 -0
- package/src/utils/composer-contenteditable.test.ts +507 -0
- package/src/utils/composer-contenteditable.ts +626 -0
- package/src/utils/composer-document.test.ts +280 -0
- package/src/utils/composer-document.ts +293 -0
- package/src/utils/composer-history.test.ts +35 -7
- package/src/utils/composer-history.ts +30 -20
- package/src/utils/composer-input.ts +159 -0
- package/src/utils/context-mention-controller.test.ts +1215 -0
- package/src/utils/context-mention-controller.ts +1186 -0
- package/src/utils/context-mention-manager.test.ts +422 -0
- package/src/utils/context-mention-manager.ts +410 -0
- package/src/utils/context-mention-orchestrator.test.ts +538 -0
- package/src/utils/context-mention-orchestrator.ts +348 -0
- package/src/utils/icons.ts +2 -0
- package/src/utils/live-region.test.ts +108 -0
- package/src/utils/live-region.ts +94 -0
- package/src/utils/mention-channels.ts +63 -0
- package/src/utils/mention-llm-format.test.ts +91 -0
- package/src/utils/mention-llm-format.ts +79 -0
- package/src/utils/mention-matcher.test.ts +86 -0
- package/src/utils/mention-matcher.ts +221 -0
- package/src/utils/mention-token.ts +72 -0
- package/src/utils/mention-trigger.test.ts +155 -0
- package/src/utils/mention-trigger.ts +156 -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 +76 -4
- package/src/utils/theme.ts +7 -0
- package/src/utils/tokens.ts +118 -11
- 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,636 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { createAgentExperience } from "./ui";
|
|
6
|
+
import type { AgentWidgetConfig, PersonaArtifactDisplayMode } from "./types";
|
|
7
|
+
|
|
8
|
+
beforeAll(() => {
|
|
9
|
+
// jsdom does not implement matchMedia; the pane's layout code touches it.
|
|
10
|
+
if (!window.matchMedia) {
|
|
11
|
+
window.matchMedia = ((query: string) => ({
|
|
12
|
+
matches: false,
|
|
13
|
+
media: query,
|
|
14
|
+
onchange: null,
|
|
15
|
+
addListener: () => {},
|
|
16
|
+
removeListener: () => {},
|
|
17
|
+
addEventListener: () => {},
|
|
18
|
+
removeEventListener: () => {},
|
|
19
|
+
dispatchEvent: () => false,
|
|
20
|
+
})) as unknown as typeof window.matchMedia;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Regression tests for the artifact pane auto-open gating by display mode
|
|
26
|
+
* (docs/artifact-display-modes-spec.md, section 1 notes).
|
|
27
|
+
*
|
|
28
|
+
* The pane's own `update()` unhides its shell whenever records exist, so the
|
|
29
|
+
* gate in `syncArtifactPane` must re-assert `persona-hidden` for artifacts
|
|
30
|
+
* whose resolved mode is "card" or "inline". The original implementation only
|
|
31
|
+
* removed the class in the show branch and never re-added it, so inline-mode
|
|
32
|
+
* artifacts still auto-opened the pane.
|
|
33
|
+
*/
|
|
34
|
+
describe("artifact pane auto-open gating by display mode", () => {
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
document.body.innerHTML = "";
|
|
37
|
+
try {
|
|
38
|
+
window.localStorage.clear();
|
|
39
|
+
} catch {
|
|
40
|
+
/* jsdom edge cases */
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
function mountWithDisplay(display?: PersonaArtifactDisplayMode) {
|
|
45
|
+
const mount = document.createElement("div");
|
|
46
|
+
document.body.appendChild(mount);
|
|
47
|
+
const config: AgentWidgetConfig = {
|
|
48
|
+
apiUrl: "https://api.example.com/chat",
|
|
49
|
+
launcher: { enabled: false },
|
|
50
|
+
features: {
|
|
51
|
+
artifacts: {
|
|
52
|
+
enabled: true,
|
|
53
|
+
allowedTypes: ["markdown", "component"],
|
|
54
|
+
...(display ? { display } : {}),
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
const controller = createAgentExperience(mount, config);
|
|
59
|
+
return { mount, controller };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function paneEl(mount: HTMLElement): HTMLElement {
|
|
63
|
+
const el = mount.querySelector<HTMLElement>(".persona-artifact-pane");
|
|
64
|
+
expect(el).not.toBeNull();
|
|
65
|
+
return el!;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function upsertSample(controller: ReturnType<typeof createAgentExperience>) {
|
|
69
|
+
controller.upsertArtifact({
|
|
70
|
+
id: "gating-test",
|
|
71
|
+
title: "Gating test",
|
|
72
|
+
artifactType: "markdown",
|
|
73
|
+
content: "# Hello",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
it('auto-opens the pane for "panel" mode (default)', () => {
|
|
78
|
+
const { mount, controller } = mountWithDisplay();
|
|
79
|
+
upsertSample(controller);
|
|
80
|
+
expect(paneEl(mount).classList.contains("persona-hidden")).toBe(false);
|
|
81
|
+
controller.destroy();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('keeps the pane hidden for "inline" mode artifacts', () => {
|
|
85
|
+
const { mount, controller } = mountWithDisplay("inline");
|
|
86
|
+
upsertSample(controller);
|
|
87
|
+
expect(paneEl(mount).classList.contains("persona-hidden")).toBe(true);
|
|
88
|
+
controller.destroy();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('keeps the pane hidden for "card" mode until an explicit open', () => {
|
|
92
|
+
const { mount, controller } = mountWithDisplay("card");
|
|
93
|
+
upsertSample(controller);
|
|
94
|
+
expect(paneEl(mount).classList.contains("persona-hidden")).toBe(true);
|
|
95
|
+
|
|
96
|
+
controller.showArtifacts();
|
|
97
|
+
expect(paneEl(mount).classList.contains("persona-hidden")).toBe(false);
|
|
98
|
+
controller.destroy();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("does not open the pane when the card's Download button is clicked", () => {
|
|
102
|
+
// jsdom has no object URLs; the download handler needs both to run fully.
|
|
103
|
+
const urlWithBlob = URL as unknown as {
|
|
104
|
+
createObjectURL?: (b: Blob) => string;
|
|
105
|
+
revokeObjectURL?: (u: string) => void;
|
|
106
|
+
};
|
|
107
|
+
const origCreate = urlWithBlob.createObjectURL;
|
|
108
|
+
const origRevoke = urlWithBlob.revokeObjectURL;
|
|
109
|
+
urlWithBlob.createObjectURL = () => "blob:persona-test";
|
|
110
|
+
urlWithBlob.revokeObjectURL = () => {};
|
|
111
|
+
try {
|
|
112
|
+
const { mount, controller } = mountWithDisplay("card");
|
|
113
|
+
upsertSample(controller);
|
|
114
|
+
|
|
115
|
+
// Recreate the reference card the way the default renderer shapes it
|
|
116
|
+
// (data-open-artifact root wrapping a data-download-artifact button),
|
|
117
|
+
// mounted inside a real message element so clicks bubble through the
|
|
118
|
+
// messages-wrapper delegation in ui.ts.
|
|
119
|
+
controller.injectAssistantMessage({ content: "See the artifact below." });
|
|
120
|
+
const msgEl = mount.querySelector("[data-message-id]");
|
|
121
|
+
expect(msgEl).not.toBeNull();
|
|
122
|
+
const card = document.createElement("div");
|
|
123
|
+
card.setAttribute("data-open-artifact", "gating-test");
|
|
124
|
+
const dl = document.createElement("button");
|
|
125
|
+
dl.setAttribute("data-download-artifact", "gating-test");
|
|
126
|
+
card.appendChild(dl);
|
|
127
|
+
msgEl!.appendChild(card);
|
|
128
|
+
|
|
129
|
+
// Download must not double as an open: the two delegated listeners sit
|
|
130
|
+
// on the same element, so stopPropagation() alone cannot separate them.
|
|
131
|
+
dl.click();
|
|
132
|
+
expect(paneEl(mount).classList.contains("persona-hidden")).toBe(true);
|
|
133
|
+
|
|
134
|
+
// Clicking the card itself still opens the pane.
|
|
135
|
+
card.click();
|
|
136
|
+
expect(paneEl(mount).classList.contains("persona-hidden")).toBe(false);
|
|
137
|
+
controller.destroy();
|
|
138
|
+
} finally {
|
|
139
|
+
urlWithBlob.createObjectURL = origCreate;
|
|
140
|
+
urlWithBlob.revokeObjectURL = origRevoke;
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Card custom actions (features.artifacts.cardActions) render on complete
|
|
147
|
+
* reference cards and are wired through the same messages-wrapper delegation as
|
|
148
|
+
* the Download button: looked up by id from fresh config at click time, and
|
|
149
|
+
* explicitly skipped by the card-open listener so a click never opens the pane.
|
|
150
|
+
*/
|
|
151
|
+
describe("artifact card custom actions (full widget)", () => {
|
|
152
|
+
afterEach(() => {
|
|
153
|
+
document.body.innerHTML = "";
|
|
154
|
+
try {
|
|
155
|
+
window.localStorage.clear();
|
|
156
|
+
} catch {
|
|
157
|
+
/* jsdom edge cases */
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
type CardActions = NonNullable<
|
|
162
|
+
NonNullable<NonNullable<AgentWidgetConfig["features"]>["artifacts"]>["cardActions"]
|
|
163
|
+
>;
|
|
164
|
+
|
|
165
|
+
function configWith(cardActions: CardActions): AgentWidgetConfig {
|
|
166
|
+
return {
|
|
167
|
+
apiUrl: "https://api.example.com/chat",
|
|
168
|
+
launcher: { enabled: false },
|
|
169
|
+
features: {
|
|
170
|
+
artifacts: {
|
|
171
|
+
enabled: true,
|
|
172
|
+
allowedTypes: ["markdown", "component"],
|
|
173
|
+
display: "card",
|
|
174
|
+
cardActions,
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function mountWith(cardActions: CardActions) {
|
|
181
|
+
const mount = document.createElement("div");
|
|
182
|
+
document.body.appendChild(mount);
|
|
183
|
+
const controller = createAgentExperience(mount, configWith(cardActions));
|
|
184
|
+
return { mount, controller };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function paneEl(mount: HTMLElement): HTMLElement {
|
|
188
|
+
const el = mount.querySelector<HTMLElement>(".persona-artifact-pane");
|
|
189
|
+
expect(el).not.toBeNull();
|
|
190
|
+
return el!;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function upsertSample(controller: ReturnType<typeof createAgentExperience>) {
|
|
194
|
+
controller.upsertArtifact({
|
|
195
|
+
id: "gating-test",
|
|
196
|
+
title: "Gating test",
|
|
197
|
+
artifactType: "markdown",
|
|
198
|
+
content: "# Hello",
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Fabricate the reference card the way the default renderer shapes it (a
|
|
203
|
+
// data-open-artifact root holding a data-artifact-custom-action button),
|
|
204
|
+
// mounted inside a real message so clicks bubble through the ui.ts delegation.
|
|
205
|
+
function fabricateCard(
|
|
206
|
+
mount: HTMLElement,
|
|
207
|
+
controller: ReturnType<typeof createAgentExperience>,
|
|
208
|
+
actionId: string
|
|
209
|
+
): HTMLButtonElement {
|
|
210
|
+
controller.injectAssistantMessage({ content: "See the artifact below." });
|
|
211
|
+
const msgEl = mount.querySelector("[data-message-id]");
|
|
212
|
+
expect(msgEl).not.toBeNull();
|
|
213
|
+
const card = document.createElement("div");
|
|
214
|
+
card.setAttribute("data-open-artifact", "gating-test");
|
|
215
|
+
const btn = document.createElement("button");
|
|
216
|
+
btn.setAttribute("data-artifact-custom-action", actionId);
|
|
217
|
+
card.appendChild(btn);
|
|
218
|
+
msgEl!.appendChild(card);
|
|
219
|
+
return btn;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
it("invokes the action onClick with resolved context and does not open the pane", () => {
|
|
223
|
+
const onClick = vi.fn();
|
|
224
|
+
const { mount, controller } = mountWith([
|
|
225
|
+
{ id: "save", label: "Save to Drive", icon: "star", onClick },
|
|
226
|
+
]);
|
|
227
|
+
upsertSample(controller);
|
|
228
|
+
const btn = fabricateCard(mount, controller, "save");
|
|
229
|
+
|
|
230
|
+
btn.click();
|
|
231
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
232
|
+
const ctx = onClick.mock.calls[0][0];
|
|
233
|
+
expect(ctx.artifactId).toBe("gating-test");
|
|
234
|
+
// Content resolves from the live session record (upserted markdown).
|
|
235
|
+
expect(ctx.markdown).toBe("# Hello");
|
|
236
|
+
|
|
237
|
+
// Card display mode: the delegated action must not open the pane.
|
|
238
|
+
expect(paneEl(mount).classList.contains("persona-hidden")).toBe(true);
|
|
239
|
+
controller.destroy();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("looks up the handler by id from fresh config on each click (live update)", () => {
|
|
243
|
+
const oldHandler = vi.fn();
|
|
244
|
+
const newHandler = vi.fn();
|
|
245
|
+
const { mount, controller } = mountWith([
|
|
246
|
+
{ id: "save", label: "Save", icon: "star", onClick: oldHandler },
|
|
247
|
+
]);
|
|
248
|
+
upsertSample(controller);
|
|
249
|
+
|
|
250
|
+
const firstBtn = fabricateCard(mount, controller, "save");
|
|
251
|
+
firstBtn.click();
|
|
252
|
+
expect(oldHandler).toHaveBeenCalledTimes(1);
|
|
253
|
+
expect(newHandler).not.toHaveBeenCalled();
|
|
254
|
+
|
|
255
|
+
// Swap the action of the same id via a live config update.
|
|
256
|
+
controller.update(
|
|
257
|
+
configWith([{ id: "save", label: "Save", icon: "star", onClick: newHandler }])
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
// Re-fabricate a card (the transcript may re-render on update) and click.
|
|
261
|
+
const secondBtn = fabricateCard(mount, controller, "save");
|
|
262
|
+
secondBtn.click();
|
|
263
|
+
expect(oldHandler).toHaveBeenCalledTimes(1);
|
|
264
|
+
expect(newHandler).toHaveBeenCalledTimes(1);
|
|
265
|
+
controller.destroy();
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The expand toggle lives in the artifact pane toolbar (opt-in via
|
|
271
|
+
* layout.showExpandToggle). ui.ts owns the runtime-only expanded state: clicking
|
|
272
|
+
* the toolbar button toggles `persona-artifact-expanded` on the mount root, an
|
|
273
|
+
* onArtifactAction handler can intercept it, and the state resets when the pane
|
|
274
|
+
* is dismissed.
|
|
275
|
+
*/
|
|
276
|
+
describe("artifact pane expand toggle (full widget)", () => {
|
|
277
|
+
afterEach(() => {
|
|
278
|
+
document.body.innerHTML = "";
|
|
279
|
+
try {
|
|
280
|
+
window.localStorage.clear();
|
|
281
|
+
} catch {
|
|
282
|
+
/* jsdom edge cases */
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
function mount(
|
|
287
|
+
onArtifactAction?: NonNullable<
|
|
288
|
+
NonNullable<AgentWidgetConfig["features"]>["artifacts"]
|
|
289
|
+
>["onArtifactAction"]
|
|
290
|
+
) {
|
|
291
|
+
const mountEl = document.createElement("div");
|
|
292
|
+
document.body.appendChild(mountEl);
|
|
293
|
+
const config: AgentWidgetConfig = {
|
|
294
|
+
apiUrl: "https://api.example.com/chat",
|
|
295
|
+
launcher: { enabled: false },
|
|
296
|
+
features: {
|
|
297
|
+
artifacts: {
|
|
298
|
+
enabled: true,
|
|
299
|
+
allowedTypes: ["markdown", "component"],
|
|
300
|
+
layout: { showExpandToggle: true },
|
|
301
|
+
...(onArtifactAction ? { onArtifactAction } : {}),
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
const controller = createAgentExperience(mountEl, config);
|
|
306
|
+
return { mount: mountEl, controller };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function paneEl(mountEl: HTMLElement): HTMLElement {
|
|
310
|
+
const el = mountEl.querySelector<HTMLElement>(".persona-artifact-pane");
|
|
311
|
+
expect(el).not.toBeNull();
|
|
312
|
+
return el!;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function expandBtn(mountEl: HTMLElement): HTMLButtonElement {
|
|
316
|
+
const el = paneEl(mountEl).querySelector<HTMLButtonElement>(
|
|
317
|
+
".persona-artifact-expand-btn"
|
|
318
|
+
);
|
|
319
|
+
expect(el).not.toBeNull();
|
|
320
|
+
return el!;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function upsertSample(controller: ReturnType<typeof createAgentExperience>) {
|
|
324
|
+
controller.upsertArtifact({
|
|
325
|
+
id: "expand-test",
|
|
326
|
+
title: "Expand test",
|
|
327
|
+
artifactType: "markdown",
|
|
328
|
+
content: "# Hello",
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
it("toggles persona-artifact-expanded on the mount root when the button is clicked", () => {
|
|
333
|
+
const { mount: mountEl, controller } = mount();
|
|
334
|
+
upsertSample(controller);
|
|
335
|
+
expect(mountEl.classList.contains("persona-artifact-expanded")).toBe(false);
|
|
336
|
+
|
|
337
|
+
expandBtn(mountEl).click();
|
|
338
|
+
expect(mountEl.classList.contains("persona-artifact-expanded")).toBe(true);
|
|
339
|
+
|
|
340
|
+
expandBtn(mountEl).click();
|
|
341
|
+
expect(mountEl.classList.contains("persona-artifact-expanded")).toBe(false);
|
|
342
|
+
controller.destroy();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("does not add the class when onArtifactAction returns true for type 'expand'", () => {
|
|
346
|
+
const { mount: mountEl, controller } = mount((action) =>
|
|
347
|
+
action.type === "expand" ? true : undefined
|
|
348
|
+
);
|
|
349
|
+
upsertSample(controller);
|
|
350
|
+
|
|
351
|
+
expandBtn(mountEl).click();
|
|
352
|
+
expect(mountEl.classList.contains("persona-artifact-expanded")).toBe(false);
|
|
353
|
+
controller.destroy();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("clears persona-artifact-expanded when the pane is dismissed", () => {
|
|
357
|
+
const { mount: mountEl, controller } = mount();
|
|
358
|
+
upsertSample(controller);
|
|
359
|
+
|
|
360
|
+
expandBtn(mountEl).click();
|
|
361
|
+
expect(mountEl.classList.contains("persona-artifact-expanded")).toBe(true);
|
|
362
|
+
|
|
363
|
+
// Default toolbar preset's Close control dismisses the pane.
|
|
364
|
+
const close = paneEl(mountEl).querySelector<HTMLButtonElement>(
|
|
365
|
+
'[aria-label="Close"]'
|
|
366
|
+
);
|
|
367
|
+
expect(close).not.toBeNull();
|
|
368
|
+
close!.click();
|
|
369
|
+
expect(mountEl.classList.contains("persona-artifact-expanded")).toBe(false);
|
|
370
|
+
controller.destroy();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("reveals the toggle via a live config update and collapses when disabled again", () => {
|
|
374
|
+
const mountEl = document.createElement("div");
|
|
375
|
+
document.body.appendChild(mountEl);
|
|
376
|
+
const base: AgentWidgetConfig = {
|
|
377
|
+
apiUrl: "https://api.example.com/chat",
|
|
378
|
+
launcher: { enabled: false },
|
|
379
|
+
features: {
|
|
380
|
+
artifacts: { enabled: true, allowedTypes: ["markdown", "component"] },
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
const controller = createAgentExperience(mountEl, base);
|
|
384
|
+
upsertSample(controller);
|
|
385
|
+
// The pane is built once at mount, so the button exists but stays hidden
|
|
386
|
+
// until a config carrying showExpandToggle arrives via update().
|
|
387
|
+
expect(expandBtn(mountEl).classList.contains("persona-hidden")).toBe(true);
|
|
388
|
+
|
|
389
|
+
controller.update({
|
|
390
|
+
...base,
|
|
391
|
+
features: {
|
|
392
|
+
artifacts: {
|
|
393
|
+
enabled: true,
|
|
394
|
+
allowedTypes: ["markdown", "component"],
|
|
395
|
+
layout: { showExpandToggle: true },
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
expect(expandBtn(mountEl).classList.contains("persona-hidden")).toBe(false);
|
|
400
|
+
|
|
401
|
+
expandBtn(mountEl).click();
|
|
402
|
+
expect(mountEl.classList.contains("persona-artifact-expanded")).toBe(true);
|
|
403
|
+
|
|
404
|
+
// Turning the toggle back off hides the button and collapses the pane.
|
|
405
|
+
controller.update(base);
|
|
406
|
+
expect(mountEl.classList.contains("persona-artifact-expanded")).toBe(false);
|
|
407
|
+
expect(expandBtn(mountEl).classList.contains("persona-hidden")).toBe(true);
|
|
408
|
+
controller.destroy();
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Inline chrome delegation (display: "inline"). The inline block carries
|
|
414
|
+
* data-artifact-inline; its custom-action buttons resolve from
|
|
415
|
+
* features.artifacts.inlineActions (not cardActions), and its Expand button
|
|
416
|
+
* opens the pane through the same open path as a card click, interceptable via
|
|
417
|
+
* onArtifactAction({ type: "open" }).
|
|
418
|
+
*/
|
|
419
|
+
describe("inline artifact chrome delegation (full widget)", () => {
|
|
420
|
+
afterEach(() => {
|
|
421
|
+
document.body.innerHTML = "";
|
|
422
|
+
try {
|
|
423
|
+
window.localStorage.clear();
|
|
424
|
+
} catch {
|
|
425
|
+
/* jsdom edge cases */
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
type Artifacts = NonNullable<
|
|
430
|
+
NonNullable<AgentWidgetConfig["features"]>["artifacts"]
|
|
431
|
+
>;
|
|
432
|
+
|
|
433
|
+
function mountInline(artifacts: Partial<Artifacts>) {
|
|
434
|
+
const mount = document.createElement("div");
|
|
435
|
+
document.body.appendChild(mount);
|
|
436
|
+
const config: AgentWidgetConfig = {
|
|
437
|
+
apiUrl: "https://api.example.com/chat",
|
|
438
|
+
launcher: { enabled: false },
|
|
439
|
+
features: {
|
|
440
|
+
artifacts: {
|
|
441
|
+
enabled: true,
|
|
442
|
+
allowedTypes: ["markdown", "component"],
|
|
443
|
+
display: "inline",
|
|
444
|
+
...artifacts,
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
const controller = createAgentExperience(mount, config);
|
|
449
|
+
return { mount, controller };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function paneEl(mount: HTMLElement): HTMLElement {
|
|
453
|
+
const el = mount.querySelector<HTMLElement>(".persona-artifact-pane");
|
|
454
|
+
expect(el).not.toBeNull();
|
|
455
|
+
return el!;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function upsertSample(controller: ReturnType<typeof createAgentExperience>) {
|
|
459
|
+
controller.upsertArtifact({
|
|
460
|
+
id: "inline-test",
|
|
461
|
+
title: "Inline test",
|
|
462
|
+
artifactType: "markdown",
|
|
463
|
+
content: "# Hello",
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Fabricate an inline block the way the default renderer shapes it: a
|
|
468
|
+
// data-artifact-inline root holding a delegated button, mounted inside a real
|
|
469
|
+
// message so clicks bubble through the ui.ts delegation.
|
|
470
|
+
function fabricateInline(
|
|
471
|
+
mount: HTMLElement,
|
|
472
|
+
controller: ReturnType<typeof createAgentExperience>,
|
|
473
|
+
attr: string,
|
|
474
|
+
value: string
|
|
475
|
+
): HTMLButtonElement {
|
|
476
|
+
controller.injectAssistantMessage({ content: "See the artifact above." });
|
|
477
|
+
const msgEl = mount.querySelector("[data-message-id]");
|
|
478
|
+
expect(msgEl).not.toBeNull();
|
|
479
|
+
const block = document.createElement("div");
|
|
480
|
+
block.setAttribute("data-artifact-inline", "inline-test");
|
|
481
|
+
const btn = document.createElement("button");
|
|
482
|
+
btn.setAttribute(attr, value);
|
|
483
|
+
block.appendChild(btn);
|
|
484
|
+
msgEl!.appendChild(block);
|
|
485
|
+
return btn;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
it("resolves a custom action from inlineActions (not cardActions) with content", () => {
|
|
489
|
+
const inlineHandler = vi.fn();
|
|
490
|
+
const cardHandler = vi.fn();
|
|
491
|
+
const { mount, controller } = mountInline({
|
|
492
|
+
inlineActions: [
|
|
493
|
+
{ id: "log", label: "Log", icon: "star", onClick: inlineHandler },
|
|
494
|
+
],
|
|
495
|
+
cardActions: [
|
|
496
|
+
{ id: "log", label: "Log", icon: "star", onClick: cardHandler },
|
|
497
|
+
],
|
|
498
|
+
});
|
|
499
|
+
upsertSample(controller);
|
|
500
|
+
const btn = fabricateInline(mount, controller, "data-artifact-custom-action", "log");
|
|
501
|
+
|
|
502
|
+
btn.click();
|
|
503
|
+
// The inline-surface list wins over cardActions for the same id.
|
|
504
|
+
expect(inlineHandler).toHaveBeenCalledTimes(1);
|
|
505
|
+
expect(cardHandler).not.toHaveBeenCalled();
|
|
506
|
+
const ctx = inlineHandler.mock.calls[0][0];
|
|
507
|
+
expect(ctx.artifactId).toBe("inline-test");
|
|
508
|
+
expect(ctx.markdown).toBe("# Hello");
|
|
509
|
+
controller.destroy();
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("opens the pane fullscreen when the Expand button is clicked", () => {
|
|
513
|
+
// The inline block already shows the full preview at chat width, so
|
|
514
|
+
// Expand means "fullscreen this file": it skips the redundant split view
|
|
515
|
+
// even without layout.showExpandToggle (the pinned expansion survives
|
|
516
|
+
// the toggle gate; Close is the exit).
|
|
517
|
+
const { mount, controller } = mountInline({});
|
|
518
|
+
upsertSample(controller);
|
|
519
|
+
// Inline mode keeps the pane closed until an explicit open.
|
|
520
|
+
expect(paneEl(mount).classList.contains("persona-hidden")).toBe(true);
|
|
521
|
+
|
|
522
|
+
const btn = fabricateInline(
|
|
523
|
+
mount,
|
|
524
|
+
controller,
|
|
525
|
+
"data-expand-artifact-inline",
|
|
526
|
+
"inline-test"
|
|
527
|
+
);
|
|
528
|
+
btn.click();
|
|
529
|
+
expect(paneEl(mount).classList.contains("persona-hidden")).toBe(false);
|
|
530
|
+
expect(mount.classList.contains("persona-artifact-expanded")).toBe(true);
|
|
531
|
+
controller.destroy();
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("Close exits the fullscreen pane and clears the pinned expansion", () => {
|
|
535
|
+
const { mount, controller } = mountInline({});
|
|
536
|
+
upsertSample(controller);
|
|
537
|
+
const btn = fabricateInline(
|
|
538
|
+
mount,
|
|
539
|
+
controller,
|
|
540
|
+
"data-expand-artifact-inline",
|
|
541
|
+
"inline-test"
|
|
542
|
+
);
|
|
543
|
+
btn.click();
|
|
544
|
+
expect(mount.classList.contains("persona-artifact-expanded")).toBe(true);
|
|
545
|
+
|
|
546
|
+
const close = paneEl(mount).querySelector<HTMLButtonElement>(
|
|
547
|
+
'[aria-label="Close"]'
|
|
548
|
+
);
|
|
549
|
+
expect(close).not.toBeNull();
|
|
550
|
+
close!.click();
|
|
551
|
+
expect(paneEl(mount).classList.contains("persona-hidden")).toBe(true);
|
|
552
|
+
expect(mount.classList.contains("persona-artifact-expanded")).toBe(false);
|
|
553
|
+
controller.destroy();
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it("does not open the pane when onArtifactAction intercepts the open", () => {
|
|
557
|
+
const { mount, controller } = mountInline({
|
|
558
|
+
onArtifactAction: (action) =>
|
|
559
|
+
action.type === "open" ? true : undefined,
|
|
560
|
+
});
|
|
561
|
+
upsertSample(controller);
|
|
562
|
+
const btn = fabricateInline(
|
|
563
|
+
mount,
|
|
564
|
+
controller,
|
|
565
|
+
"data-expand-artifact-inline",
|
|
566
|
+
"inline-test"
|
|
567
|
+
);
|
|
568
|
+
btn.click();
|
|
569
|
+
expect(paneEl(mount).classList.contains("persona-hidden")).toBe(true);
|
|
570
|
+
controller.destroy();
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Lazy pane rendering regression (double artifact-script execution). With
|
|
576
|
+
* display:"inline" a streamed HTML file artifact used to build TWO sandboxed
|
|
577
|
+
* srcdoc iframes at once: one in the inline transcript block (correct) and one
|
|
578
|
+
* inside the hidden .persona-artifact-pane, because the pane's update()
|
|
579
|
+
* unconditionally rendered its preview body. That executed the artifact's
|
|
580
|
+
* scripts twice. The pane must render its preview lazily and skip it while the
|
|
581
|
+
* surface is hidden.
|
|
582
|
+
*/
|
|
583
|
+
describe("artifact pane lazy preview (no double iframe in hidden inline pane)", () => {
|
|
584
|
+
afterEach(() => {
|
|
585
|
+
document.body.innerHTML = "";
|
|
586
|
+
try {
|
|
587
|
+
window.localStorage.clear();
|
|
588
|
+
} catch {
|
|
589
|
+
/* jsdom edge cases */
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
function mountInlineFile() {
|
|
594
|
+
const mount = document.createElement("div");
|
|
595
|
+
document.body.appendChild(mount);
|
|
596
|
+
const config: AgentWidgetConfig = {
|
|
597
|
+
apiUrl: "https://api.example.com/chat",
|
|
598
|
+
launcher: { enabled: false },
|
|
599
|
+
sanitize: false,
|
|
600
|
+
features: {
|
|
601
|
+
artifacts: {
|
|
602
|
+
enabled: true,
|
|
603
|
+
allowedTypes: ["markdown", "component"],
|
|
604
|
+
display: "inline",
|
|
605
|
+
// loading:false → an iframe would be built synchronously if rendered.
|
|
606
|
+
filePreview: { loading: false },
|
|
607
|
+
},
|
|
608
|
+
},
|
|
609
|
+
};
|
|
610
|
+
const controller = createAgentExperience(mount, config);
|
|
611
|
+
return { mount, controller };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
it("keeps the hidden inline pane free of a preview iframe (only the transcript renders one)", () => {
|
|
615
|
+
const { mount, controller } = mountInlineFile();
|
|
616
|
+
controller.upsertArtifact({
|
|
617
|
+
id: "cat",
|
|
618
|
+
artifactType: "markdown",
|
|
619
|
+
title: "outputs/cat.html",
|
|
620
|
+
content: "```html\n<h1>hi</h1>\n```",
|
|
621
|
+
file: { path: "outputs/cat.html", mimeType: "text/html", language: "html" },
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
const pane = mount.querySelector<HTMLElement>(".persona-artifact-pane")!;
|
|
625
|
+
expect(pane.classList.contains("persona-hidden")).toBe(true);
|
|
626
|
+
// The pane surface is hidden in inline mode, so it must not have built a
|
|
627
|
+
// second sandboxed iframe next to the inline transcript preview.
|
|
628
|
+
expect(pane.querySelector("iframe")).toBeNull();
|
|
629
|
+
|
|
630
|
+
// Opening the pane (explicit reveal) renders the preview from recorded state.
|
|
631
|
+
controller.showArtifacts();
|
|
632
|
+
expect(pane.classList.contains("persona-hidden")).toBe(false);
|
|
633
|
+
expect(pane.querySelector("iframe.persona-artifact-iframe")).not.toBeNull();
|
|
634
|
+
controller.destroy();
|
|
635
|
+
});
|
|
636
|
+
});
|