@runtypelabs/persona 3.18.0 → 3.19.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 -1
- package/dist/index.cjs +47 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +281 -4
- package/dist/index.d.ts +281 -4
- package/dist/index.global.js +102 -1636
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +47 -47
- package/dist/index.js.map +1 -1
- package/dist/theme-editor.cjs +1438 -619
- package/dist/theme-editor.d.cts +119 -1
- package/dist/theme-editor.d.ts +119 -1
- package/dist/theme-editor.js +1552 -619
- package/dist/widget.css +348 -0
- package/package.json +1 -1
- package/src/components/composer-builder.test.ts +52 -0
- package/src/components/composer-builder.ts +67 -490
- package/src/components/composer-parts.test.ts +152 -0
- package/src/components/composer-parts.ts +452 -0
- package/src/components/header-builder.ts +22 -299
- package/src/components/header-parts.ts +360 -0
- package/src/components/panel.test.ts +61 -0
- package/src/components/panel.ts +262 -5
- package/src/components/pill-composer-builder.test.ts +85 -0
- package/src/components/pill-composer-builder.ts +183 -0
- package/src/index.ts +4 -0
- package/src/runtime/init.ts +4 -2
- package/src/runtime/persist-state.test.ts +152 -0
- package/src/styles/widget.css +348 -0
- package/src/types.ts +121 -1
- package/src/ui.component-directive.test.ts +183 -0
- package/src/ui.composer-bar.test.ts +1009 -0
- package/src/ui.ts +809 -72
- package/src/utils/attachment-manager.ts +1 -1
- package/src/utils/dock.test.ts +45 -0
- package/src/utils/dock.ts +3 -0
- package/src/utils/icons.ts +314 -58
- package/src/utils/stream-animation.ts +7 -2
|
@@ -0,0 +1,1009 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { createAgentExperience } from "./ui";
|
|
6
|
+
|
|
7
|
+
describe("createAgentExperience composer-bar mode", () => {
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
document.body.innerHTML = "";
|
|
10
|
+
// The widget's default localStorage adapter persists chat history
|
|
11
|
+
// across createAgentExperience calls. Clear it so each test starts
|
|
12
|
+
// with an empty session.
|
|
13
|
+
try {
|
|
14
|
+
window.localStorage.clear();
|
|
15
|
+
} catch {
|
|
16
|
+
/* jsdom edge cases */
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("starts collapsed with pill geometry — bottom-centered, configured width on pillRoot", () => {
|
|
21
|
+
const mount = document.createElement("div");
|
|
22
|
+
document.body.appendChild(mount);
|
|
23
|
+
|
|
24
|
+
const controller = createAgentExperience(mount, {
|
|
25
|
+
apiUrl: "https://api.example.com/chat",
|
|
26
|
+
launcher: {
|
|
27
|
+
mountMode: "composer-bar",
|
|
28
|
+
composerBar: { collapsedMaxWidth: "640px", bottomOffset: "20px" },
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const wrapper = mount.querySelector<HTMLElement>(".persona-widget-wrapper[data-persona-composer-bar]");
|
|
33
|
+
const pillRoot = mount.querySelector<HTMLElement>(".persona-widget-pill-root");
|
|
34
|
+
expect(wrapper).not.toBeNull();
|
|
35
|
+
expect(pillRoot).not.toBeNull();
|
|
36
|
+
expect(wrapper!.dataset.state).toBe("collapsed");
|
|
37
|
+
expect(wrapper!.dataset.expandedSize).toBe("anchored");
|
|
38
|
+
// pillRoot mirrors state attributes so peek/pill rules can cascade.
|
|
39
|
+
expect(pillRoot!.dataset.state).toBe("collapsed");
|
|
40
|
+
expect(pillRoot!.dataset.expandedSize).toBe("anchored");
|
|
41
|
+
// Pill geometry now lives on pillRoot (a viewport-fixed sibling of
|
|
42
|
+
// wrapper); horizontal positioning is provided by CSS, ui.ts writes
|
|
43
|
+
// bottom + (optional) collapsed width.
|
|
44
|
+
expect(pillRoot!.style.bottom).toBe("20px");
|
|
45
|
+
expect(pillRoot!.style.width).toBe("640px");
|
|
46
|
+
|
|
47
|
+
controller.destroy();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("leaves collapsed width empty when no collapsedMaxWidth is configured (CSS responsive defaults take over)", () => {
|
|
51
|
+
const mount = document.createElement("div");
|
|
52
|
+
document.body.appendChild(mount);
|
|
53
|
+
|
|
54
|
+
const controller = createAgentExperience(mount, {
|
|
55
|
+
apiUrl: "https://api.example.com/chat",
|
|
56
|
+
launcher: {
|
|
57
|
+
mountMode: "composer-bar",
|
|
58
|
+
composerBar: { bottomOffset: "16px" },
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const wrapper = mount.querySelector<HTMLElement>(".persona-widget-wrapper[data-persona-composer-bar]");
|
|
63
|
+
const pillRoot = mount.querySelector<HTMLElement>(".persona-widget-pill-root");
|
|
64
|
+
expect(wrapper).not.toBeNull();
|
|
65
|
+
expect(pillRoot).not.toBeNull();
|
|
66
|
+
expect(wrapper!.dataset.state).toBe("collapsed");
|
|
67
|
+
// No collapsedMaxWidth → leave inline width empty so the CSS media
|
|
68
|
+
// queries on .persona-widget-pill-root provide the responsive default.
|
|
69
|
+
expect(pillRoot!.style.width).toBe("");
|
|
70
|
+
expect(pillRoot!.style.bottom).toBe("16px");
|
|
71
|
+
|
|
72
|
+
controller.destroy();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("clears collapsed-only inline styles when expanding to anchored", () => {
|
|
76
|
+
const mount = document.createElement("div");
|
|
77
|
+
document.body.appendChild(mount);
|
|
78
|
+
|
|
79
|
+
const controller = createAgentExperience(mount, {
|
|
80
|
+
apiUrl: "https://api.example.com/chat",
|
|
81
|
+
launcher: {
|
|
82
|
+
mountMode: "composer-bar",
|
|
83
|
+
composerBar: {
|
|
84
|
+
expandedSize: "anchored",
|
|
85
|
+
expandedMaxWidth: "900px",
|
|
86
|
+
expandedTopOffset: "10vh",
|
|
87
|
+
collapsedMaxWidth: "720px",
|
|
88
|
+
bottomOffset: "16px",
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
controller.open();
|
|
94
|
+
|
|
95
|
+
const wrapper = mount.querySelector<HTMLElement>(".persona-widget-wrapper[data-persona-composer-bar]");
|
|
96
|
+
expect(wrapper).not.toBeNull();
|
|
97
|
+
expect(wrapper!.dataset.state).toBe("expanded");
|
|
98
|
+
// Anchored uses both top + bottom so the column is bounded by the
|
|
99
|
+
// viewport. Bottom edge clears the pill-area (pill + peek live in the
|
|
100
|
+
// pillRoot below) so the wrapper's chrome doesn't overlap them.
|
|
101
|
+
expect(wrapper!.style.bottom).toBe(
|
|
102
|
+
"calc(16px + var(--persona-pill-area-height, 80px))"
|
|
103
|
+
);
|
|
104
|
+
expect(wrapper!.style.top).toBe("10vh");
|
|
105
|
+
expect(wrapper!.style.left).toBe("50%");
|
|
106
|
+
expect(wrapper!.style.transform).toBe("translateX(-50%)");
|
|
107
|
+
expect(wrapper!.style.width).toBe("900px");
|
|
108
|
+
expect(wrapper!.style.maxWidth).toBe("calc(100vw - 32px)");
|
|
109
|
+
// Critical regression check: the previous implementation set
|
|
110
|
+
// `width: calc(100% - 32px)` inline in createWrapper and never
|
|
111
|
+
// cleared it. Make sure it's gone after expanding.
|
|
112
|
+
expect(wrapper!.style.width).not.toContain("100%");
|
|
113
|
+
|
|
114
|
+
controller.destroy();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("clears all inline geometry when expanding to fullscreen so CSS rule wins", () => {
|
|
118
|
+
const mount = document.createElement("div");
|
|
119
|
+
document.body.appendChild(mount);
|
|
120
|
+
|
|
121
|
+
const controller = createAgentExperience(mount, {
|
|
122
|
+
apiUrl: "https://api.example.com/chat",
|
|
123
|
+
launcher: {
|
|
124
|
+
mountMode: "composer-bar",
|
|
125
|
+
composerBar: { expandedSize: "fullscreen" },
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
controller.open();
|
|
130
|
+
|
|
131
|
+
const wrapper = mount.querySelector<HTMLElement>("[data-persona-composer-bar]");
|
|
132
|
+
expect(wrapper).not.toBeNull();
|
|
133
|
+
expect(wrapper!.dataset.state).toBe("expanded");
|
|
134
|
+
expect(wrapper!.dataset.expandedSize).toBe("fullscreen");
|
|
135
|
+
// Fullscreen lets the CSS rule (`inset: 0; transform: none; ...`) own
|
|
136
|
+
// the geometry, so all the per-state inline styles must be cleared.
|
|
137
|
+
expect(wrapper!.style.left).toBe("");
|
|
138
|
+
expect(wrapper!.style.right).toBe("");
|
|
139
|
+
expect(wrapper!.style.top).toBe("");
|
|
140
|
+
expect(wrapper!.style.bottom).toBe("");
|
|
141
|
+
expect(wrapper!.style.transform).toBe("");
|
|
142
|
+
expect(wrapper!.style.width).toBe("");
|
|
143
|
+
expect(wrapper!.style.maxWidth).toBe("");
|
|
144
|
+
expect(wrapper!.style.height).toBe("");
|
|
145
|
+
|
|
146
|
+
controller.destroy();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("centers the modal expanded variant via translate(-50%, -50%)", () => {
|
|
150
|
+
const mount = document.createElement("div");
|
|
151
|
+
document.body.appendChild(mount);
|
|
152
|
+
|
|
153
|
+
const controller = createAgentExperience(mount, {
|
|
154
|
+
apiUrl: "https://api.example.com/chat",
|
|
155
|
+
launcher: {
|
|
156
|
+
mountMode: "composer-bar",
|
|
157
|
+
composerBar: {
|
|
158
|
+
expandedSize: "modal",
|
|
159
|
+
modalMaxWidth: "640px",
|
|
160
|
+
modalMaxHeight: "70vh",
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
controller.open();
|
|
166
|
+
|
|
167
|
+
const wrapper = mount.querySelector<HTMLElement>("[data-persona-composer-bar]");
|
|
168
|
+
expect(wrapper).not.toBeNull();
|
|
169
|
+
expect(wrapper!.style.top).toBe("50%");
|
|
170
|
+
expect(wrapper!.style.left).toBe("50%");
|
|
171
|
+
expect(wrapper!.style.transform).toBe("translate(-50%, -50%)");
|
|
172
|
+
expect(wrapper!.style.width).toBe("640px");
|
|
173
|
+
expect(wrapper!.style.maxWidth).toBe("calc(100vw - 32px)");
|
|
174
|
+
expect(wrapper!.style.maxHeight).toBe("70vh");
|
|
175
|
+
|
|
176
|
+
controller.destroy();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("returns to collapsed pill geometry on close, with no stale expanded inline styles", () => {
|
|
180
|
+
const mount = document.createElement("div");
|
|
181
|
+
document.body.appendChild(mount);
|
|
182
|
+
|
|
183
|
+
const controller = createAgentExperience(mount, {
|
|
184
|
+
apiUrl: "https://api.example.com/chat",
|
|
185
|
+
launcher: {
|
|
186
|
+
mountMode: "composer-bar",
|
|
187
|
+
composerBar: { expandedSize: "modal", modalMaxHeight: "60vh" },
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
controller.open();
|
|
192
|
+
controller.close();
|
|
193
|
+
|
|
194
|
+
const wrapper = mount.querySelector<HTMLElement>(".persona-widget-wrapper[data-persona-composer-bar]");
|
|
195
|
+
const pillRoot = mount.querySelector<HTMLElement>(".persona-widget-pill-root");
|
|
196
|
+
expect(wrapper).not.toBeNull();
|
|
197
|
+
expect(pillRoot).not.toBeNull();
|
|
198
|
+
expect(wrapper!.dataset.state).toBe("collapsed");
|
|
199
|
+
expect(pillRoot!.dataset.state).toBe("collapsed");
|
|
200
|
+
// Modal's expanded geometry on the wrapper must be cleared on close —
|
|
201
|
+
// the wrapper has no visible chrome in collapsed state since the
|
|
202
|
+
// container is hidden via CSS and the pill lives in pillRoot.
|
|
203
|
+
expect(wrapper!.style.transform).toBe("");
|
|
204
|
+
expect(wrapper!.style.top).toBe("");
|
|
205
|
+
expect(wrapper!.style.maxHeight).toBe("");
|
|
206
|
+
expect(wrapper!.style.height).toBe("");
|
|
207
|
+
// Pill geometry on pillRoot is reapplied per state — bottom uses the
|
|
208
|
+
// configured offset (default 16px).
|
|
209
|
+
expect(pillRoot!.style.bottom).toBe("16px");
|
|
210
|
+
|
|
211
|
+
controller.destroy();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("does NOT cap the pill composer form with contentMaxWidth — the pill matches the wrapper's responsive width", () => {
|
|
215
|
+
const mount = document.createElement("div");
|
|
216
|
+
document.body.appendChild(mount);
|
|
217
|
+
|
|
218
|
+
const controller = createAgentExperience(mount, {
|
|
219
|
+
apiUrl: "https://api.example.com/chat",
|
|
220
|
+
launcher: {
|
|
221
|
+
mountMode: "composer-bar",
|
|
222
|
+
composerBar: { contentMaxWidth: "600px" },
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const composerForm = mount.querySelector<HTMLElement>("[data-persona-composer-form]");
|
|
227
|
+
expect(composerForm).not.toBeNull();
|
|
228
|
+
// The pill is the composer in composer-bar mode and must fill the
|
|
229
|
+
// wrapper (which carries the responsive 50/70/90vw width). Capping
|
|
230
|
+
// the form at contentMaxWidth + auto-margins would shrink it to
|
|
231
|
+
// content width inside a wider wrapper.
|
|
232
|
+
expect(composerForm!.style.maxWidth).toBe("");
|
|
233
|
+
expect(composerForm!.style.marginLeft).toBe("");
|
|
234
|
+
expect(composerForm!.style.marginRight).toBe("");
|
|
235
|
+
|
|
236
|
+
controller.destroy();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("renders the purpose-built pill composer (not the column-stacked full composer)", () => {
|
|
240
|
+
const mount = document.createElement("div");
|
|
241
|
+
document.body.appendChild(mount);
|
|
242
|
+
|
|
243
|
+
const controller = createAgentExperience(mount, {
|
|
244
|
+
apiUrl: "https://api.example.com/chat",
|
|
245
|
+
launcher: { mountMode: "composer-bar" },
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const composerForm = mount.querySelector<HTMLElement>("[data-persona-composer-form]");
|
|
249
|
+
expect(composerForm).not.toBeNull();
|
|
250
|
+
// Pill marker class present.
|
|
251
|
+
expect(composerForm!.classList.contains("persona-pill-composer")).toBe(true);
|
|
252
|
+
// Full-composer column-stack utility classes are NOT present in pill mode.
|
|
253
|
+
expect(composerForm!.classList.contains("persona-flex-col")).toBe(false);
|
|
254
|
+
expect(composerForm!.classList.contains("persona-rounded-2xl")).toBe(false);
|
|
255
|
+
|
|
256
|
+
controller.destroy();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("renders a minimal close + clear-chat pair (no full header strip) in composer-bar mode", () => {
|
|
260
|
+
const mount = document.createElement("div");
|
|
261
|
+
document.body.appendChild(mount);
|
|
262
|
+
|
|
263
|
+
const controller = createAgentExperience(mount, {
|
|
264
|
+
apiUrl: "https://api.example.com/chat",
|
|
265
|
+
launcher: {
|
|
266
|
+
mountMode: "composer-bar",
|
|
267
|
+
title: "Should Not Render",
|
|
268
|
+
subtitle: "Should Not Render Either",
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Header placeholder exists for downstream toggles but renders nothing.
|
|
273
|
+
const headerPlaceholder = mount.querySelector<HTMLElement>(".persona-widget-header");
|
|
274
|
+
expect(headerPlaceholder).not.toBeNull();
|
|
275
|
+
expect(headerPlaceholder!.style.display).toBe("none");
|
|
276
|
+
expect(headerPlaceholder!.textContent).toBe("");
|
|
277
|
+
|
|
278
|
+
// Close button exists and is absolutely positioned in the corner.
|
|
279
|
+
const closeButton = mount.querySelector<HTMLButtonElement>(
|
|
280
|
+
"[aria-label='Close chat'], [aria-label='Minimize']"
|
|
281
|
+
);
|
|
282
|
+
expect(closeButton).not.toBeNull();
|
|
283
|
+
const closeWrapper = closeButton!.parentElement!;
|
|
284
|
+
expect(closeWrapper.classList.contains("persona-composer-bar-close")).toBe(true);
|
|
285
|
+
expect(closeWrapper.style.position).toBe("absolute");
|
|
286
|
+
expect(closeWrapper.style.top).toBe("8px");
|
|
287
|
+
expect(closeWrapper.style.right).toBe("8px");
|
|
288
|
+
|
|
289
|
+
// Clear-chat button renders by default (launcher.clearChat.enabled defaults
|
|
290
|
+
// to true), positioned immediately left of the × close.
|
|
291
|
+
const clearChat = mount.querySelector<HTMLButtonElement>(
|
|
292
|
+
"[aria-label='Clear chat'], [aria-label='Start over']"
|
|
293
|
+
);
|
|
294
|
+
expect(clearChat).not.toBeNull();
|
|
295
|
+
const clearWrapper = clearChat!.parentElement!;
|
|
296
|
+
expect(clearWrapper.classList.contains("persona-composer-bar-clear-chat")).toBe(true);
|
|
297
|
+
expect(clearWrapper.style.position).toBe("absolute");
|
|
298
|
+
expect(clearWrapper.style.top).toBe("8px");
|
|
299
|
+
expect(clearWrapper.style.right).toBe("32px");
|
|
300
|
+
// Composer-bar override sizes the clear button to match the smaller close.
|
|
301
|
+
expect(clearChat!.style.height).toBe("16px");
|
|
302
|
+
expect(clearChat!.style.width).toBe("16px");
|
|
303
|
+
|
|
304
|
+
controller.destroy();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("hides the clear-chat button when launcher.clearChat.enabled is false", () => {
|
|
308
|
+
const mount = document.createElement("div");
|
|
309
|
+
document.body.appendChild(mount);
|
|
310
|
+
|
|
311
|
+
const controller = createAgentExperience(mount, {
|
|
312
|
+
apiUrl: "https://api.example.com/chat",
|
|
313
|
+
launcher: {
|
|
314
|
+
mountMode: "composer-bar",
|
|
315
|
+
clearChat: { enabled: false },
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const clearChat = mount.querySelector<HTMLButtonElement>(
|
|
320
|
+
"[aria-label='Clear chat'], [aria-label='Start over']"
|
|
321
|
+
);
|
|
322
|
+
expect(clearChat).toBeNull();
|
|
323
|
+
|
|
324
|
+
// Close button is still present.
|
|
325
|
+
const closeButton = mount.querySelector<HTMLButtonElement>(
|
|
326
|
+
"[aria-label='Close chat'], [aria-label='Minimize']"
|
|
327
|
+
);
|
|
328
|
+
expect(closeButton).not.toBeNull();
|
|
329
|
+
|
|
330
|
+
controller.destroy();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("clicking the clear-chat button clears injected messages", () => {
|
|
334
|
+
const mount = document.createElement("div");
|
|
335
|
+
document.body.appendChild(mount);
|
|
336
|
+
|
|
337
|
+
const controller = createAgentExperience(mount, {
|
|
338
|
+
apiUrl: "https://api.example.com/chat",
|
|
339
|
+
launcher: { mountMode: "composer-bar" },
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
controller.injectAssistantMessage({ content: "hello there" });
|
|
343
|
+
controller.injectUserMessage({ content: "ping" });
|
|
344
|
+
expect(controller.getMessages().length).toBeGreaterThan(0);
|
|
345
|
+
|
|
346
|
+
const clearChat = mount.querySelector<HTMLButtonElement>(
|
|
347
|
+
"[aria-label='Clear chat'], [aria-label='Start over']"
|
|
348
|
+
);
|
|
349
|
+
expect(clearChat).not.toBeNull();
|
|
350
|
+
clearChat!.click();
|
|
351
|
+
|
|
352
|
+
expect(controller.getMessages().length).toBe(0);
|
|
353
|
+
|
|
354
|
+
controller.destroy();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("honors launcher.clearChat.tooltipText override (e.g. 'Start over')", () => {
|
|
358
|
+
const mount = document.createElement("div");
|
|
359
|
+
document.body.appendChild(mount);
|
|
360
|
+
|
|
361
|
+
const controller = createAgentExperience(mount, {
|
|
362
|
+
apiUrl: "https://api.example.com/chat",
|
|
363
|
+
launcher: {
|
|
364
|
+
mountMode: "composer-bar",
|
|
365
|
+
clearChat: { tooltipText: "Start over" },
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const clearChat = mount.querySelector<HTMLButtonElement>(
|
|
370
|
+
"[aria-label='Start over']"
|
|
371
|
+
);
|
|
372
|
+
expect(clearChat).not.toBeNull();
|
|
373
|
+
|
|
374
|
+
controller.destroy();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("places the pill (footer) inside pillRoot — a viewport-fixed sibling of the wrapper, not inside the panel", () => {
|
|
378
|
+
const mount = document.createElement("div");
|
|
379
|
+
document.body.appendChild(mount);
|
|
380
|
+
|
|
381
|
+
const controller = createAgentExperience(mount, {
|
|
382
|
+
apiUrl: "https://api.example.com/chat",
|
|
383
|
+
launcher: { mountMode: "composer-bar" },
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
const wrapper = mount.querySelector<HTMLElement>(".persona-widget-wrapper[data-persona-composer-bar]");
|
|
387
|
+
const pillRoot = mount.querySelector<HTMLElement>(".persona-widget-pill-root");
|
|
388
|
+
const panel = mount.querySelector<HTMLElement>(".persona-widget-panel");
|
|
389
|
+
const container = mount.querySelector<HTMLElement>(".persona-widget-container");
|
|
390
|
+
const composerForm = mount.querySelector<HTMLFormElement>(
|
|
391
|
+
"[data-persona-composer-form]"
|
|
392
|
+
);
|
|
393
|
+
expect(wrapper).not.toBeNull();
|
|
394
|
+
expect(pillRoot).not.toBeNull();
|
|
395
|
+
expect(panel).not.toBeNull();
|
|
396
|
+
expect(container).not.toBeNull();
|
|
397
|
+
expect(composerForm).not.toBeNull();
|
|
398
|
+
|
|
399
|
+
// pillRoot is mounted as a sibling of the wrapper inside `mount`, so it
|
|
400
|
+
// never inherits the wrapper's geometry transitions (critical for modal
|
|
401
|
+
// mode where the wrapper has `transform: translate(-50%, -50%)`).
|
|
402
|
+
expect(pillRoot!.parentElement).toBe(mount);
|
|
403
|
+
expect(wrapper!.parentElement).toBe(mount);
|
|
404
|
+
expect(wrapper!.contains(pillRoot!)).toBe(false);
|
|
405
|
+
|
|
406
|
+
// Footer in pill mode = the form's parent (`.persona-widget-footer--pill`).
|
|
407
|
+
// It now lives inside pillRoot, NOT inside the panel/container.
|
|
408
|
+
const footer = composerForm!.closest(".persona-widget-footer--pill") as HTMLElement | null;
|
|
409
|
+
expect(footer).not.toBeNull();
|
|
410
|
+
expect(footer!.parentElement).toBe(pillRoot);
|
|
411
|
+
expect(panel!.contains(footer!)).toBe(false);
|
|
412
|
+
expect(container!.contains(footer!)).toBe(false);
|
|
413
|
+
|
|
414
|
+
controller.destroy();
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("hides the container when collapsed and shows it (display:flex) when expanded", () => {
|
|
418
|
+
const mount = document.createElement("div");
|
|
419
|
+
document.body.appendChild(mount);
|
|
420
|
+
|
|
421
|
+
const controller = createAgentExperience(mount, {
|
|
422
|
+
apiUrl: "https://api.example.com/chat",
|
|
423
|
+
launcher: { mountMode: "composer-bar" },
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const container = mount.querySelector<HTMLElement>(".persona-widget-container");
|
|
427
|
+
expect(container).not.toBeNull();
|
|
428
|
+
// Initial state is collapsed → container hidden, only the pill visible.
|
|
429
|
+
expect(container!.style.display).toBe("none");
|
|
430
|
+
|
|
431
|
+
controller.open();
|
|
432
|
+
expect(container!.style.display).toBe("flex");
|
|
433
|
+
|
|
434
|
+
controller.close();
|
|
435
|
+
expect(container!.style.display).toBe("none");
|
|
436
|
+
|
|
437
|
+
controller.destroy();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("mirrors data-state from wrapper to pillRoot when toggling open/collapsed", () => {
|
|
441
|
+
const mount = document.createElement("div");
|
|
442
|
+
document.body.appendChild(mount);
|
|
443
|
+
|
|
444
|
+
const controller = createAgentExperience(mount, {
|
|
445
|
+
apiUrl: "https://api.example.com/chat",
|
|
446
|
+
launcher: {
|
|
447
|
+
mountMode: "composer-bar",
|
|
448
|
+
composerBar: { expandedSize: "modal" },
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
const wrapper = mount.querySelector<HTMLElement>(".persona-widget-wrapper[data-persona-composer-bar]");
|
|
453
|
+
const pillRoot = mount.querySelector<HTMLElement>(".persona-widget-pill-root");
|
|
454
|
+
expect(wrapper).not.toBeNull();
|
|
455
|
+
expect(pillRoot).not.toBeNull();
|
|
456
|
+
|
|
457
|
+
// Initial: collapsed mirrored on both.
|
|
458
|
+
expect(wrapper!.dataset.state).toBe("collapsed");
|
|
459
|
+
expect(pillRoot!.dataset.state).toBe("collapsed");
|
|
460
|
+
expect(wrapper!.dataset.expandedSize).toBe("modal");
|
|
461
|
+
expect(pillRoot!.dataset.expandedSize).toBe("modal");
|
|
462
|
+
|
|
463
|
+
controller.open();
|
|
464
|
+
expect(wrapper!.dataset.state).toBe("expanded");
|
|
465
|
+
expect(pillRoot!.dataset.state).toBe("expanded");
|
|
466
|
+
|
|
467
|
+
controller.close();
|
|
468
|
+
expect(wrapper!.dataset.state).toBe("collapsed");
|
|
469
|
+
expect(pillRoot!.dataset.state).toBe("collapsed");
|
|
470
|
+
|
|
471
|
+
controller.destroy();
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("does NOT dismiss the expanded panel when pointerdown lands on the pill (now outside the wrapper)", () => {
|
|
475
|
+
const mount = document.createElement("div");
|
|
476
|
+
document.body.appendChild(mount);
|
|
477
|
+
|
|
478
|
+
const controller = createAgentExperience(mount, {
|
|
479
|
+
apiUrl: "https://api.example.com/chat",
|
|
480
|
+
launcher: { mountMode: "composer-bar" },
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
controller.open();
|
|
484
|
+
const wrapper = mount.querySelector<HTMLElement>(".persona-widget-wrapper[data-persona-composer-bar]");
|
|
485
|
+
const pillRoot = mount.querySelector<HTMLElement>(".persona-widget-pill-root");
|
|
486
|
+
expect(wrapper!.dataset.state).toBe("expanded");
|
|
487
|
+
expect(pillRoot).not.toBeNull();
|
|
488
|
+
|
|
489
|
+
// Sanity: pillRoot is OUTSIDE the wrapper subtree, so the dismiss
|
|
490
|
+
// listener's wrapper-only composedPath check would treat pill clicks
|
|
491
|
+
// as "outside" without the explicit pillRoot fall-through.
|
|
492
|
+
expect(wrapper!.contains(pillRoot!)).toBe(false);
|
|
493
|
+
|
|
494
|
+
const textarea = mount.querySelector<HTMLTextAreaElement>(
|
|
495
|
+
"[data-persona-composer-input]"
|
|
496
|
+
);
|
|
497
|
+
expect(textarea).not.toBeNull();
|
|
498
|
+
expect(pillRoot!.contains(textarea!)).toBe(true);
|
|
499
|
+
|
|
500
|
+
textarea!.dispatchEvent(
|
|
501
|
+
new PointerEvent("pointerdown", { bubbles: true, composed: true })
|
|
502
|
+
);
|
|
503
|
+
expect(wrapper!.dataset.state).toBe("expanded");
|
|
504
|
+
|
|
505
|
+
controller.destroy();
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("dismisses the expanded panel when a pointerdown fires outside the wrapper", () => {
|
|
509
|
+
const mount = document.createElement("div");
|
|
510
|
+
document.body.appendChild(mount);
|
|
511
|
+
|
|
512
|
+
const controller = createAgentExperience(mount, {
|
|
513
|
+
apiUrl: "https://api.example.com/chat",
|
|
514
|
+
launcher: { mountMode: "composer-bar" },
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
controller.open();
|
|
518
|
+
const wrapper = mount.querySelector<HTMLElement>("[data-persona-composer-bar]");
|
|
519
|
+
expect(wrapper!.dataset.state).toBe("expanded");
|
|
520
|
+
|
|
521
|
+
// Simulate a pointerdown anywhere outside the wrapper. Use document.body
|
|
522
|
+
// as the target — definitely outside the wrapper subtree.
|
|
523
|
+
const outsideTarget = document.createElement("div");
|
|
524
|
+
document.body.appendChild(outsideTarget);
|
|
525
|
+
outsideTarget.dispatchEvent(
|
|
526
|
+
new PointerEvent("pointerdown", { bubbles: true, composed: true })
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
expect(wrapper!.dataset.state).toBe("collapsed");
|
|
530
|
+
|
|
531
|
+
outsideTarget.remove();
|
|
532
|
+
controller.destroy();
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("dismisses the expanded panel when Escape is pressed", () => {
|
|
536
|
+
const mount = document.createElement("div");
|
|
537
|
+
document.body.appendChild(mount);
|
|
538
|
+
|
|
539
|
+
const controller = createAgentExperience(mount, {
|
|
540
|
+
apiUrl: "https://api.example.com/chat",
|
|
541
|
+
launcher: { mountMode: "composer-bar" },
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
controller.open();
|
|
545
|
+
const wrapper = mount.querySelector<HTMLElement>("[data-persona-composer-bar]");
|
|
546
|
+
expect(wrapper!.dataset.state).toBe("expanded");
|
|
547
|
+
|
|
548
|
+
document.dispatchEvent(
|
|
549
|
+
new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
expect(wrapper!.dataset.state).toBe("collapsed");
|
|
553
|
+
|
|
554
|
+
controller.destroy();
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("does NOT dismiss the panel when Escape is pressed during IME composition", () => {
|
|
558
|
+
const mount = document.createElement("div");
|
|
559
|
+
document.body.appendChild(mount);
|
|
560
|
+
|
|
561
|
+
const controller = createAgentExperience(mount, {
|
|
562
|
+
apiUrl: "https://api.example.com/chat",
|
|
563
|
+
launcher: { mountMode: "composer-bar" },
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
controller.open();
|
|
567
|
+
const wrapper = mount.querySelector<HTMLElement>("[data-persona-composer-bar]");
|
|
568
|
+
expect(wrapper!.dataset.state).toBe("expanded");
|
|
569
|
+
|
|
570
|
+
// KeyboardEvent doesn't expose an `isComposing` constructor option, so
|
|
571
|
+
// override the getter to simulate the IME-composing state.
|
|
572
|
+
const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true });
|
|
573
|
+
Object.defineProperty(event, "isComposing", { value: true });
|
|
574
|
+
document.dispatchEvent(event);
|
|
575
|
+
|
|
576
|
+
expect(wrapper!.dataset.state).toBe("expanded");
|
|
577
|
+
|
|
578
|
+
controller.destroy();
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
// --- Peek banner tests --------------------------------------------------
|
|
582
|
+
// The peek banner (data-persona-pill-peek) is a chrome-less row above the
|
|
583
|
+
// pill that previews the trailing 100 chars of the most recent assistant
|
|
584
|
+
// message. Visible when (collapsed) AND (assistant content exists) AND
|
|
585
|
+
// (isStreaming OR composer hovered). The streaming branch is exercised
|
|
586
|
+
// end-to-end in manual demo (sendMessage path requires fetch); these unit
|
|
587
|
+
// tests cover the hover branch and the visibility/content invariants.
|
|
588
|
+
|
|
589
|
+
const getPeekBanner = (mount: HTMLElement) =>
|
|
590
|
+
mount.querySelector<HTMLButtonElement>("[data-persona-pill-peek]");
|
|
591
|
+
|
|
592
|
+
const getPeekText = (mount: HTMLElement) =>
|
|
593
|
+
mount.querySelector<HTMLElement>(".persona-pill-peek__text");
|
|
594
|
+
|
|
595
|
+
it("renders a hidden peek banner above the pill inside pillRoot when collapsed with no messages", () => {
|
|
596
|
+
const mount = document.createElement("div");
|
|
597
|
+
document.body.appendChild(mount);
|
|
598
|
+
|
|
599
|
+
const controller = createAgentExperience(mount, {
|
|
600
|
+
apiUrl: "https://api.example.com/chat",
|
|
601
|
+
launcher: { mountMode: "composer-bar" },
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
const peek = getPeekBanner(mount);
|
|
605
|
+
expect(peek).not.toBeNull();
|
|
606
|
+
expect(peek!.classList.contains("persona-pill-peek--visible")).toBe(false);
|
|
607
|
+
|
|
608
|
+
// Order inside pillRoot: peek → footer (pill). pillRoot's `gap` provides
|
|
609
|
+
// the visible spacing between them.
|
|
610
|
+
const pillRoot = mount.querySelector<HTMLElement>(".persona-widget-pill-root");
|
|
611
|
+
expect(pillRoot).not.toBeNull();
|
|
612
|
+
const children = Array.from(pillRoot!.children);
|
|
613
|
+
const peekIdx = children.indexOf(peek!);
|
|
614
|
+
const footerIdx = children.findIndex((c) =>
|
|
615
|
+
c.classList.contains("persona-widget-footer")
|
|
616
|
+
);
|
|
617
|
+
expect(peekIdx).toBe(0);
|
|
618
|
+
expect(footerIdx).toBe(1);
|
|
619
|
+
|
|
620
|
+
controller.destroy();
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it("shows the peek with trailing 100 chars when hovered with a long assistant message", () => {
|
|
624
|
+
const mount = document.createElement("div");
|
|
625
|
+
document.body.appendChild(mount);
|
|
626
|
+
|
|
627
|
+
const controller = createAgentExperience(mount, {
|
|
628
|
+
apiUrl: "https://api.example.com/chat",
|
|
629
|
+
launcher: { mountMode: "composer-bar" },
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
const longText = "a".repeat(150);
|
|
633
|
+
controller.injectAssistantMessage({ content: longText });
|
|
634
|
+
// injectAssistantMessage auto-opens; close to evaluate the collapsed-pill UX.
|
|
635
|
+
controller.close();
|
|
636
|
+
|
|
637
|
+
const wrapper = mount.querySelector<HTMLElement>("[data-persona-composer-bar]");
|
|
638
|
+
expect(wrapper!.dataset.state).toBe("collapsed");
|
|
639
|
+
|
|
640
|
+
const panel = mount.querySelector<HTMLElement>(".persona-widget-panel");
|
|
641
|
+
panel!.dispatchEvent(new PointerEvent("pointerenter", { bubbles: true }));
|
|
642
|
+
|
|
643
|
+
const peek = getPeekBanner(mount);
|
|
644
|
+
expect(peek!.classList.contains("persona-pill-peek--visible")).toBe(true);
|
|
645
|
+
|
|
646
|
+
const textNode = getPeekText(mount);
|
|
647
|
+
// Leading U+2026 prefix + last 100 chars.
|
|
648
|
+
expect(textNode!.textContent).toBe(`…${"a".repeat(100)}`);
|
|
649
|
+
expect(textNode!.textContent!.length).toBe(101);
|
|
650
|
+
|
|
651
|
+
controller.destroy();
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("shows the full message text when shorter than 100 chars (no leading ellipsis)", () => {
|
|
655
|
+
const mount = document.createElement("div");
|
|
656
|
+
document.body.appendChild(mount);
|
|
657
|
+
|
|
658
|
+
const controller = createAgentExperience(mount, {
|
|
659
|
+
apiUrl: "https://api.example.com/chat",
|
|
660
|
+
launcher: { mountMode: "composer-bar" },
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
controller.injectAssistantMessage({ content: "thanks i like the recommendation" });
|
|
664
|
+
controller.close();
|
|
665
|
+
|
|
666
|
+
const panel = mount.querySelector<HTMLElement>(".persona-widget-panel");
|
|
667
|
+
panel!.dispatchEvent(new PointerEvent("pointerenter", { bubbles: true }));
|
|
668
|
+
|
|
669
|
+
const peek = getPeekBanner(mount);
|
|
670
|
+
expect(peek!.classList.contains("persona-pill-peek--visible")).toBe(true);
|
|
671
|
+
expect(getPeekText(mount)!.textContent).toBe("thanks i like the recommendation");
|
|
672
|
+
|
|
673
|
+
controller.destroy();
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it("hides the peek again on pointerleave from the panel", () => {
|
|
677
|
+
const mount = document.createElement("div");
|
|
678
|
+
document.body.appendChild(mount);
|
|
679
|
+
|
|
680
|
+
const controller = createAgentExperience(mount, {
|
|
681
|
+
apiUrl: "https://api.example.com/chat",
|
|
682
|
+
launcher: { mountMode: "composer-bar" },
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
controller.injectAssistantMessage({ content: "Earlier reply" });
|
|
686
|
+
controller.close();
|
|
687
|
+
|
|
688
|
+
const panel = mount.querySelector<HTMLElement>(".persona-widget-panel");
|
|
689
|
+
panel!.dispatchEvent(new PointerEvent("pointerenter", { bubbles: true }));
|
|
690
|
+
expect(getPeekBanner(mount)!.classList.contains("persona-pill-peek--visible")).toBe(true);
|
|
691
|
+
|
|
692
|
+
panel!.dispatchEvent(new PointerEvent("pointerleave", { bubbles: true }));
|
|
693
|
+
expect(getPeekBanner(mount)!.classList.contains("persona-pill-peek--visible")).toBe(false);
|
|
694
|
+
|
|
695
|
+
controller.destroy();
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it("clicking the peek banner expands the panel", () => {
|
|
699
|
+
const mount = document.createElement("div");
|
|
700
|
+
document.body.appendChild(mount);
|
|
701
|
+
|
|
702
|
+
const controller = createAgentExperience(mount, {
|
|
703
|
+
apiUrl: "https://api.example.com/chat",
|
|
704
|
+
launcher: { mountMode: "composer-bar" },
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
controller.injectAssistantMessage({ content: "Earlier reply" });
|
|
708
|
+
controller.close();
|
|
709
|
+
|
|
710
|
+
const wrapper = mount.querySelector<HTMLElement>("[data-persona-composer-bar]");
|
|
711
|
+
expect(wrapper!.dataset.state).toBe("collapsed");
|
|
712
|
+
|
|
713
|
+
const peek = getPeekBanner(mount)!;
|
|
714
|
+
peek.dispatchEvent(
|
|
715
|
+
new PointerEvent("pointerdown", { bubbles: true, composed: true })
|
|
716
|
+
);
|
|
717
|
+
expect(wrapper!.dataset.state).toBe("expanded");
|
|
718
|
+
|
|
719
|
+
controller.destroy();
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
it("hides the peek banner when the panel is expanded, even with hover", () => {
|
|
723
|
+
const mount = document.createElement("div");
|
|
724
|
+
document.body.appendChild(mount);
|
|
725
|
+
|
|
726
|
+
const controller = createAgentExperience(mount, {
|
|
727
|
+
apiUrl: "https://api.example.com/chat",
|
|
728
|
+
launcher: { mountMode: "composer-bar" },
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
controller.injectAssistantMessage({ content: "Earlier reply" });
|
|
732
|
+
// Panel was auto-opened by injectAssistantMessage.
|
|
733
|
+
const wrapper = mount.querySelector<HTMLElement>("[data-persona-composer-bar]");
|
|
734
|
+
expect(wrapper!.dataset.state).toBe("expanded");
|
|
735
|
+
|
|
736
|
+
const panel = mount.querySelector<HTMLElement>(".persona-widget-panel");
|
|
737
|
+
panel!.dispatchEvent(new PointerEvent("pointerenter", { bubbles: true }));
|
|
738
|
+
|
|
739
|
+
expect(getPeekBanner(mount)!.classList.contains("persona-pill-peek--visible")).toBe(false);
|
|
740
|
+
|
|
741
|
+
controller.destroy();
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
it("ignores user-only messages (peek requires assistant content)", () => {
|
|
745
|
+
const mount = document.createElement("div");
|
|
746
|
+
document.body.appendChild(mount);
|
|
747
|
+
|
|
748
|
+
const controller = createAgentExperience(mount, {
|
|
749
|
+
apiUrl: "https://api.example.com/chat",
|
|
750
|
+
launcher: { mountMode: "composer-bar" },
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
controller.injectUserMessage({ content: "what is the price?" });
|
|
754
|
+
controller.close();
|
|
755
|
+
|
|
756
|
+
const panel = mount.querySelector<HTMLElement>(".persona-widget-panel");
|
|
757
|
+
panel!.dispatchEvent(new PointerEvent("pointerenter", { bubbles: true }));
|
|
758
|
+
|
|
759
|
+
expect(getPeekBanner(mount)!.classList.contains("persona-pill-peek--visible")).toBe(false);
|
|
760
|
+
|
|
761
|
+
controller.destroy();
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
// --- Peek streamAnimation tests ----------------------------------------
|
|
765
|
+
// The peek banner accepts the same `streamAnimation` shape as
|
|
766
|
+
// `features.streamAnimation`. Resolution: peek-specific override → inherit
|
|
767
|
+
// from features. The carve-out is `bubbleClass` (peek has no bubble);
|
|
768
|
+
// everything else (containerClass, wrap, useCaret, buffer, placeholder,
|
|
769
|
+
// speed/duration, custom plugins) ports over.
|
|
770
|
+
|
|
771
|
+
it("inherits features.streamAnimation when peek.streamAnimation is omitted", () => {
|
|
772
|
+
const mount = document.createElement("div");
|
|
773
|
+
document.body.appendChild(mount);
|
|
774
|
+
|
|
775
|
+
const controller = createAgentExperience(mount, {
|
|
776
|
+
apiUrl: "https://api.example.com/chat",
|
|
777
|
+
launcher: { mountMode: "composer-bar" },
|
|
778
|
+
features: { streamAnimation: { type: "typewriter", speed: 60 } },
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
controller.injectAssistantMessage({ content: "hello world", streaming: true });
|
|
782
|
+
controller.close();
|
|
783
|
+
const panel = mount.querySelector<HTMLElement>(".persona-widget-panel");
|
|
784
|
+
panel!.dispatchEvent(new PointerEvent("pointerenter", { bubbles: true }));
|
|
785
|
+
|
|
786
|
+
const textNode = getPeekText(mount)!;
|
|
787
|
+
// typewriter ⇒ container class applied + per-char spans rendered.
|
|
788
|
+
expect(textNode.classList.contains("persona-stream-typewriter")).toBe(true);
|
|
789
|
+
expect(textNode.style.getPropertyValue("--persona-stream-step")).toBe("60ms");
|
|
790
|
+
expect(textNode.querySelectorAll(".persona-stream-char").length).toBeGreaterThan(0);
|
|
791
|
+
|
|
792
|
+
controller.destroy();
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
it("peek.streamAnimation override beats features.streamAnimation inheritance", () => {
|
|
796
|
+
const mount = document.createElement("div");
|
|
797
|
+
document.body.appendChild(mount);
|
|
798
|
+
|
|
799
|
+
const controller = createAgentExperience(mount, {
|
|
800
|
+
apiUrl: "https://api.example.com/chat",
|
|
801
|
+
launcher: {
|
|
802
|
+
mountMode: "composer-bar",
|
|
803
|
+
composerBar: {
|
|
804
|
+
peek: { streamAnimation: { type: "letter-rise", speed: 40 } },
|
|
805
|
+
},
|
|
806
|
+
},
|
|
807
|
+
features: { streamAnimation: { type: "typewriter", speed: 200 } },
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
controller.injectAssistantMessage({ content: "hi there", streaming: true });
|
|
811
|
+
controller.close();
|
|
812
|
+
const panel = mount.querySelector<HTMLElement>(".persona-widget-panel");
|
|
813
|
+
panel!.dispatchEvent(new PointerEvent("pointerenter", { bubbles: true }));
|
|
814
|
+
|
|
815
|
+
const textNode = getPeekText(mount)!;
|
|
816
|
+
// letter-rise wins over typewriter — peek-specific config beats inherit.
|
|
817
|
+
expect(textNode.classList.contains("persona-stream-letter-rise")).toBe(true);
|
|
818
|
+
expect(textNode.classList.contains("persona-stream-typewriter")).toBe(false);
|
|
819
|
+
expect(textNode.style.getPropertyValue("--persona-stream-step")).toBe("40ms");
|
|
820
|
+
|
|
821
|
+
controller.destroy();
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
it("namespaces per-char span IDs with `peek-` so they don't collide with main bubble spans", () => {
|
|
825
|
+
const mount = document.createElement("div");
|
|
826
|
+
document.body.appendChild(mount);
|
|
827
|
+
|
|
828
|
+
const controller = createAgentExperience(mount, {
|
|
829
|
+
apiUrl: "https://api.example.com/chat",
|
|
830
|
+
launcher: { mountMode: "composer-bar" },
|
|
831
|
+
features: { streamAnimation: { type: "typewriter" } },
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
const msg = controller.injectAssistantMessage({
|
|
835
|
+
content: "abc",
|
|
836
|
+
streaming: true,
|
|
837
|
+
});
|
|
838
|
+
controller.close();
|
|
839
|
+
const panel = mount.querySelector<HTMLElement>(".persona-widget-panel");
|
|
840
|
+
panel!.dispatchEvent(new PointerEvent("pointerenter", { bubbles: true }));
|
|
841
|
+
|
|
842
|
+
const textNode = getPeekText(mount)!;
|
|
843
|
+
const firstChar = textNode.querySelector<HTMLElement>(".persona-stream-char");
|
|
844
|
+
expect(firstChar).not.toBeNull();
|
|
845
|
+
expect(firstChar!.id).toBe(`stream-c-peek-${msg.id}-0`);
|
|
846
|
+
|
|
847
|
+
controller.destroy();
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
it("uses absolute char indices when the trailing 100-char window slices a long message", () => {
|
|
851
|
+
const mount = document.createElement("div");
|
|
852
|
+
document.body.appendChild(mount);
|
|
853
|
+
|
|
854
|
+
const controller = createAgentExperience(mount, {
|
|
855
|
+
apiUrl: "https://api.example.com/chat",
|
|
856
|
+
launcher: { mountMode: "composer-bar" },
|
|
857
|
+
features: { streamAnimation: { type: "typewriter" } },
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
// 150 chars: slice = chars 50-149, so first peek span ID should be index 50.
|
|
861
|
+
const msg = controller.injectAssistantMessage({
|
|
862
|
+
content: "a".repeat(150),
|
|
863
|
+
streaming: true,
|
|
864
|
+
});
|
|
865
|
+
controller.close();
|
|
866
|
+
const panel = mount.querySelector<HTMLElement>(".persona-widget-panel");
|
|
867
|
+
panel!.dispatchEvent(new PointerEvent("pointerenter", { bubbles: true }));
|
|
868
|
+
|
|
869
|
+
const textNode = getPeekText(mount)!;
|
|
870
|
+
const chars = textNode.querySelectorAll<HTMLElement>(".persona-stream-char");
|
|
871
|
+
expect(chars.length).toBe(100);
|
|
872
|
+
expect(chars[0].id).toBe(`stream-c-peek-${msg.id}-50`);
|
|
873
|
+
expect(chars[chars.length - 1].id).toBe(`stream-c-peek-${msg.id}-149`);
|
|
874
|
+
|
|
875
|
+
controller.destroy();
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
it("appends a caret when the resolved plugin uses `useCaret` (typewriter)", () => {
|
|
879
|
+
const mount = document.createElement("div");
|
|
880
|
+
document.body.appendChild(mount);
|
|
881
|
+
|
|
882
|
+
const controller = createAgentExperience(mount, {
|
|
883
|
+
apiUrl: "https://api.example.com/chat",
|
|
884
|
+
launcher: { mountMode: "composer-bar" },
|
|
885
|
+
features: { streamAnimation: { type: "typewriter" } },
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
controller.injectAssistantMessage({ content: "hi", streaming: true });
|
|
889
|
+
controller.close();
|
|
890
|
+
const panel = mount.querySelector<HTMLElement>(".persona-widget-panel");
|
|
891
|
+
panel!.dispatchEvent(new PointerEvent("pointerenter", { bubbles: true }));
|
|
892
|
+
|
|
893
|
+
const textNode = getPeekText(mount)!;
|
|
894
|
+
expect(textNode.querySelector(".persona-stream-caret")).not.toBeNull();
|
|
895
|
+
|
|
896
|
+
controller.destroy();
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
it("does NOT apply bubbleClass to the peek (carve-out: peek has no bubble)", () => {
|
|
900
|
+
const mount = document.createElement("div");
|
|
901
|
+
document.body.appendChild(mount);
|
|
902
|
+
|
|
903
|
+
const controller = createAgentExperience(mount, {
|
|
904
|
+
apiUrl: "https://api.example.com/chat",
|
|
905
|
+
launcher: { mountMode: "composer-bar" },
|
|
906
|
+
// pop-bubble is bubbleClass-only (no containerClass, no wrap).
|
|
907
|
+
features: { streamAnimation: { type: "pop-bubble" } },
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
controller.injectAssistantMessage({ content: "hello", streaming: true });
|
|
911
|
+
controller.close();
|
|
912
|
+
const panel = mount.querySelector<HTMLElement>(".persona-widget-panel");
|
|
913
|
+
panel!.dispatchEvent(new PointerEvent("pointerenter", { bubbles: true }));
|
|
914
|
+
|
|
915
|
+
const peek = getPeekBanner(mount)!;
|
|
916
|
+
const textNode = getPeekText(mount)!;
|
|
917
|
+
// Neither the peek root nor its text should pick up the bubble class.
|
|
918
|
+
expect(peek.classList.contains("persona-stream-pop")).toBe(false);
|
|
919
|
+
expect(textNode.classList.contains("persona-stream-pop")).toBe(false);
|
|
920
|
+
|
|
921
|
+
controller.destroy();
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
it("renders a peek-sized skeleton when buffer:line + placeholder:skeleton trims content to empty", () => {
|
|
925
|
+
const mount = document.createElement("div");
|
|
926
|
+
document.body.appendChild(mount);
|
|
927
|
+
|
|
928
|
+
const controller = createAgentExperience(mount, {
|
|
929
|
+
apiUrl: "https://api.example.com/chat",
|
|
930
|
+
launcher: { mountMode: "composer-bar" },
|
|
931
|
+
features: {
|
|
932
|
+
streamAnimation: {
|
|
933
|
+
type: "typewriter",
|
|
934
|
+
buffer: "line",
|
|
935
|
+
placeholder: "skeleton",
|
|
936
|
+
},
|
|
937
|
+
},
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
// No newline yet → buffer:"line" trims to empty → skeleton stands in.
|
|
941
|
+
controller.injectAssistantMessage({ content: "first li", streaming: true });
|
|
942
|
+
controller.close();
|
|
943
|
+
const panel = mount.querySelector<HTMLElement>(".persona-widget-panel");
|
|
944
|
+
panel!.dispatchEvent(new PointerEvent("pointerenter", { bubbles: true }));
|
|
945
|
+
|
|
946
|
+
const textNode = getPeekText(mount)!;
|
|
947
|
+
expect(textNode.querySelector(".persona-pill-peek__skeleton")).not.toBeNull();
|
|
948
|
+
// No char spans yet — skeleton stands alone.
|
|
949
|
+
expect(textNode.querySelectorAll(".persona-stream-char").length).toBe(0);
|
|
950
|
+
|
|
951
|
+
controller.destroy();
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
it("falls back to the legacy plain-text preview when no streamAnimation is configured", () => {
|
|
955
|
+
const mount = document.createElement("div");
|
|
956
|
+
document.body.appendChild(mount);
|
|
957
|
+
|
|
958
|
+
const controller = createAgentExperience(mount, {
|
|
959
|
+
apiUrl: "https://api.example.com/chat",
|
|
960
|
+
launcher: { mountMode: "composer-bar" },
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
controller.injectAssistantMessage({ content: "hello", streaming: true });
|
|
964
|
+
controller.close();
|
|
965
|
+
const panel = mount.querySelector<HTMLElement>(".persona-widget-panel");
|
|
966
|
+
panel!.dispatchEvent(new PointerEvent("pointerenter", { bubbles: true }));
|
|
967
|
+
|
|
968
|
+
const textNode = getPeekText(mount)!;
|
|
969
|
+
expect(textNode.textContent).toBe("hello");
|
|
970
|
+
expect(textNode.querySelector(".persona-stream-char")).toBeNull();
|
|
971
|
+
expect(textNode.classList.contains("persona-stream-typewriter")).toBe(false);
|
|
972
|
+
|
|
973
|
+
controller.destroy();
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
it("does NOT dismiss the panel when pointerdown lands inside the pill or chat container", () => {
|
|
977
|
+
const mount = document.createElement("div");
|
|
978
|
+
document.body.appendChild(mount);
|
|
979
|
+
|
|
980
|
+
const controller = createAgentExperience(mount, {
|
|
981
|
+
apiUrl: "https://api.example.com/chat",
|
|
982
|
+
launcher: { mountMode: "composer-bar" },
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
controller.open();
|
|
986
|
+
const wrapper = mount.querySelector<HTMLElement>("[data-persona-composer-bar]");
|
|
987
|
+
expect(wrapper!.dataset.state).toBe("expanded");
|
|
988
|
+
|
|
989
|
+
// Click inside the pill textarea — must keep the panel expanded.
|
|
990
|
+
const textarea = mount.querySelector<HTMLTextAreaElement>(
|
|
991
|
+
"[data-persona-composer-input]"
|
|
992
|
+
);
|
|
993
|
+
expect(textarea).not.toBeNull();
|
|
994
|
+
textarea!.dispatchEvent(
|
|
995
|
+
new PointerEvent("pointerdown", { bubbles: true, composed: true })
|
|
996
|
+
);
|
|
997
|
+
expect(wrapper!.dataset.state).toBe("expanded");
|
|
998
|
+
|
|
999
|
+
// Click inside the chat container body — must also keep the panel open.
|
|
1000
|
+
const body = mount.querySelector<HTMLElement>(".persona-widget-body");
|
|
1001
|
+
expect(body).not.toBeNull();
|
|
1002
|
+
body!.dispatchEvent(
|
|
1003
|
+
new PointerEvent("pointerdown", { bubbles: true, composed: true })
|
|
1004
|
+
);
|
|
1005
|
+
expect(wrapper!.dataset.state).toBe("expanded");
|
|
1006
|
+
|
|
1007
|
+
controller.destroy();
|
|
1008
|
+
});
|
|
1009
|
+
});
|