@runtypelabs/persona 3.6.0 → 3.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.
Files changed (50) hide show
  1. package/dist/index.cjs +40 -40
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +73 -4
  4. package/dist/index.d.ts +73 -4
  5. package/dist/index.global.js +69 -69
  6. package/dist/index.global.js.map +1 -1
  7. package/dist/index.js +40 -40
  8. package/dist/index.js.map +1 -1
  9. package/dist/theme-editor.cjs +704 -243
  10. package/dist/theme-editor.d.cts +75 -5
  11. package/dist/theme-editor.d.ts +75 -5
  12. package/dist/theme-editor.js +703 -243
  13. package/dist/theme-reference.cjs +1 -1
  14. package/dist/theme-reference.d.cts +53 -0
  15. package/dist/theme-reference.d.ts +53 -0
  16. package/dist/theme-reference.js +1 -1
  17. package/dist/widget.css +44 -0
  18. package/package.json +1 -1
  19. package/src/components/artifact-card.ts +1 -1
  20. package/src/components/demo-carousel.ts +1 -1
  21. package/src/components/event-stream-view.test.ts +142 -0
  22. package/src/components/event-stream-view.ts +67 -28
  23. package/src/components/header-builder.ts +3 -0
  24. package/src/components/launcher.ts +7 -2
  25. package/src/components/panel.ts +3 -1
  26. package/src/defaults.ts +15 -0
  27. package/src/runtime/host-layout.test.ts +1 -1
  28. package/src/runtime/host-layout.ts +2 -1
  29. package/src/scroll-to-bottom-defaults.test.ts +13 -0
  30. package/src/styles/widget.css +44 -0
  31. package/src/theme-editor/index.ts +1 -0
  32. package/src/theme-editor/role-mappings.ts +12 -0
  33. package/src/theme-editor/sections.test.ts +43 -0
  34. package/src/theme-editor/sections.ts +42 -0
  35. package/src/theme-reference.ts +8 -0
  36. package/src/types/theme.ts +45 -0
  37. package/src/types.ts +31 -4
  38. package/src/ui.overlay-z-index.test.ts +34 -2
  39. package/src/ui.scroll.test.ts +554 -0
  40. package/src/ui.ts +264 -90
  41. package/src/utils/auto-follow.test.ts +110 -0
  42. package/src/utils/auto-follow.ts +112 -0
  43. package/src/utils/constants.ts +13 -0
  44. package/src/utils/dropdown.ts +2 -1
  45. package/src/utils/overlay-host-stacking.test.ts +61 -0
  46. package/src/utils/overlay-host-stacking.ts +38 -0
  47. package/src/utils/scroll-lock.test.ts +64 -0
  48. package/src/utils/scroll-lock.ts +62 -0
  49. package/src/utils/theme.test.ts +34 -0
  50. package/src/utils/tokens.ts +112 -0
package/src/types.ts CHANGED
@@ -552,10 +552,32 @@ export type AgentWidgetArtifactsFeature = {
552
552
  }) => HTMLElement | null;
553
553
  };
554
554
 
