@runtypelabs/persona 4.32.2 → 4.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/activity-ui.d.cts +3 -15
- package/dist/activity-ui.d.ts +3 -15
- package/dist/animations/glyph-cycle.d.cts +1 -3
- package/dist/animations/glyph-cycle.d.ts +1 -3
- package/dist/animations/wipe.d.cts +1 -3
- package/dist/animations/wipe.d.ts +1 -3
- package/dist/approval-ui.cjs +2 -2
- package/dist/approval-ui.d.cts +3 -15
- package/dist/approval-ui.d.ts +3 -15
- package/dist/approval-ui.js +1 -1
- package/dist/artifacts-ui.cjs +1 -1
- package/dist/artifacts-ui.d.cts +3 -21
- package/dist/artifacts-ui.d.ts +3 -21
- package/dist/background-runtime.d.cts +2 -3
- package/dist/background-runtime.d.ts +2 -3
- package/dist/{chunk-BbXc58OA.js → chunk-6CUrH_jm.js} +1 -1
- package/dist/{chunk-B_H0l3u2.js → chunk-BRM3bHcJ.js} +1 -1
- package/dist/{chunk-DNdeIFJ6.js → chunk-Brdx4NCz.js} +1 -1
- package/dist/chunk-C-rux8Q9.js +1 -0
- package/dist/{chunk-Ds_twaZn.js → chunk-C4v-HNhS.js} +1 -1
- package/dist/{chunk-Bkma5hIU.js → chunk-CE4seJxF.js} +1 -1
- package/dist/chunk-CEPM3FOp.js +1 -0
- package/dist/{chunk-BpjtKnGI.js → chunk-CV8EPIhT.js} +1 -1
- package/dist/{chunk-DVZ8ri1z.js → chunk-CWzHNuVK.js} +1 -1
- package/dist/{chunk-B3l7TOOv.js → chunk-CYpt7PsO.js} +1 -1
- package/dist/{chunk-2FnmZ9AA.js → chunk-Csr59G5V.js} +1 -1
- package/dist/{chunk-B9Zu-wrk.js → chunk-CxNPSY-b.js} +1 -1
- package/dist/{chunk-UWMfWW2a.js → chunk-D1G8tgYc.js} +1 -1
- package/dist/{chunk-DTGvDx8o.js → chunk-Dj2a9f9w.js} +1 -1
- package/dist/chunk-Dqa_GOcj.js +1 -0
- package/dist/chunk-DsWv3EGF.js +1 -0
- package/dist/{chunk-C7-m0EmI.js → chunk-NUNdcnAZ.js} +1 -1
- package/dist/{chunk-D0tcIZJM.js → chunk-YMkMvAsa.js} +2 -2
- package/dist/{chunk-C-EHtpbB.js → chunk-h5aOI_Pa.js} +1 -1
- package/dist/chunk-i4qGOJAL.js +1 -0
- package/dist/chunk-kizhspe5.js +1 -0
- package/dist/client-dispatch.js +1 -1
- package/dist/client-history.js +1 -1
- package/dist/client-stream.js +1 -1
- package/dist/{code-generators-DqauYyfX.d.cts → code-generators-DPAqTcAz.d.cts} +0 -2
- package/dist/{code-generators-DqauYyfX.d.ts → code-generators-DPAqTcAz.d.ts} +0 -2
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.d.cts +1 -1
- package/dist/codegen.d.ts +1 -1
- package/dist/codegen.js +1 -1
- package/dist/composer-actions-runtime.cjs +1 -1
- package/dist/composer-actions-runtime.d.cts +4 -20
- package/dist/composer-actions-runtime.d.ts +4 -20
- package/dist/composer-actions-runtime.js +1 -1
- package/dist/{composer-input-dwg_o9Z-.d.ts → composer-input-i_FiUp6J.d.cts} +1 -7
- package/dist/{composer-input-dwg_o9Z-.d.cts → composer-input-i_FiUp6J.d.ts} +1 -7
- package/dist/{composer-store-Dw7Zbqxr.d.ts → composer-store-DhGNgG7Z.d.cts} +1 -3
- package/dist/{composer-store-Dw7Zbqxr.d.cts → composer-store-DhGNgG7Z.d.ts} +1 -3
- package/dist/config-diagnostics.d.cts +0 -2
- package/dist/config-diagnostics.d.ts +0 -2
- package/dist/{context-mention-manager-D2p0-JH-.d.ts → context-mention-manager-DL-xwGfL.d.cts} +2 -6
- package/dist/{context-mention-manager-D2p0-JH-.d.cts → context-mention-manager-DL-xwGfL.d.ts} +2 -6
- package/dist/context-mentions-inline.cjs +1 -1
- package/dist/context-mentions-inline.d.cts +2 -4
- package/dist/context-mentions-inline.d.ts +2 -4
- package/dist/context-mentions-inline.js +1 -1
- package/dist/context-mentions.cjs +1 -1
- package/dist/context-mentions.d.cts +3 -9
- package/dist/context-mentions.d.ts +3 -9
- package/dist/{defaults-CjX0kKzb.d.ts → defaults-Bga2Xrsp.d.cts} +2 -5
- package/dist/{defaults-CjX0kKzb.d.cts → defaults-Bga2Xrsp.d.ts} +2 -5
- package/dist/dictation-runtime.cjs +1 -1
- package/dist/dictation-runtime.d.cts +4 -13
- package/dist/dictation-runtime.d.ts +4 -13
- package/dist/dictation-runtime.js +1 -1
- package/dist/{dom-context-DHTabIn7.d.ts → dom-context-Bw-_9Evh.d.cts} +0 -2
- package/dist/{dom-context-DHTabIn7.d.cts → dom-context-Bw-_9Evh.d.ts} +0 -2
- package/dist/event-stream-view.cjs +1 -1
- package/dist/event-stream-view.d.cts +3 -16
- package/dist/event-stream-view.d.ts +3 -16
- package/dist/{fallback-speech-engine-DmF7MczI.d.ts → fallback-speech-engine-kLdyPcmk.d.cts} +1 -3
- package/dist/{fallback-speech-engine-DmF7MczI.d.cts → fallback-speech-engine-kLdyPcmk.d.ts} +1 -3
- package/dist/{feature-preferences-CVFDt9Xf.d.ts → feature-preferences-CmwZGKrf.d.cts} +1 -5
- package/dist/{feature-preferences-CVFDt9Xf.d.cts → feature-preferences-CmwZGKrf.d.ts} +1 -5
- package/dist/forms-ui.d.cts +2 -4
- package/dist/forms-ui.d.ts +2 -4
- package/dist/{history-confirm-dialog-DJEXsTyE.d.cts → history-confirm-dialog-i46-lPN_.d.cts} +2 -14
- package/dist/{history-confirm-dialog-DJEXsTyE.d.ts → history-confirm-dialog-i46-lPN_.d.ts} +2 -14
- package/dist/history-shell.cjs +1 -1
- package/dist/history-shell.d.cts +8 -27
- package/dist/history-shell.d.ts +8 -27
- package/dist/history-shell.js +1 -1
- package/dist/history-view.d.cts +1 -1
- package/dist/history-view.d.ts +1 -1
- package/dist/icons-extra.cjs +1 -1
- package/dist/icons-extra.d.cts +0 -2
- package/dist/icons-extra.d.ts +0 -2
- package/dist/index.cjs +44 -44
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +11 -11
- package/dist/index.d.ts +11 -11
- package/dist/index.global.js +1 -1
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +46 -46
- 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 +2 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/{loading-label-runtime-loader-CPxnoaoi.d.ts → loading-label-runtime-loader-Ccr3ETd3.d.cts} +1 -3
- package/dist/{loading-label-runtime-loader-CPxnoaoi.d.cts → loading-label-runtime-loader-Ccr3ETd3.d.ts} +1 -3
- package/dist/loading-label-runtime.cjs +1 -1
- package/dist/loading-label-runtime.d.cts +1 -1
- package/dist/loading-label-runtime.d.ts +1 -1
- package/dist/loading-label-runtime.js +1 -1
- package/dist/markdown-parsers.js +1 -1
- package/dist/{message-bubble-CkZGlCLC.d.ts → message-bubble-C6AVaH7f.d.cts} +21 -4
- package/dist/{message-bubble-CkZGlCLC.d.cts → message-bubble-C6AVaH7f.d.ts} +21 -4
- package/dist/{plugin-kit-C1mRjLgW.d.cts → plugin-kit-BVggqE3C.d.cts} +0 -2
- package/dist/{plugin-kit-C1mRjLgW.d.ts → plugin-kit-BVggqE3C.d.ts} +0 -2
- package/dist/plugin-kit.d.cts +1 -1
- package/dist/plugin-kit.d.ts +1 -1
- package/dist/preferences-runtime.cjs +1 -1
- package/dist/preferences-runtime.d.cts +1 -1
- package/dist/preferences-runtime.d.ts +1 -1
- package/dist/preferences-runtime.js +1 -1
- package/dist/{preview-DdUvoLb4.d.cts → preview-BMdRffo4.d.cts} +2 -6
- package/dist/{preview-DdUvoLb4.d.ts → preview-BMdRffo4.d.ts} +2 -6
- package/dist/{registry-Dz4iHNST.d.ts → registry-B16l8s1b.d.cts} +1 -8
- package/dist/{registry-Dz4iHNST.d.cts → registry-B16l8s1b.d.ts} +1 -8
- package/dist/{session-Baxp9iRK.d.cts → session-CtZHe7fc.d.cts} +10 -93
- package/dist/{session-Baxp9iRK.d.ts → session-CtZHe7fc.d.ts} +10 -93
- package/dist/session-reconnect.d.cts +2 -4
- package/dist/session-reconnect.d.ts +2 -4
- package/dist/smart-dom-reader.d.cts +2 -8
- package/dist/smart-dom-reader.d.ts +2 -8
- package/dist/testing.d.cts +0 -2
- package/dist/testing.d.ts +0 -2
- package/dist/theme-editor-preview.cjs +42 -42
- package/dist/theme-editor-preview.d.cts +1 -1
- package/dist/theme-editor-preview.d.ts +1 -1
- package/dist/theme-editor-preview.js +25 -25
- package/dist/theme-editor.cjs +25 -5
- package/dist/theme-editor.d.cts +2 -22
- package/dist/theme-editor.d.ts +2 -22
- package/dist/theme-editor.js +1 -1
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +4 -4
- package/dist/theme-reference.d.ts +4 -4
- package/dist/theme-reference.js +1 -1
- package/dist/{tool-loading-animation-un3kA5Ur.d.ts → tool-loading-animation-D2cyFvmM.d.cts} +1 -7
- package/dist/{tool-loading-animation-un3kA5Ur.d.cts → tool-loading-animation-D2cyFvmM.d.ts} +1 -7
- package/dist/{tooltip-BkW-8MnD.d.cts → tooltip-DbXkAuJL.d.cts} +0 -2
- package/dist/{tooltip-BkW-8MnD.d.ts → tooltip-DbXkAuJL.d.ts} +0 -2
- package/dist/{turn-activity-ui-BGNhtMcH.d.cts → turn-activity-ui-Do1Nd-Ow.d.cts} +2 -12
- package/dist/{turn-activity-ui-BGNhtMcH.d.ts → turn-activity-ui-Do1Nd-Ow.d.ts} +2 -12
- package/dist/turn-activity-ui.d.cts +1 -1
- package/dist/turn-activity-ui.d.ts +1 -1
- package/dist/{types-BOZHQIk0.d.cts → types-BC7MRxO5.d.cts} +167 -36
- package/dist/{types-BOZHQIk0.d.ts → types-BC7MRxO5.d.ts} +167 -36
- package/dist/ui-extras.js +3 -3
- package/dist/voice-client-tools.d.cts +1 -3
- package/dist/voice-client-tools.d.ts +1 -3
- package/dist/voice-runtime.cjs +1 -1
- package/dist/voice-runtime.d.cts +2 -12
- package/dist/voice-runtime.d.ts +2 -12
- package/dist/voice-runtime.js +2 -2
- package/dist/voice-worklet-player.d.cts +2 -6
- package/dist/voice-worklet-player.d.ts +2 -6
- package/dist/{webmcp-runtime-entry-DuAG1_yF.d.ts → webmcp-runtime-entry-B8g46vS0.d.cts} +1 -5
- package/dist/{webmcp-runtime-entry-DuAG1_yF.d.cts → webmcp-runtime-entry-B8g46vS0.d.ts} +1 -5
- package/dist/webmcp-runtime.d.cts +1 -1
- package/dist/webmcp-runtime.d.ts +1 -1
- package/dist/widget.css +1 -1
- package/package.json +4 -4
- package/src/client-dispatch.ts +69 -16
- package/src/client.operator-role.test.ts +217 -0
- package/src/components/event-stream-view.test.ts +5 -0
- package/src/components/message-bubble.test.ts +169 -0
- package/src/components/message-bubble.ts +123 -34
- package/src/components/scroll-to-bottom.test.ts +135 -0
- package/src/components/scroll-to-bottom.ts +50 -13
- package/src/components/user-message-collapse.ts +117 -0
- package/src/defaults-parity.test.ts +14 -0
- package/src/defaults.ts +6 -0
- package/src/index-core.ts +7 -0
- package/src/session.resubmit-from.test.ts +27 -0
- package/src/session.test.ts +10 -0
- package/src/session.ts +8 -5
- package/src/styles/widget.css +79 -1
- package/src/theme-reference.ts +5 -3
- package/src/types/theme.ts +50 -0
- package/src/types.ts +120 -11
- package/src/ui.ask-user-question-plugin.test.ts +154 -0
- package/src/ui.operator-role.test.ts +336 -0
- package/src/ui.scroll-additive.test.ts +219 -117
- package/src/ui.scroll.test.ts +176 -13
- package/src/ui.ts +267 -119
- package/src/ui.turn-activity.test.ts +7 -7
- package/src/ui.user-message-collapse.test.ts +540 -0
- package/src/utils/__snapshots__/tokens-parity.test.ts.snap +236 -0
- package/src/utils/auto-follow.test.ts +0 -38
- package/src/utils/auto-follow.ts +0 -35
- package/src/utils/brand.test.ts +14 -0
- package/src/utils/brand.ts +8 -4
- package/src/utils/history-messages.test.ts +30 -0
- package/src/utils/history-messages.ts +26 -8
- package/src/utils/message-fingerprint.ts +4 -0
- package/src/utils/resubmit-turn.test.ts +13 -0
- package/src/utils/resubmit-turn.ts +12 -1
- package/src/utils/theme.test.ts +23 -0
- package/src/utils/tokens.ts +47 -0
- package/src/welcome.test.ts +16 -0
- package/src/welcome.ts +10 -2
- package/dist/chunk-BNE40cEG.js +0 -1
- package/dist/chunk-Bz9jAgba.js +0 -1
- package/dist/chunk-CVMIncEY.js +0 -1
- package/dist/chunk-CdpVP6LZ.js +0 -1
- package/dist/chunk-CjkechxR.js +0 -1
- package/dist/chunk-DgZZqy14.js +0 -1
|
@@ -72,7 +72,7 @@ it("keeps a manual close/open choice through streaming and completion", async ()
|
|
|
72
72
|
|
|
73
73
|
it.each([
|
|
74
74
|
["instant", false], ["height", false], ["instant", true], ["height", true],
|
|
75
|
-
] as const)("
|
|
75
|
+
] as const)("keeps a long user prompt anchored through %s collapse (reader scrolled: %s)", async (animation, readerScrolled) => {
|
|
76
76
|
// Vitest's global performance and JSDOM frame timestamps can have different
|
|
77
77
|
// origins. Match the browser's shared clock so the 220ms ease stays bounded.
|
|
78
78
|
const requestFrame = globalThis.requestAnimationFrame;
|
|
@@ -83,9 +83,9 @@ it.each([
|
|
|
83
83
|
scrollHeight: { configurable: true, get: () => 3000 },
|
|
84
84
|
clientHeight: { configurable: true, get: () => 400 },
|
|
85
85
|
});
|
|
86
|
-
// A long user prompt
|
|
87
|
-
//
|
|
88
|
-
//
|
|
86
|
+
// A long user prompt puts the first progress block below the viewport. The
|
|
87
|
+
// prompt keeps the anchor (100 - 16): the disclosure streaming in and
|
|
88
|
+
// folding away below the fold never moves the reader to its header.
|
|
89
89
|
vi.spyOn(HTMLElement.prototype, "offsetTop", "get").mockImplementation(function (this: HTMLElement) {
|
|
90
90
|
if (this.hasAttribute("data-persona-turn-activity")) return 600;
|
|
91
91
|
if (this.closest("[data-persona-turn-activity]")) return 644;
|
|
@@ -93,7 +93,7 @@ it.each([
|
|
|
93
93
|
return 0;
|
|
94
94
|
});
|
|
95
95
|
await start(); await text("p", "Investigating the issue.");
|
|
96
|
-
await vi.waitFor(() => expect(body.scrollTop).toBe(
|
|
96
|
+
await vi.waitFor(() => expect(body.scrollTop).toBe(84));
|
|
97
97
|
if (readerScrolled) {
|
|
98
98
|
body.dispatchEvent(new WheelEvent("wheel", { deltaY: 120, bubbles: true }));
|
|
99
99
|
body.scrollTop = 800;
|
|
@@ -101,9 +101,9 @@ it.each([
|
|
|
101
101
|
await text("answer", "The answer stays below the header.", "final");
|
|
102
102
|
push("execution_complete", { kind: "agent", success: true }); close();
|
|
103
103
|
await vi.waitFor(() => expect(header().getAttribute("aria-expanded")).toBe("false"));
|
|
104
|
-
expect(body.scrollTop).toBe(readerScrolled ? 800 :
|
|
104
|
+
expect(body.scrollTop).toBe(readerScrolled ? 800 : 84);
|
|
105
105
|
|
|
106
|
-
// Later render/resize passes must not pull a reader back to the
|
|
106
|
+
// Later render/resize passes must not pull a reader back to the prompt.
|
|
107
107
|
body.dispatchEvent(new WheelEvent("wheel", { deltaY: 120, bubbles: true }));
|
|
108
108
|
body.scrollTop = 800;
|
|
109
109
|
controller.update({ expansion: { animation, duration: 120 } });
|
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { createAgentExperience } from "./ui";
|
|
6
|
+
import { mergeWithDefaults } from "./defaults";
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_USER_MESSAGE_COLLAPSE_MAX_HEIGHT,
|
|
9
|
+
userMessageOverflows,
|
|
10
|
+
} from "./components/user-message-collapse";
|
|
11
|
+
import type { AgentWidgetConfig, AgentWidgetMessage } from "./types";
|
|
12
|
+
|
|
13
|
+
type RafCallback = (time: number) => void;
|
|
14
|
+
|
|
15
|
+
const CREATED_AT = "2026-03-29T00:00:00.000Z";
|
|
16
|
+
const LONG_TEXT = Array.from({ length: 40 }, (_, i) => `Line ${i + 1} of a long pasted message.`).join("\n\n");
|
|
17
|
+
|
|
18
|
+
const installRafMock = () => {
|
|
19
|
+
let nextId = 1;
|
|
20
|
+
let now = performance.now();
|
|
21
|
+
const callbacks = new Map<number, RafCallback>();
|
|
22
|
+
vi.spyOn(performance, "now").mockImplementation(() => now);
|
|
23
|
+
vi.stubGlobal("requestAnimationFrame", (callback: RafCallback) => {
|
|
24
|
+
const id = nextId++;
|
|
25
|
+
callbacks.set(id, callback);
|
|
26
|
+
return id;
|
|
27
|
+
});
|
|
28
|
+
vi.stubGlobal("cancelAnimationFrame", (id: number) => {
|
|
29
|
+
callbacks.delete(id);
|
|
30
|
+
});
|
|
31
|
+
return {
|
|
32
|
+
step(frameCount = 1) {
|
|
33
|
+
let frames = 0;
|
|
34
|
+
while (callbacks.size > 0 && frames < frameCount) {
|
|
35
|
+
const pending = [...callbacks.values()];
|
|
36
|
+
callbacks.clear();
|
|
37
|
+
frames += 1;
|
|
38
|
+
now += 16;
|
|
39
|
+
pending.forEach((callback) => callback(now));
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
flush(maxFrames = 80) {
|
|
43
|
+
let frames = 0;
|
|
44
|
+
while (callbacks.size > 0 && frames < maxFrames) {
|
|
45
|
+
const pending = [...callbacks.values()];
|
|
46
|
+
callbacks.clear();
|
|
47
|
+
frames += 1;
|
|
48
|
+
now += 16;
|
|
49
|
+
pending.forEach((callback) => callback(now));
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const installResizeObserverMock = () => {
|
|
56
|
+
const triggers: Array<() => void> = [];
|
|
57
|
+
class ResizeObserverMock {
|
|
58
|
+
constructor(callback: (entries: unknown[], observer: unknown) => void) {
|
|
59
|
+
triggers.push(() => callback([], this));
|
|
60
|
+
}
|
|
61
|
+
observe() {}
|
|
62
|
+
unobserve() {}
|
|
63
|
+
disconnect() {}
|
|
64
|
+
}
|
|
65
|
+
vi.stubGlobal("ResizeObserver", ResizeObserverMock);
|
|
66
|
+
return { trigger: () => triggers.forEach((fire) => fire()) };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// jsdom has no layout. Collapse regions report a per-message natural height
|
|
70
|
+
// (what the browser would measure for the rendered markdown and attachments);
|
|
71
|
+
// every other element keeps jsdom's 0, and the scroll container overrides it.
|
|
72
|
+
const naturalHeights = new Map<string, number>();
|
|
73
|
+
const originalScrollHeight = Object.getOwnPropertyDescriptor(Element.prototype, "scrollHeight")!;
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
|
|
76
|
+
configurable: true,
|
|
77
|
+
get(this: HTMLElement) {
|
|
78
|
+
if (this.hasAttribute("data-persona-user-collapse")) {
|
|
79
|
+
const id = this.closest("[data-message-id]")?.getAttribute("data-message-id") ?? "";
|
|
80
|
+
return naturalHeights.get(id) ?? 0;
|
|
81
|
+
}
|
|
82
|
+
return originalScrollHeight.get!.call(this);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
delete (HTMLElement.prototype as { scrollHeight?: number }).scrollHeight;
|
|
89
|
+
naturalHeights.clear();
|
|
90
|
+
vi.unstubAllGlobals();
|
|
91
|
+
vi.restoreAllMocks();
|
|
92
|
+
document.body.replaceChildren();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const installScrollMetrics = (element: HTMLElement, initial: { scrollHeight: number; clientHeight: number }) => {
|
|
96
|
+
let scrollTop = 0;
|
|
97
|
+
let scrollHeight = initial.scrollHeight;
|
|
98
|
+
const clientHeight = initial.clientHeight;
|
|
99
|
+
Object.defineProperties(element, {
|
|
100
|
+
scrollTop: {
|
|
101
|
+
configurable: true,
|
|
102
|
+
get: () => scrollTop,
|
|
103
|
+
set: (value: number) => {
|
|
104
|
+
scrollTop = Math.max(0, Math.min(value, Math.max(0, scrollHeight - clientHeight)));
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
scrollHeight: { configurable: true, get: () => scrollHeight },
|
|
108
|
+
clientHeight: { configurable: true, get: () => clientHeight },
|
|
109
|
+
});
|
|
110
|
+
return {
|
|
111
|
+
getScrollTop: () => scrollTop,
|
|
112
|
+
getBottomScrollTop: () => Math.max(0, scrollHeight - clientHeight),
|
|
113
|
+
setScrollTop: (value: number) => { element.scrollTop = value; },
|
|
114
|
+
setScrollHeight: (value: number) => {
|
|
115
|
+
scrollHeight = value;
|
|
116
|
+
if (scrollTop > scrollHeight - clientHeight) scrollTop = Math.max(0, scrollHeight - clientHeight);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const mountWidget = (config: Partial<AgentWidgetConfig> = {}) => {
|
|
122
|
+
const mount = document.createElement("div");
|
|
123
|
+
document.body.appendChild(mount);
|
|
124
|
+
const controller = createAgentExperience(mount, {
|
|
125
|
+
apiUrl: "https://api.example.com/chat",
|
|
126
|
+
launcher: { enabled: false },
|
|
127
|
+
persistState: false,
|
|
128
|
+
...config,
|
|
129
|
+
features: { userMessageCollapse: { enabled: true }, ...config.features },
|
|
130
|
+
} as AgentWidgetConfig);
|
|
131
|
+
return { mount, controller };
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const emit = (
|
|
135
|
+
controller: ReturnType<typeof createAgentExperience>,
|
|
136
|
+
message: Partial<AgentWidgetMessage> & Pick<AgentWidgetMessage, "id" | "role">
|
|
137
|
+
) => {
|
|
138
|
+
controller.injectTestMessage({
|
|
139
|
+
type: "message",
|
|
140
|
+
message: { content: "", createdAt: CREATED_AT, ...message } as AgentWidgetMessage,
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const emitUser = (
|
|
145
|
+
controller: ReturnType<typeof createAgentExperience>,
|
|
146
|
+
id: string,
|
|
147
|
+
height: number,
|
|
148
|
+
extra: Partial<AgentWidgetMessage> = {}
|
|
149
|
+
) => {
|
|
150
|
+
naturalHeights.set(id, height);
|
|
151
|
+
emit(controller, { id, role: "user", content: LONG_TEXT, ...extra });
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const region = (mount: HTMLElement, id: string) =>
|
|
155
|
+
mount.querySelector<HTMLElement>(`[data-message-id="${id}"] [data-persona-user-collapse]`);
|
|
156
|
+
const toggle = (mount: HTMLElement, id: string) =>
|
|
157
|
+
mount.querySelector<HTMLButtonElement>(`[data-message-id="${id}"] button[data-persona-user-collapse-toggle]`);
|
|
158
|
+
|
|
159
|
+
describe("userMessageOverflows", () => {
|
|
160
|
+
it("leaves content within two lines of the limit expanded", () => {
|
|
161
|
+
expect(userMessageOverflows(240, 240)).toBe(false);
|
|
162
|
+
expect(userMessageOverflows(288, 240)).toBe(false);
|
|
163
|
+
expect(userMessageOverflows(289, 240)).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("long user message collapse", () => {
|
|
168
|
+
it("adds no toggle under the threshold", () => {
|
|
169
|
+
installRafMock();
|
|
170
|
+
const { mount, controller } = mountWidget();
|
|
171
|
+
emitUser(controller, "short", 200);
|
|
172
|
+
expect(region(mount, "short")?.getAttribute("data-persona-user-collapse")).toBe("fit");
|
|
173
|
+
expect(toggle(mount, "short")).toBeNull();
|
|
174
|
+
controller.destroy();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("collapses over the threshold with a labelled, controlling toggle", () => {
|
|
178
|
+
installRafMock();
|
|
179
|
+
const { mount, controller } = mountWidget();
|
|
180
|
+
emitUser(controller, "long", 900);
|
|
181
|
+
const clip = region(mount, "long")!;
|
|
182
|
+
const button = toggle(mount, "long")!;
|
|
183
|
+
expect(clip.getAttribute("data-persona-user-collapse")).toBe("collapsed");
|
|
184
|
+
expect(clip.style.getPropertyValue("--persona-user-collapse-max-height")).toBe(`${DEFAULT_USER_MESSAGE_COLLAPSE_MAX_HEIGHT}px`);
|
|
185
|
+
expect(button.tagName).toBe("BUTTON");
|
|
186
|
+
expect(button.type).toBe("button");
|
|
187
|
+
expect(button.textContent).toBe("Show more");
|
|
188
|
+
expect(button.getAttribute("aria-expanded")).toBe("false");
|
|
189
|
+
expect(button.getAttribute("aria-controls")).toBe(clip.id);
|
|
190
|
+
expect(mount.querySelector(`#${clip.id}`)).toBe(clip);
|
|
191
|
+
// The whole message stays in the DOM: no truncation, no nested scroller.
|
|
192
|
+
expect(clip.textContent).toContain("Line 40 of a long pasted message.");
|
|
193
|
+
expect(clip.hasAttribute("tabindex")).toBe(false);
|
|
194
|
+
controller.destroy();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("honors a custom maxHeight and localized labels", () => {
|
|
198
|
+
installRafMock();
|
|
199
|
+
const { mount, controller } = mountWidget({
|
|
200
|
+
features: { userMessageCollapse: { enabled: true, maxHeight: 120 } },
|
|
201
|
+
copy: { showMoreLabel: "Mehr anzeigen", showLessLabel: "Weniger anzeigen" },
|
|
202
|
+
});
|
|
203
|
+
emitUser(controller, "custom", 200);
|
|
204
|
+
expect(region(mount, "custom")!.style.getPropertyValue("--persona-user-collapse-max-height")).toBe("120px");
|
|
205
|
+
toggle(mount, "custom")!.click();
|
|
206
|
+
expect(toggle(mount, "custom")!.textContent).toBe("Weniger anzeigen");
|
|
207
|
+
toggle(mount, "custom")!.click();
|
|
208
|
+
expect(toggle(mount, "custom")!.textContent).toBe("Mehr anzeigen");
|
|
209
|
+
controller.destroy();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("expands and collapses on toggle, keeping the same button", () => {
|
|
213
|
+
installRafMock();
|
|
214
|
+
const { mount, controller } = mountWidget();
|
|
215
|
+
emitUser(controller, "toggle", 900);
|
|
216
|
+
const button = toggle(mount, "toggle")!;
|
|
217
|
+
button.click();
|
|
218
|
+
expect(region(mount, "toggle")!.getAttribute("data-persona-user-collapse")).toBe("expanded");
|
|
219
|
+
expect(button.getAttribute("aria-expanded")).toBe("true");
|
|
220
|
+
expect(button.textContent).toBe("Show less");
|
|
221
|
+
button.click();
|
|
222
|
+
expect(region(mount, "toggle")!.getAttribute("data-persona-user-collapse")).toBe("collapsed");
|
|
223
|
+
expect(button.getAttribute("aria-expanded")).toBe("false");
|
|
224
|
+
expect(button.textContent).toBe("Show more");
|
|
225
|
+
controller.destroy();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("expands when keyboard focus lands inside the clipped content", () => {
|
|
229
|
+
installRafMock();
|
|
230
|
+
const { mount, controller } = mountWidget();
|
|
231
|
+
emitUser(controller, "focus", 900);
|
|
232
|
+
const content = region(mount, "focus")!.querySelector<HTMLElement>(".persona-message-content")!;
|
|
233
|
+
content.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
|
|
234
|
+
expect(region(mount, "focus")!.getAttribute("data-persona-user-collapse")).toBe("expanded");
|
|
235
|
+
expect(toggle(mount, "focus")!.getAttribute("aria-expanded")).toBe("true");
|
|
236
|
+
// Focusing the toggle itself (outside the region) changes nothing.
|
|
237
|
+
toggle(mount, "focus")!.click();
|
|
238
|
+
toggle(mount, "focus")!.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
|
|
239
|
+
expect(region(mount, "focus")!.getAttribute("data-persona-user-collapse")).toBe("collapsed");
|
|
240
|
+
controller.destroy();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("applies maxHeight and label changes from update() to rendered bubbles", () => {
|
|
244
|
+
installRafMock();
|
|
245
|
+
const { mount, controller } = mountWidget();
|
|
246
|
+
emitUser(controller, "updated", 900);
|
|
247
|
+
emit(controller, { id: "reply", role: "assistant", content: "Reply" });
|
|
248
|
+
controller.update({
|
|
249
|
+
features: { userMessageCollapse: { enabled: true, maxHeight: 300 } },
|
|
250
|
+
copy: { showMoreLabel: "Expand", showLessLabel: "Shrink" },
|
|
251
|
+
});
|
|
252
|
+
expect(region(mount, "updated")!.style.getPropertyValue("--persona-user-collapse-max-height")).toBe("300px");
|
|
253
|
+
expect(toggle(mount, "updated")!.textContent).toBe("Expand");
|
|
254
|
+
// Raised past the content height, the bubble no longer collapses at all.
|
|
255
|
+
controller.update({ features: { userMessageCollapse: { enabled: true, maxHeight: 1000 } } });
|
|
256
|
+
expect(region(mount, "updated")!.getAttribute("data-persona-user-collapse")).toBe("fit");
|
|
257
|
+
expect(toggle(mount, "updated")).toBeNull();
|
|
258
|
+
controller.destroy();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("keeps the reader's choice across streaming re-renders and morphs", () => {
|
|
262
|
+
const raf = installRafMock();
|
|
263
|
+
const { mount, controller } = mountWidget();
|
|
264
|
+
controller.injectTestMessage({ type: "status", status: "connecting" });
|
|
265
|
+
emitUser(controller, "kept", 900);
|
|
266
|
+
toggle(mount, "kept")!.click();
|
|
267
|
+
for (const content of ["Streaming", "Streaming a longer reply", "Streaming a much longer reply"]) {
|
|
268
|
+
emit(controller, { id: "reply", role: "assistant", content, streaming: true });
|
|
269
|
+
raf.flush();
|
|
270
|
+
}
|
|
271
|
+
emit(controller, { id: "reply", role: "assistant", content: "Done" });
|
|
272
|
+
// A re-render of the user message itself (same id) must not re-collapse it.
|
|
273
|
+
emitUser(controller, "kept", 900, { content: `${LONG_TEXT}\n\nEdited.` });
|
|
274
|
+
raf.flush();
|
|
275
|
+
expect(region(mount, "kept")!.getAttribute("data-persona-user-collapse")).toBe("expanded");
|
|
276
|
+
expect(toggle(mount, "kept")!.getAttribute("aria-expanded")).toBe("true");
|
|
277
|
+
expect(toggle(mount, "kept")!.textContent).toBe("Show less");
|
|
278
|
+
|
|
279
|
+
// And a collapsed choice is not re-expanded either.
|
|
280
|
+
toggle(mount, "kept")!.click();
|
|
281
|
+
emit(controller, { id: "reply-2", role: "assistant", content: "Another reply" });
|
|
282
|
+
raf.flush();
|
|
283
|
+
expect(region(mount, "kept")!.getAttribute("data-persona-user-collapse")).toBe("collapsed");
|
|
284
|
+
controller.destroy();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("keeps an interim voice transcript expanded, then collapses it once final", () => {
|
|
288
|
+
const raf = installRafMock();
|
|
289
|
+
const { mount, controller } = mountWidget();
|
|
290
|
+
emitUser(controller, "voice", 400, { content: "So what I was thinking", voiceProcessing: true });
|
|
291
|
+
expect(region(mount, "voice")!.getAttribute("data-persona-user-collapse")).toBe("live");
|
|
292
|
+
expect(toggle(mount, "voice")).toBeNull();
|
|
293
|
+
// The same id keeps growing while the speaker talks.
|
|
294
|
+
emitUser(controller, "voice", 900, { voiceProcessing: true });
|
|
295
|
+
raf.flush();
|
|
296
|
+
expect(region(mount, "voice")!.getAttribute("data-persona-user-collapse")).toBe("live");
|
|
297
|
+
expect(toggle(mount, "voice")).toBeNull();
|
|
298
|
+
emitUser(controller, "voice", 900, { voiceProcessing: false });
|
|
299
|
+
raf.flush();
|
|
300
|
+
expect(region(mount, "voice")!.getAttribute("data-persona-user-collapse")).toBe("collapsed");
|
|
301
|
+
expect(toggle(mount, "voice")!.getAttribute("aria-expanded")).toBe("false");
|
|
302
|
+
controller.destroy();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("does not move an engaged reader's viewport when a final transcript collapses", () => {
|
|
306
|
+
const raf = installRafMock();
|
|
307
|
+
const { mount, controller } = mountWidget({ features: { scrollBehavior: { mode: "follow" } } });
|
|
308
|
+
const body = mount.querySelector<HTMLElement>("#persona-scroll-container")!;
|
|
309
|
+
const metrics = installScrollMetrics(body, { scrollHeight: 2000, clientHeight: 400 });
|
|
310
|
+
emitUser(controller, "voice-read", 900, { voiceProcessing: true });
|
|
311
|
+
raf.flush();
|
|
312
|
+
// The reader scrolls up into the transcript: follow pauses.
|
|
313
|
+
body.dispatchEvent(new WheelEvent("wheel", { deltaY: -200, bubbles: true }));
|
|
314
|
+
metrics.setScrollTop(500);
|
|
315
|
+
body.dispatchEvent(new Event("scroll"));
|
|
316
|
+
raf.flush();
|
|
317
|
+
const bubble = mount.querySelector<HTMLElement>('[data-message-id="voice-read"]')!;
|
|
318
|
+
// The bubble is in view at 100px; collapsing must hold its top there.
|
|
319
|
+
let top = 100;
|
|
320
|
+
vi.spyOn(bubble, "getBoundingClientRect").mockImplementation(() => ({ top, bottom: top + 300 } as DOMRect));
|
|
321
|
+
emitUser(controller, "voice-read", 900, { voiceProcessing: false });
|
|
322
|
+
raf.flush();
|
|
323
|
+
expect(region(mount, "voice-read")!.getAttribute("data-persona-user-collapse")).toBe("collapsed");
|
|
324
|
+
expect(metrics.getScrollTop()).toBe(500);
|
|
325
|
+
top = 0;
|
|
326
|
+
controller.destroy();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("holds the content below when a final transcript above the viewport collapses", () => {
|
|
330
|
+
const raf = installRafMock();
|
|
331
|
+
const { mount, controller } = mountWidget({ features: { scrollBehavior: { mode: "follow" } } });
|
|
332
|
+
const body = mount.querySelector<HTMLElement>("#persona-scroll-container")!;
|
|
333
|
+
// The transcript bubble sits at the top of the content; the reader has
|
|
334
|
+
// scrolled 700px down, past most of it, into what follows.
|
|
335
|
+
let scrollTop = 0;
|
|
336
|
+
const bubbleHeight = () =>
|
|
337
|
+
region(mount, "voice-above")?.getAttribute("data-persona-user-collapse") === "collapsed" ? 270 : 900;
|
|
338
|
+
const scrollHeight = () => bubbleHeight() + 1100;
|
|
339
|
+
Object.defineProperties(body, {
|
|
340
|
+
scrollTop: {
|
|
341
|
+
configurable: true,
|
|
342
|
+
get: () => scrollTop,
|
|
343
|
+
set: (value: number) => { scrollTop = Math.max(0, Math.min(value, scrollHeight() - 400)); },
|
|
344
|
+
},
|
|
345
|
+
scrollHeight: { configurable: true, get: scrollHeight },
|
|
346
|
+
clientHeight: { configurable: true, get: () => 400 },
|
|
347
|
+
});
|
|
348
|
+
emitUser(controller, "voice-above", 900, { voiceProcessing: true });
|
|
349
|
+
raf.flush();
|
|
350
|
+
const bubble = mount.querySelector<HTMLElement>('[data-message-id="voice-above"]')!;
|
|
351
|
+
vi.spyOn(bubble, "getBoundingClientRect").mockImplementation(() =>
|
|
352
|
+
({ top: -scrollTop, bottom: bubbleHeight() - scrollTop } as DOMRect));
|
|
353
|
+
body.dispatchEvent(new WheelEvent("wheel", { deltaY: -200, bubbles: true }));
|
|
354
|
+
body.scrollTop = 700;
|
|
355
|
+
body.dispatchEvent(new Event("scroll"));
|
|
356
|
+
raf.flush();
|
|
357
|
+
emitUser(controller, "voice-above", 900, { voiceProcessing: false });
|
|
358
|
+
raf.flush();
|
|
359
|
+
expect(region(mount, "voice-above")!.getAttribute("data-persona-user-collapse")).toBe("collapsed");
|
|
360
|
+
// The bubble's bottom edge (200px above the viewport) stays where it was:
|
|
361
|
+
// 900 - 700 = 200 before, 270 - 70 = 200 after.
|
|
362
|
+
expect(scrollTop).toBe(70);
|
|
363
|
+
controller.destroy();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("treats a toggle as reader intent: follow pauses and later renders leave the viewport", () => {
|
|
367
|
+
const raf = installRafMock();
|
|
368
|
+
const resize = installResizeObserverMock();
|
|
369
|
+
const { mount, controller } = mountWidget({ features: { scrollBehavior: { mode: "follow" } } });
|
|
370
|
+
const body = mount.querySelector<HTMLElement>("#persona-scroll-container")!;
|
|
371
|
+
const metrics = installScrollMetrics(body, { scrollHeight: 1000, clientHeight: 400 });
|
|
372
|
+
controller.injectTestMessage({ type: "status", status: "connecting" });
|
|
373
|
+
emitUser(controller, "intent", 900);
|
|
374
|
+
emit(controller, { id: "reply", role: "assistant", content: "Streaming", streaming: true });
|
|
375
|
+
raf.flush();
|
|
376
|
+
expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
|
|
377
|
+
|
|
378
|
+
metrics.setScrollTop(300);
|
|
379
|
+
toggle(mount, "intent")!.click();
|
|
380
|
+
for (const height of [1400, 1800]) {
|
|
381
|
+
metrics.setScrollHeight(height);
|
|
382
|
+
emit(controller, { id: "reply", role: "assistant", content: `Streaming ${height}`, streaming: true });
|
|
383
|
+
resize.trigger();
|
|
384
|
+
raf.flush();
|
|
385
|
+
expect(metrics.getScrollTop()).toBe(300);
|
|
386
|
+
}
|
|
387
|
+
expect(mount.querySelector("[data-persona-scroll-to-bottom]")?.getAttribute("data-persona-scroll-to-bottom-visible")).toBe("true");
|
|
388
|
+
controller.destroy();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("brings a bubble whose top scrolled away back to the anchor offset on Show less", () => {
|
|
392
|
+
const raf = installRafMock();
|
|
393
|
+
const { mount, controller } = mountWidget({ features: { scrollBehavior: { mode: "follow" } } });
|
|
394
|
+
const body = mount.querySelector<HTMLElement>("#persona-scroll-container")!;
|
|
395
|
+
let scrollTop = 0;
|
|
396
|
+
const collapsed = () => region(mount, "far")?.getAttribute("data-persona-user-collapse") === "collapsed";
|
|
397
|
+
const scrollHeight = () => (collapsed() ? 270 : 900) + 1100;
|
|
398
|
+
Object.defineProperties(body, {
|
|
399
|
+
scrollTop: {
|
|
400
|
+
configurable: true,
|
|
401
|
+
get: () => scrollTop,
|
|
402
|
+
set: (value: number) => { scrollTop = Math.max(0, Math.min(value, scrollHeight() - 400)); },
|
|
403
|
+
},
|
|
404
|
+
scrollHeight: { configurable: true, get: scrollHeight },
|
|
405
|
+
clientHeight: { configurable: true, get: () => 400 },
|
|
406
|
+
});
|
|
407
|
+
// The bubble starts 100px into the content.
|
|
408
|
+
emitUser(controller, "far", 900);
|
|
409
|
+
raf.flush();
|
|
410
|
+
const bubble = mount.querySelector<HTMLElement>('[data-message-id="far"]')!;
|
|
411
|
+
vi.spyOn(bubble, "getBoundingClientRect").mockImplementation(() =>
|
|
412
|
+
({ top: 100 - scrollTop, bottom: 100 - scrollTop + (collapsed() ? 270 : 900) } as DOMRect));
|
|
413
|
+
toggle(mount, "far")!.click();
|
|
414
|
+
// The reader reads down to the Show less button at the bubble's bottom.
|
|
415
|
+
body.scrollTop = 700;
|
|
416
|
+
toggle(mount, "far")!.click();
|
|
417
|
+
expect(collapsed()).toBe(true);
|
|
418
|
+
// Top lands 16px below the viewport top: 100 - 84 = 16.
|
|
419
|
+
expect(scrollTop).toBe(84);
|
|
420
|
+
controller.destroy();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("leaves the anchor-top pin, its spacer, and jump-to-latest intact", () => {
|
|
424
|
+
const raf = installRafMock();
|
|
425
|
+
const resize = installResizeObserverMock();
|
|
426
|
+
const { mount, controller } = mountWidget({
|
|
427
|
+
features: { scrollBehavior: { mode: "anchor-top", anchorTopOffset: 16 } },
|
|
428
|
+
});
|
|
429
|
+
const body = mount.querySelector<HTMLElement>("#persona-scroll-container")!;
|
|
430
|
+
const spacer = body.querySelector<HTMLElement>("[data-persona-anchor-spacer]")!;
|
|
431
|
+
// As in a browser, the scroll height counts the spacer, and the content
|
|
432
|
+
// grows by 660px while the 900px message is expanded (300px collapsed).
|
|
433
|
+
let replyHeight = 0;
|
|
434
|
+
let scrollTop = 0;
|
|
435
|
+
const isExpanded = () => region(mount, "anchored")?.getAttribute("data-persona-user-collapse") === "expanded";
|
|
436
|
+
const bubbleHeight = () => (isExpanded() ? 960 : 300);
|
|
437
|
+
const scrollHeight = () =>
|
|
438
|
+
1000 + (isExpanded() ? 660 : 0) + replyHeight +
|
|
439
|
+
(spacer.style.display === "none" ? 0 : parseFloat(spacer.style.height) || 0);
|
|
440
|
+
Object.defineProperties(body, {
|
|
441
|
+
scrollTop: {
|
|
442
|
+
configurable: true,
|
|
443
|
+
get: () => scrollTop,
|
|
444
|
+
set: (value: number) => { scrollTop = Math.max(0, Math.min(value, scrollHeight() - 400)); },
|
|
445
|
+
},
|
|
446
|
+
scrollHeight: { configurable: true, get: scrollHeight },
|
|
447
|
+
clientHeight: { configurable: true, get: () => 400 },
|
|
448
|
+
});
|
|
449
|
+
controller.injectTestMessage({ type: "status", status: "connecting" });
|
|
450
|
+
emitUser(controller, "anchored", 900);
|
|
451
|
+
// Collapsed on send, before the anchor frame measures the layout.
|
|
452
|
+
expect(region(mount, "anchored")!.getAttribute("data-persona-user-collapse")).toBe("collapsed");
|
|
453
|
+
const bubble = mount.querySelector<HTMLElement>('[data-message-id="anchored"]')!;
|
|
454
|
+
Object.defineProperty(bubble, "offsetTop", { value: 700 });
|
|
455
|
+
vi.spyOn(bubble, "offsetHeight", "get").mockImplementation(bubbleHeight);
|
|
456
|
+
// Pinned 16px below the viewport top once the anchor ease lands.
|
|
457
|
+
vi.spyOn(bubble, "getBoundingClientRect").mockImplementation(() => {
|
|
458
|
+
const top = 700 - scrollTop;
|
|
459
|
+
return { top, bottom: top + bubbleHeight() } as DOMRect;
|
|
460
|
+
});
|
|
461
|
+
raf.step(1);
|
|
462
|
+
// target = 700 - 16 = 684; spacer = 684 + 400 - 1000 + 24 slack = 108.
|
|
463
|
+
expect(spacer.style.height).toBe("108px");
|
|
464
|
+
raf.flush();
|
|
465
|
+
expect(scrollTop).toBe(684);
|
|
466
|
+
|
|
467
|
+
// Expanding holds the pinned bubble's top at the anchor offset; the
|
|
468
|
+
// grown content now covers the viewport, so the spacer gives its room back.
|
|
469
|
+
toggle(mount, "anchored")!.click();
|
|
470
|
+
raf.flush();
|
|
471
|
+
expect(scrollTop).toBe(684);
|
|
472
|
+
expect(spacer.style.height).toBe("0px");
|
|
473
|
+
// Collapsing re-reserves the spacer so the shrink can't clamp scrollTop.
|
|
474
|
+
toggle(mount, "anchored")!.click();
|
|
475
|
+
raf.flush();
|
|
476
|
+
expect(scrollTop).toBe(684);
|
|
477
|
+
expect(spacer.style.height).toBe("108px");
|
|
478
|
+
|
|
479
|
+
// The reply streams in under the pin, and jump-to-latest still drops the anchor.
|
|
480
|
+
emit(controller, { id: "reply", role: "assistant", content: "Streaming", streaming: true });
|
|
481
|
+
replyHeight = 600;
|
|
482
|
+
resize.trigger();
|
|
483
|
+
raf.flush();
|
|
484
|
+
expect(scrollTop).toBe(684);
|
|
485
|
+
expect(spacer.style.height).toBe("0px");
|
|
486
|
+
const jump = mount.querySelector<HTMLButtonElement>("[data-persona-scroll-to-bottom]")!;
|
|
487
|
+
expect(jump.getAttribute("data-persona-scroll-to-bottom-visible")).toBe("true");
|
|
488
|
+
jump.click();
|
|
489
|
+
raf.flush();
|
|
490
|
+
expect(spacer.style.display).toBe("none");
|
|
491
|
+
expect(scrollTop).toBe(scrollHeight() - 400);
|
|
492
|
+
controller.destroy();
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("leaves a custom renderUserMessage bubble untouched", () => {
|
|
496
|
+
installRafMock();
|
|
497
|
+
const { mount, controller } = mountWidget({
|
|
498
|
+
layout: {
|
|
499
|
+
messages: {
|
|
500
|
+
renderUserMessage: ({ message }) => {
|
|
501
|
+
const el = document.createElement("div");
|
|
502
|
+
el.className = "custom-user";
|
|
503
|
+
el.setAttribute("data-message-id", message.id);
|
|
504
|
+
el.textContent = message.content;
|
|
505
|
+
return el;
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
});
|
|
510
|
+
emitUser(controller, "custom-render", 900);
|
|
511
|
+
const custom = mount.querySelector<HTMLElement>(".custom-user")!;
|
|
512
|
+
expect(custom.textContent).toBe(LONG_TEXT);
|
|
513
|
+
expect(custom.querySelector("[data-persona-user-collapse]")).toBeNull();
|
|
514
|
+
expect(mount.querySelector("button[data-persona-user-collapse-toggle]")).toBeNull();
|
|
515
|
+
controller.destroy();
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("is off by default in V4 and on under future.v5Defaults", () => {
|
|
519
|
+
installRafMock();
|
|
520
|
+
expect(mergeWithDefaults({}).features?.userMessageCollapse).toBeUndefined();
|
|
521
|
+
expect(mergeWithDefaults({ future: { v5Defaults: true } }).features?.userMessageCollapse).toEqual({ enabled: true });
|
|
522
|
+
// A partial override keeps the V5 enable flag.
|
|
523
|
+
expect(mergeWithDefaults({
|
|
524
|
+
future: { v5Defaults: true }, features: { userMessageCollapse: { maxHeight: 300 } },
|
|
525
|
+
}).features?.userMessageCollapse).toEqual({ enabled: true, maxHeight: 300 });
|
|
526
|
+
|
|
527
|
+
for (const v5Defaults of [false, true]) {
|
|
528
|
+
const mount = document.createElement("div");
|
|
529
|
+
document.body.appendChild(mount);
|
|
530
|
+
const controller = createAgentExperience(mount, mergeWithDefaults({
|
|
531
|
+
apiUrl: "https://api.example.com/chat", launcher: { enabled: false }, persistState: false,
|
|
532
|
+
future: { v5Defaults },
|
|
533
|
+
}) as AgentWidgetConfig);
|
|
534
|
+
emitUser(controller, `default-${v5Defaults}`, 900);
|
|
535
|
+
expect(Boolean(toggle(mount, `default-${v5Defaults}`))).toBe(v5Defaults);
|
|
536
|
+
expect(Boolean(region(mount, `default-${v5Defaults}`))).toBe(v5Defaults);
|
|
537
|
+
controller.destroy();
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
});
|