555
+ export type AgentWidgetScrollToBottomFeature = {
556
+ /**
557
+ * When true, Persona shows a scroll-to-bottom affordance when the user breaks
558
+ * away from the latest transcript or event stream content.
559
+ * @default true
560
+ */
561
+ enabled?: boolean;
562
+ /**
563
+ * Lucide icon name used for the affordance.
564
+ * @default "arrow-down"
565
+ */
566
+ iconName?: string;
567
+ /**
568
+ * Optional label text shown next to the icon. Set to an empty string for an
569
+ * icon-only affordance.
570
+ * @default ""
571
+ */
572
+ label?: string;
573
+ };
574
+
555
575
  export type AgentWidgetFeatureFlags = {
556
576
  showReasoning?: boolean;
557
577
  showToolCalls?: boolean;
558
578
  showEventStreamToggle?: boolean;
579
+ /** Shared transcript + event stream scroll-to-bottom affordance. */
580
+ scrollToBottom?: AgentWidgetScrollToBottomFeature;
559
581
  /** Configuration for the Event Stream inspector view */
560
582
  eventStream?: EventStreamConfig;
561
583
  /** Optional artifact sidebar (split pane / mobile drawer) */
@@ -791,11 +813,16 @@ export type AgentWidgetLauncherConfig = {
791
813
  */
792
814
  mobileBreakpoint?: number;
793
815
  /**
794
- * CSS z-index applied to the widget wrapper when it is in a positioned mode
795
- * (floating panel, mobile fullscreen, or sidebar). Increase this value if
796
- * other elements on the host page appear on top of the widget.
816
+ * CSS z-index applied to the widget wrapper and launcher button in all
817
+ * positioned modes (floating panel, mobile fullscreen, sidebar, docked
818
+ * mobile fullscreen). Increase this value if other elements on the host
819
+ * page appear on top of the widget.
820
+ *
821
+ * In viewport-covering modes (sidebar, mobile fullscreen), the widget
822
+ * also elevates the host element's stacking context and locks
823
+ * document scroll to prevent background scrolling.
797
824
  *
798
- * @default 9999 in overlay modes (mobile fullscreen / sidebar); 50 for the regular floating panel
825
+ * @default 100000
799
826
  */
800
827
  zIndex?: number;
801
828
  callToActionIconText?: string;
@@ -39,7 +39,7 @@ describe("createAgentExperience overlay z-index", () => {
39
39
  const wrapper = mount.firstElementChild as HTMLElement | null;
40
40
 
41
41
  expect(wrapper).not.toBeNull();
42
- expect(wrapper?.style.zIndex).toBe("9999");
42
+ expect(wrapper?.style.zIndex).toBe("100000");
43
43
 
44
44
  controller.destroy();
45
45
  });
@@ -55,7 +55,39 @@ describe("createAgentExperience overlay z-index", () => {
55
55
  const wrapper = mount.firstElementChild as HTMLElement | null;
56
56
 
57
57
  expect(wrapper).not.toBeNull();
58
- expect(wrapper?.style.zIndex).toBe("9999");
58
+ expect(wrapper?.style.zIndex).toBe("100000");
59
+
60
+ controller.destroy();
61
+ });
62
+
63
+ it("defaults floating panel wrapper to the overlay z-index", () => {
64
+ setInnerWidth(1024);
65
+
66
+ const mount = createMount();
67
+ const controller = createAgentExperience(mount, {
68
+ apiUrl: "https://api.example.com/chat",
69
+ });
70
+
71
+ const wrapper = mount.firstElementChild as HTMLElement | null;
72
+
73
+ expect(wrapper).not.toBeNull();
74
+ expect(wrapper?.style.zIndex).toBe("100000");
75
+
76
+ controller.destroy();
77
+ });
78
+
79
+ it("respects a custom zIndex", () => {
80
+ const mount = createMount();
81
+ const controller = createAgentExperience(mount, {
82
+ apiUrl: "https://api.example.com/chat",
83
+ launcher: {
84
+ sidebarMode: true,
85
+ zIndex: 42,
86
+ },
87
+ });
88
+
89
+ const wrapper = mount.firstElementChild as HTMLElement | null;
90
+ expect(wrapper?.style.zIndex).toBe("42");
59
91
 
60
92
  controller.destroy();
61
93
  });
@@ -0,0 +1,554 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { createAgentExperience } from "./ui";
6
+
7
+ type RafCallback = (time: number) => void;
8
+
9
+ const STREAM_MESSAGE_ID = "ast-stream";
10
+ const STREAM_CREATED_AT = "2026-03-29T00:00:00.000Z";
11
+
12
+ const createMount = () => {
13
+ const mount = document.createElement("div");
14
+ document.body.appendChild(mount);
15
+ return mount;
16
+ };
17
+
18
+ const getScrollToBottomButton = (mount: HTMLElement) =>
19
+ mount.querySelector<HTMLElement>("[data-persona-scroll-to-bottom]");
20
+
21
+ const installRafMock = () => {
22
+ let nextId = 1;
23
+ let now = performance.now();
24
+ const callbacks = new Map<number, RafCallback>();
25
+
26
+ vi.stubGlobal("requestAnimationFrame", (callback: RafCallback) => {
27
+ const id = nextId++;
28
+ callbacks.set(id, callback);
29
+ return id;
30
+ });
31
+
32
+ vi.stubGlobal("cancelAnimationFrame", (id: number) => {
33
+ callbacks.delete(id);
34
+ });
35
+
36
+ return {
37
+ flush(maxFrames = 80) {
38
+ let frames = 0;
39
+ while (callbacks.size > 0 && frames < maxFrames) {
40
+ const pending = [...callbacks.entries()];
41
+ callbacks.clear();
42
+ frames += 1;
43
+ now += 16;
44
+ pending.forEach(([, callback]) => callback(now));
45
+ }
46
+
47
+ if (callbacks.size > 0) {
48
+ throw new Error("requestAnimationFrame queue did not settle");
49
+ }
50
+ }
51
+ };
52
+ };
53
+
54
+ const installScrollMetrics = (
55
+ element: HTMLElement,
56
+ initial: { scrollHeight: number; clientHeight: number }
57
+ ) => {
58
+ let scrollTop = 0;
59
+ let scrollHeight = initial.scrollHeight;
60
+ const clientHeight = initial.clientHeight;
61
+
62
+ Object.defineProperties(element, {
63
+ scrollTop: {
64
+ configurable: true,
65
+ get: () => scrollTop,
66
+ set: (value: number) => {
67
+ const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
68
+ scrollTop = Math.max(0, Math.min(value, maxScrollTop));
69
+ }
70
+ },
71
+ scrollHeight: {
72
+ configurable: true,
73
+ get: () => scrollHeight
74
+ },
75
+ clientHeight: {
76
+ configurable: true,
77
+ get: () => clientHeight
78
+ }
79
+ });
80
+
81
+ return {
82
+ getScrollTop: () => scrollTop,
83
+ getBottomScrollTop: () => Math.max(0, scrollHeight - clientHeight),
84
+ setScrollTop: (value: number) => {
85
+ element.scrollTop = value;
86
+ },
87
+ setScrollHeight: (value: number) => {
88
+ scrollHeight = value;
89
+ if (scrollTop > scrollHeight - clientHeight) {
90
+ scrollTop = Math.max(0, scrollHeight - clientHeight);
91
+ }
92
+ }
93
+ };
94
+ };
95
+
96
+ const emitStreamingStatus = (controller: ReturnType<typeof createAgentExperience>) => {
97
+ controller.injectTestMessage({ type: "status", status: "connecting" });
98
+ };
99
+
100
+ const emitStreamingMessage = (
101
+ controller: ReturnType<typeof createAgentExperience>,
102
+ content: string
103
+ ) => {
104
+ controller.injectTestMessage({
105
+ type: "message",
106
+ message: {
107
+ id: STREAM_MESSAGE_ID,
108
+ role: "assistant",
109
+ content,
110
+ createdAt: STREAM_CREATED_AT,
111
+ streaming: true
112
+ }
113
+ });
114
+ };
115
+
116
+ const emitReasoningMessage = (
117
+ controller: ReturnType<typeof createAgentExperience>,
118
+ chunks: string[]
119
+ ) => {
120
+ controller.injectTestMessage({
121
+ type: "message",
122
+ message: {
123
+ id: STREAM_MESSAGE_ID,
124
+ role: "assistant",
125
+ content: "",
126
+ createdAt: STREAM_CREATED_AT,
127
+ streaming: true,
128
+ variant: "reasoning",
129
+ reasoning: {
130
+ id: "reason-1",
131
+ status: "streaming",
132
+ chunks
133
+ }
134
+ }
135
+ });
136
+ };
137
+
138
+ const createCustomComposer = () => {
139
+ const footer = document.createElement("div");
140
+ footer.className = "persona-widget-footer";
141
+
142
+ const form = document.createElement("form");
143
+ form.setAttribute("data-persona-composer-form", "");
144
+
145
+ const textarea = document.createElement("textarea");
146
+ textarea.setAttribute("data-persona-composer-input", "");
147
+
148
+ const status = document.createElement("div");
149
+ status.setAttribute("data-persona-composer-status", "");
150
+
151
+ form.appendChild(textarea);
152
+ footer.append(form, status);
153
+ return footer;
154
+ };
155
+
156
+ describe("createAgentExperience streaming scroll", () => {
157
+ beforeEach(() => {
158
+ installRafMock();
159
+ });
160
+
161
+ afterEach(() => {
162
+ document.body.innerHTML = "";
163
+ vi.restoreAllMocks();
164
+ });
165
+
166
+ it("stops auto-follow after a small upward scroll during streaming", () => {
167
+ const raf = installRafMock();
168
+ const mount = createMount();
169
+ const controller = createAgentExperience(mount, {
170
+ apiUrl: "https://api.example.com/chat",
171
+ launcher: { enabled: false }
172
+ });
173
+
174
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
175
+ expect(scrollContainer).not.toBeNull();
176
+
177
+ const metrics = installScrollMetrics(scrollContainer!, {
178
+ scrollHeight: 1000,
179
+ clientHeight: 400
180
+ });
181
+
182
+ emitStreamingStatus(controller);
183
+ emitStreamingMessage(controller, "First chunk");
184
+ raf.flush();
185
+
186
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
187
+
188
+ metrics.setScrollTop(metrics.getBottomScrollTop() - 3);
189
+ scrollContainer!.dispatchEvent(new Event("scroll"));
190
+
191
+ metrics.setScrollHeight(1040);
192
+ emitStreamingMessage(controller, "Second chunk");
193
+ raf.flush();
194
+
195
+ expect(metrics.getScrollTop()).toBe(597);
196
+
197
+ controller.destroy();
198
+ });
199
+
200
+ it("pauses auto-follow on upward wheel intent before the next streamed update", () => {
201
+ const raf = installRafMock();
202
+ const mount = createMount();
203
+ const controller = createAgentExperience(mount, {
204
+ apiUrl: "https://api.example.com/chat",
205
+ launcher: { enabled: false }
206
+ });
207
+
208
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
209
+ expect(scrollContainer).not.toBeNull();
210
+
211
+ const metrics = installScrollMetrics(scrollContainer!, {
212
+ scrollHeight: 1000,
213
+ clientHeight: 400
214
+ });
215
+
216
+ emitStreamingStatus(controller);
217
+ emitStreamingMessage(controller, "First chunk");
218
+ raf.flush();
219
+
220
+ scrollContainer!.dispatchEvent(new WheelEvent("wheel", { deltaY: -24 }));
221
+ metrics.setScrollTop(580);
222
+ metrics.setScrollHeight(1060);
223
+
224
+ emitStreamingMessage(controller, "Second chunk");
225
+ raf.flush();
226
+
227
+ expect(metrics.getScrollTop()).toBe(580);
228
+
229
+ controller.destroy();
230
+ });
231
+
232
+ it("resumes auto-follow when the user scrolls back to the bottom", () => {
233
+ const raf = installRafMock();
234
+ const mount = createMount();
235
+ const controller = createAgentExperience(mount, {
236
+ apiUrl: "https://api.example.com/chat",
237
+ launcher: { enabled: false }
238
+ });
239
+
240
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
241
+ expect(scrollContainer).not.toBeNull();
242
+
243
+ const metrics = installScrollMetrics(scrollContainer!, {
244
+ scrollHeight: 1000,
245
+ clientHeight: 400
246
+ });
247
+
248
+ emitStreamingStatus(controller);
249
+ emitStreamingMessage(controller, "First chunk");
250
+ raf.flush();
251
+
252
+ scrollContainer!.dispatchEvent(new WheelEvent("wheel", { deltaY: -24 }));
253
+ metrics.setScrollTop(560);
254
+ metrics.setScrollHeight(1060);
255
+ emitStreamingMessage(controller, "Second chunk");
256
+ raf.flush();
257
+
258
+ expect(metrics.getScrollTop()).toBe(560);
259
+
260
+ metrics.setScrollTop(metrics.getBottomScrollTop() - 2);
261
+ scrollContainer!.dispatchEvent(new Event("scroll"));
262
+
263
+ metrics.setScrollHeight(1100);
264
+ emitStreamingMessage(controller, "Third chunk");
265
+ raf.flush();
266
+
267
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
268
+
269
+ controller.destroy();
270
+ });
271
+
272
+ it("does not immediately resume after an upward scroll while still near the bottom", () => {
273
+ const raf = installRafMock();
274
+ const mount = createMount();
275
+ const controller = createAgentExperience(mount, {
276
+ apiUrl: "https://api.example.com/chat",
277
+ launcher: { enabled: false }
278
+ });
279
+
280
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
281
+ expect(scrollContainer).not.toBeNull();
282
+
283
+ const metrics = installScrollMetrics(scrollContainer!, {
284
+ scrollHeight: 1000,
285
+ clientHeight: 400
286
+ });
287
+
288
+ emitStreamingStatus(controller);
289
+ emitStreamingMessage(controller, "First chunk");
290
+ raf.flush();
291
+
292
+ scrollContainer!.dispatchEvent(new WheelEvent("wheel", { deltaY: -24 }));
293
+ metrics.setScrollTop(metrics.getBottomScrollTop() - 3);
294
+ scrollContainer!.dispatchEvent(new Event("scroll"));
295
+
296
+ metrics.setScrollHeight(1040);
297
+ emitStreamingMessage(controller, "Second chunk");
298
+ raf.flush();
299
+
300
+ expect(metrics.getScrollTop()).toBe(597);
301
+ expect(getScrollToBottomButton(mount)?.style.display).not.toBe("none");
302
+
303
+ controller.destroy();
304
+ });
305
+
306
+ it("keeps following the stream when the user does not scroll", () => {
307
+ const raf = installRafMock();
308
+ const mount = createMount();
309
+ const controller = createAgentExperience(mount, {
310
+ apiUrl: "https://api.example.com/chat",
311
+ launcher: { enabled: false }
312
+ });
313
+
314
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
315
+ expect(scrollContainer).not.toBeNull();
316
+
317
+ const metrics = installScrollMetrics(scrollContainer!, {
318
+ scrollHeight: 900,
319
+ clientHeight: 400
320
+ });
321
+
322
+ emitStreamingStatus(controller);
323
+ emitStreamingMessage(controller, "Chunk one");
324
+ raf.flush();
325
+
326
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
327
+
328
+ metrics.setScrollHeight(980);
329
+ emitStreamingMessage(controller, "Chunk two");
330
+ raf.flush();
331
+
332
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
333
+
334
+ controller.destroy();
335
+ });
336
+
337
+ it("lets the user break away during reasoning streaming", () => {
338
+ const raf = installRafMock();
339
+ const mount = createMount();
340
+ const controller = createAgentExperience(mount, {
341
+ apiUrl: "https://api.example.com/chat",
342
+ launcher: { enabled: false }
343
+ });
344
+
345
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
346
+ expect(scrollContainer).not.toBeNull();
347
+
348
+ const metrics = installScrollMetrics(scrollContainer!, {
349
+ scrollHeight: 960,
350
+ clientHeight: 400
351
+ });
352
+
353
+ emitStreamingStatus(controller);
354
+ emitReasoningMessage(controller, ["Thinking"]);
355
+ raf.flush();
356
+
357
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
358
+
359
+ scrollContainer!.dispatchEvent(new WheelEvent("wheel", { deltaY: -18 }));
360
+ metrics.setScrollTop(metrics.getBottomScrollTop() - 4);
361
+ metrics.setScrollHeight(1010);
362
+
363
+ emitReasoningMessage(controller, ["Thinking", " harder"]);
364
+ raf.flush();
365
+
366
+ expect(metrics.getScrollTop()).toBe(556);
367
+
368
+ controller.destroy();
369
+ });
370
+
371
+ it("uses icon-only arrow-down defaults for the transcript affordance", () => {
372
+ const raf = installRafMock();
373
+ const mount = createMount();
374
+ const controller = createAgentExperience(mount, {
375
+ apiUrl: "https://api.example.com/chat",
376
+ launcher: { enabled: false }
377
+ });
378
+
379
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
380
+ expect(scrollContainer).not.toBeNull();
381
+
382
+ const metrics = installScrollMetrics(scrollContainer!, {
383
+ scrollHeight: 1000,
384
+ clientHeight: 400
385
+ });
386
+
387
+ emitStreamingStatus(controller);
388
+ emitStreamingMessage(controller, "First chunk");
389
+ raf.flush();
390
+
391
+ scrollContainer!.dispatchEvent(new WheelEvent("wheel", { deltaY: -18 }));
392
+ metrics.setScrollTop(560);
393
+ metrics.setScrollHeight(1060);
394
+ emitStreamingMessage(controller, "Second chunk");
395
+ raf.flush();
396
+
397
+ const button = getScrollToBottomButton(mount);
398
+ expect(button).not.toBeNull();
399
+ expect(button?.textContent?.trim()).toBe("");
400
+ expect(button?.querySelector("svg")).not.toBeNull();
401
+
402
+ controller.destroy();
403
+ });
404
+
405
+ it("anchors the transcript affordance outside the scroll container", () => {
406
+ const raf = installRafMock();
407
+ const mount = createMount();
408
+ const controller = createAgentExperience(mount, {
409
+ apiUrl: "https://api.example.com/chat",
410
+ launcher: { enabled: false }
411
+ });
412
+
413
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
414
+ expect(scrollContainer).not.toBeNull();
415
+
416
+ const metrics = installScrollMetrics(scrollContainer!, {
417
+ scrollHeight: 1000,
418
+ clientHeight: 400
419
+ });
420
+
421
+ emitStreamingStatus(controller);
422
+ emitStreamingMessage(controller, "First chunk");
423
+ raf.flush();
424
+
425
+ scrollContainer!.dispatchEvent(new WheelEvent("wheel", { deltaY: -18 }));
426
+ metrics.setScrollTop(560);
427
+ metrics.setScrollHeight(1060);
428
+ emitStreamingMessage(controller, "Second chunk");
429
+ raf.flush();
430
+
431
+ const button = getScrollToBottomButton(mount);
432
+ expect(button).not.toBeNull();
433
+ expect(button?.parentElement).not.toBe(scrollContainer);
434
+
435
+ controller.destroy();
436
+ });
437
+
438
+ it("keeps the transcript affordance outside the scroll container with a custom composer", () => {
439
+ const raf = installRafMock();
440
+ const mount = createMount();
441
+ const controller = createAgentExperience(mount, {
442
+ apiUrl: "https://api.example.com/chat",
443
+ launcher: { enabled: false },
444
+ plugins: [
445
+ {
446
+ id: "custom-composer",
447
+ renderComposer: () => createCustomComposer()
448
+ }
449
+ ]
450
+ } as any);
451
+
452
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
453
+ expect(scrollContainer).not.toBeNull();
454
+
455
+ const metrics = installScrollMetrics(scrollContainer!, {
456
+ scrollHeight: 1000,
457
+ clientHeight: 400
458
+ });
459
+
460
+ emitStreamingStatus(controller);
461
+ emitStreamingMessage(controller, "First chunk");
462
+ raf.flush();
463
+
464
+ scrollContainer!.dispatchEvent(new WheelEvent("wheel", { deltaY: -18 }));
465
+ metrics.setScrollTop(560);
466
+ metrics.setScrollHeight(1060);
467
+ emitStreamingMessage(controller, "Second chunk");
468
+ raf.flush();
469
+
470
+ const button = getScrollToBottomButton(mount);
471
+ expect(button).not.toBeNull();
472
+ expect(button?.parentElement).not.toBe(scrollContainer);
473
+
474
+ controller.destroy();
475
+ });
476
+
477
+ it("hides the transcript affordance when scroll-to-bottom is disabled", () => {
478
+ const raf = installRafMock();
479
+ const mount = createMount();
480
+ const controller = createAgentExperience(mount, {
481
+ apiUrl: "https://api.example.com/chat",
482
+ launcher: { enabled: false },
483
+ features: {
484
+ scrollToBottom: {
485
+ enabled: false
486
+ }
487
+ }
488
+ } as any);
489
+
490
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
491
+ expect(scrollContainer).not.toBeNull();
492
+
493
+ const metrics = installScrollMetrics(scrollContainer!, {
494
+ scrollHeight: 1000,
495
+ clientHeight: 400
496
+ });
497
+
498
+ emitStreamingStatus(controller);
499
+ emitStreamingMessage(controller, "First chunk");
500
+ raf.flush();
501
+
502
+ scrollContainer!.dispatchEvent(new WheelEvent("wheel", { deltaY: -18 }));
503
+ metrics.setScrollTop(560);
504
+ metrics.setScrollHeight(1060);
505
+ emitStreamingMessage(controller, "Second chunk");
506
+ raf.flush();
507
+
508
+ expect(getScrollToBottomButton(mount)).toBeNull();
509
+
510
+ controller.destroy();
511
+ });
512
+
513
+ it("renders the transcript affordance as icon-only when label is empty", () => {
514
+ const raf = installRafMock();
515
+ const mount = createMount();
516
+ const controller = createAgentExperience(mount, {
517
+ apiUrl: "https://api.example.com/chat",
518
+ launcher: { enabled: false },
519
+ features: {
520
+ scrollToBottom: {
521
+ enabled: true,
522
+ iconName: "arrow-down",
523
+ label: ""
524
+ }
525
+ }
526
+ } as any);
527
+
528
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
529
+ expect(scrollContainer).not.toBeNull();
530
+
531
+ const metrics = installScrollMetrics(scrollContainer!, {
532
+ scrollHeight: 1000,
533
+ clientHeight: 400
534
+ });
535
+
536
+ emitStreamingStatus(controller);
537
+ emitStreamingMessage(controller, "First chunk");
538
+ raf.flush();
539
+
540
+ scrollContainer!.dispatchEvent(new WheelEvent("wheel", { deltaY: -18 }));
541
+ metrics.setScrollTop(560);
542
+ metrics.setScrollHeight(1060);
543
+ emitStreamingMessage(controller, "Second chunk");
544
+ raf.flush();
545
+
546
+ const button = getScrollToBottomButton(mount);
547
+ expect(button).not.toBeNull();
548
+ expect(button?.textContent?.trim()).toBe("");
549
+ expect(button?.querySelector("svg")).not.toBeNull();
550
+
551
+ controller.destroy();
552
+ });
553
+
554
+ });