@runtypelabs/persona 4.4.2 → 4.5.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/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/index.cjs +49 -49
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +58 -9
- package/dist/index.d.ts +58 -9
- package/dist/index.global.js +38 -38
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +49 -49
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +57 -8
- package/dist/smart-dom-reader.d.ts +57 -8
- package/dist/theme-editor-preview.cjs +40 -40
- package/dist/theme-editor-preview.d.cts +57 -8
- package/dist/theme-editor-preview.d.ts +57 -8
- package/dist/theme-editor-preview.js +40 -40
- package/dist/theme-editor.cjs +1 -1
- package/dist/theme-editor.d.cts +57 -8
- package/dist/theme-editor.d.ts +57 -8
- package/dist/theme-editor.js +1 -1
- package/package.json +1 -1
- package/src/defaults.ts +9 -1
- package/src/index-core.ts +5 -0
- package/src/types.ts +58 -8
- package/src/ui.scroll-additive.test.ts +451 -0
- package/src/ui.scroll.test.ts +8 -2
- package/src/ui.ts +300 -40
package/src/types.ts
CHANGED
|
@@ -847,26 +847,76 @@ export type AgentWidgetArtifactsFeature = {
|
|
|
847
847
|
/**
|
|
848
848
|
* How the transcript scrolls while an assistant response streams in.
|
|
849
849
|
*
|
|
850
|
-
* - `"
|
|
851
|
-
* viewport
|
|
852
|
-
* to
|
|
853
|
-
*
|
|
854
|
-
*
|
|
855
|
-
*
|
|
850
|
+
* - `"anchor-top"` (default): on send, scroll the user's message near the top
|
|
851
|
+
* of the viewport and hold it there while the response streams in beneath it
|
|
852
|
+
* (ChatGPT-style). When a turn has no user send to anchor to (a proactive
|
|
853
|
+
* greeting, an injected assistant message, a resubmit, or first-load
|
|
854
|
+
* streaming), it falls back to `"follow"` for that turn so content never
|
|
855
|
+
* streams in off-screen.
|
|
856
|
+
* - `"follow"`: keep the newest content pinned to the bottom of the viewport,
|
|
857
|
+
* pausing when the user scrolls up and resuming when they return to the
|
|
858
|
+
* bottom. This was the default before 4.x.
|
|
856
859
|
* - `"none"`: never auto-scroll; the scroll-to-bottom affordance is the only
|
|
857
860
|
* way back to the latest content.
|
|
858
861
|
*/
|
|
859
862
|
export type AgentWidgetScrollMode = "follow" | "anchor-top" | "none";
|
|
860
863
|
|
|
864
|
+
/**
|
|
865
|
+
* Where the transcript lands when a *saved* conversation is reopened (restored
|
|
866
|
+
* from the storage adapter / `initialMessages`), as opposed to a fresh send.
|
|
867
|
+
*
|
|
868
|
+
* - `"bottom"` (default): jump to the absolute end of the transcript, matching
|
|
869
|
+
* the historical behavior.
|
|
870
|
+
* - `"last-user-turn"`: pin the last user message near the top of the viewport
|
|
871
|
+
* (the last point the reader was actively driving the conversation) so a long
|
|
872
|
+
* restored thread opens at a readable place rather than at the very bottom.
|
|
873
|
+
*/
|
|
874
|
+
export type AgentWidgetScrollRestorePosition = "bottom" | "last-user-turn";
|
|
875
|
+
|
|
861
876
|
export type AgentWidgetScrollBehaviorFeature = {
|
|
862
|
-
/** Scroll behavior during streamed responses. @default "
|
|
877
|
+
/** Scroll behavior during streamed responses. @default "anchor-top" */
|
|
863
878
|
mode?: AgentWidgetScrollMode;
|
|
864
879
|
/**
|
|
865
880
|
* Gap (px) kept between the anchored user message and the top of the
|
|
866
|
-
* viewport in `"anchor-top"` mode.
|
|
881
|
+
* viewport in `"anchor-top"` mode. Also used as the gap for
|
|
882
|
+
* `restorePosition: "last-user-turn"`.
|
|
867
883
|
* @default 16
|
|
868
884
|
*/
|
|
869
885
|
anchorTopOffset?: number;
|
|
886
|
+
/**
|
|
887
|
+
* Where to land when a saved conversation reopens. Additive and opt-in: the
|
|
888
|
+
* default preserves the historical "jump to the bottom" behavior.
|
|
889
|
+
* @default "bottom"
|
|
890
|
+
*/
|
|
891
|
+
restorePosition?: AgentWidgetScrollRestorePosition;
|
|
892
|
+
/**
|
|
893
|
+
* When true, interactions beyond text selection — keyboard navigation
|
|
894
|
+
* (PageUp/PageDown/Home/End/arrows) inside the transcript and focusing a
|
|
895
|
+
* link or other interactive element within it — also pause auto-follow in
|
|
896
|
+
* `"follow"` mode. Treats "the reader is doing something here" as intent to
|
|
897
|
+
* stay put, not just "the reader dragged a selection". Opt-in; default keeps
|
|
898
|
+
* only the existing wheel/scroll/selection triggers.
|
|
899
|
+
* @default false
|
|
900
|
+
*/
|
|
901
|
+
pauseOnInteraction?: boolean;
|
|
902
|
+
/**
|
|
903
|
+
* When true, the scroll-to-bottom affordance also surfaces new-content and
|
|
904
|
+
* still-streaming activity while the reader is pinned away from the latest
|
|
905
|
+
* content in `"anchor-top"` mode. Lets the reader know a response is arriving
|
|
906
|
+
* offscreen below. Defaults on alongside the `"anchor-top"` default; set
|
|
907
|
+
* `false` to keep the count + streaming hint silent while pinned.
|
|
908
|
+
* @default true
|
|
909
|
+
*/
|
|
910
|
+
showActivityWhilePinned?: boolean;
|
|
911
|
+
/**
|
|
912
|
+
* When true, Persona maintains a visually-hidden `aria-live="polite"` region
|
|
913
|
+
* that announces important transcript events — a response starting, a
|
|
914
|
+
* response finishing, and "N new messages below" while the reader is scrolled
|
|
915
|
+
* away — at a comfortable cadence (never token-by-token). Opt-in so existing
|
|
916
|
+
* embeds don't suddenly gain screen-reader announcements.
|
|
917
|
+
* @default false
|
|
918
|
+
*/
|
|
919
|
+
announce?: boolean;
|
|
870
920
|
};
|
|
871
921
|
|
|
872
922
|
export type AgentWidgetScrollToBottomFeature = {
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { createAgentExperience } from "./ui";
|
|
6
|
+
import type { AgentWidgetConfig, AgentWidgetMessage } from "./types";
|
|
7
|
+
|
|
8
|
+
type RafCallback = (time: number) => void;
|
|
9
|
+
|
|
10
|
+
const 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 getScrollContainer = (mount: HTMLElement) =>
|
|
19
|
+
mount.querySelector<HTMLElement>("#persona-scroll-container")!;
|
|
20
|
+
|
|
21
|
+
const getScrollToBottomButton = (mount: HTMLElement) =>
|
|
22
|
+
mount.querySelector<HTMLElement>("[data-persona-scroll-to-bottom]")!;
|
|
23
|
+
|
|
24
|
+
const getCountBadge = (mount: HTMLElement) =>
|
|
25
|
+
mount.querySelector<HTMLElement>("[data-persona-scroll-to-bottom-count]")!;
|
|
26
|
+
|
|
27
|
+
const getLiveRegion = (mount: HTMLElement) =>
|
|
28
|
+
mount.querySelector<HTMLElement>("[data-persona-live-region]");
|
|
29
|
+
|
|
30
|
+
const installRafMock = () => {
|
|
31
|
+
let nextId = 1;
|
|
32
|
+
const callbacks = new Map<number, RafCallback>();
|
|
33
|
+
let now = 0;
|
|
34
|
+
vi.stubGlobal("requestAnimationFrame", (cb: RafCallback) => {
|
|
35
|
+
const id = nextId++;
|
|
36
|
+
callbacks.set(id, cb);
|
|
37
|
+
return id;
|
|
38
|
+
});
|
|
39
|
+
vi.stubGlobal("cancelAnimationFrame", (id: number) => {
|
|
40
|
+
callbacks.delete(id);
|
|
41
|
+
});
|
|
42
|
+
return {
|
|
43
|
+
flush(maxFrames = 80) {
|
|
44
|
+
let frames = 0;
|
|
45
|
+
while (callbacks.size > 0 && frames < maxFrames) {
|
|
46
|
+
const pending = [...callbacks.values()];
|
|
47
|
+
callbacks.clear();
|
|
48
|
+
frames += 1;
|
|
49
|
+
now += 16;
|
|
50
|
+
pending.forEach((cb) => cb(now));
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const installScrollMetrics = (
|
|
57
|
+
element: HTMLElement,
|
|
58
|
+
initial: { scrollHeight: number; clientHeight: number }
|
|
59
|
+
) => {
|
|
60
|
+
let scrollTop = 0;
|
|
61
|
+
let scrollHeight = initial.scrollHeight;
|
|
62
|
+
const clientHeight = initial.clientHeight;
|
|
63
|
+
Object.defineProperties(element, {
|
|
64
|
+
scrollTop: {
|
|
65
|
+
configurable: true,
|
|
66
|
+
get: () => scrollTop,
|
|
67
|
+
set: (value: number) => {
|
|
68
|
+
scrollTop = Math.max(0, Math.min(value, Math.max(0, scrollHeight - clientHeight)));
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
scrollHeight: { configurable: true, get: () => scrollHeight },
|
|
72
|
+
clientHeight: { configurable: true, get: () => clientHeight },
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
getScrollTop: () => scrollTop,
|
|
76
|
+
getBottom: () => Math.max(0, scrollHeight - clientHeight),
|
|
77
|
+
setScrollTop: (v: number) => {
|
|
78
|
+
element.scrollTop = v;
|
|
79
|
+
},
|
|
80
|
+
setScrollHeight: (v: number) => {
|
|
81
|
+
scrollHeight = v;
|
|
82
|
+
if (scrollTop > scrollHeight - clientHeight) {
|
|
83
|
+
scrollTop = Math.max(0, scrollHeight - clientHeight);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const emitStreamingMessage = (
|
|
90
|
+
controller: ReturnType<typeof createAgentExperience>,
|
|
91
|
+
id: string,
|
|
92
|
+
content: string
|
|
93
|
+
) => {
|
|
94
|
+
controller.injectTestMessage({
|
|
95
|
+
type: "message",
|
|
96
|
+
message: { id, role: "assistant", content, createdAt: CREATED_AT, streaming: true },
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const emitAssistantMessage = (
|
|
101
|
+
controller: ReturnType<typeof createAgentExperience>,
|
|
102
|
+
id: string,
|
|
103
|
+
content: string
|
|
104
|
+
) => {
|
|
105
|
+
controller.injectTestMessage({
|
|
106
|
+
type: "message",
|
|
107
|
+
message: { id, role: "assistant", content, createdAt: CREATED_AT },
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const emitUserMessage = (
|
|
112
|
+
controller: ReturnType<typeof createAgentExperience>,
|
|
113
|
+
id: string,
|
|
114
|
+
content: string
|
|
115
|
+
) => {
|
|
116
|
+
controller.injectTestMessage({
|
|
117
|
+
type: "message",
|
|
118
|
+
message: { id, role: "user", content, createdAt: CREATED_AT },
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const emitStreamingAssistant = (
|
|
123
|
+
controller: ReturnType<typeof createAgentExperience>,
|
|
124
|
+
id: string,
|
|
125
|
+
content: string
|
|
126
|
+
) => {
|
|
127
|
+
controller.injectTestMessage({
|
|
128
|
+
type: "message",
|
|
129
|
+
message: { id, role: "assistant", content, createdAt: CREATED_AT, streaming: true },
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Establish a real anchor-top turn: a prior assistant message seeds
|
|
134
|
+
// send-detection, then a user send anchors. The next assistant message is the
|
|
135
|
+
// anchored response (so it does NOT hit the no-anchor follow fallback) — the
|
|
136
|
+
// scenario `showActivityWhilePinned` is about: the answer streaming in below a
|
|
137
|
+
// pinned question the reader is still reading from the top.
|
|
138
|
+
const anchorUserTurn = (
|
|
139
|
+
controller: ReturnType<typeof createAgentExperience>
|
|
140
|
+
) => {
|
|
141
|
+
emitAssistantMessage(controller, "seed", "Earlier reply");
|
|
142
|
+
emitUserMessage(controller, "u1", "Question");
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const baseConfig = (overrides: AgentWidgetConfig): AgentWidgetConfig => ({
|
|
146
|
+
apiUrl: "https://api.example.com/chat",
|
|
147
|
+
launcher: { enabled: false },
|
|
148
|
+
// Hermetic: never restore persisted history at construction. Otherwise a
|
|
149
|
+
// prior test's transcript (sharing message ids like "u1") leaks via
|
|
150
|
+
// localStorage and a user send reads as already-seen, so the anchor never
|
|
151
|
+
// takes and the assertion sees a follow-to-bottom instead.
|
|
152
|
+
persistState: false,
|
|
153
|
+
...overrides,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
afterEach(() => {
|
|
157
|
+
document.body.innerHTML = "";
|
|
158
|
+
localStorage.clear();
|
|
159
|
+
vi.restoreAllMocks();
|
|
160
|
+
vi.unstubAllGlobals();
|
|
161
|
+
vi.useRealTimers();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("scrollBehavior.pauseOnInteraction (Principle 3)", () => {
|
|
165
|
+
beforeEach(() => {
|
|
166
|
+
installRafMock();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("pauses auto-follow on a transcript navigation keypress when enabled", () => {
|
|
170
|
+
const raf = installRafMock();
|
|
171
|
+
const mount = createMount();
|
|
172
|
+
const controller = createAgentExperience(
|
|
173
|
+
mount,
|
|
174
|
+
baseConfig({ features: { scrollBehavior: { mode: "follow", pauseOnInteraction: true } } })
|
|
175
|
+
);
|
|
176
|
+
const sc = getScrollContainer(mount);
|
|
177
|
+
const metrics = installScrollMetrics(sc, { scrollHeight: 1000, clientHeight: 400 });
|
|
178
|
+
|
|
179
|
+
emitStreamingMessage(controller, "a1", "First chunk");
|
|
180
|
+
raf.flush();
|
|
181
|
+
expect(metrics.getScrollTop()).toBe(metrics.getBottom());
|
|
182
|
+
|
|
183
|
+
sc.dispatchEvent(new KeyboardEvent("keydown", { key: "PageUp", bubbles: true }));
|
|
184
|
+
|
|
185
|
+
metrics.setScrollHeight(1080);
|
|
186
|
+
emitStreamingMessage(controller, "a1", "First chunk + more");
|
|
187
|
+
raf.flush();
|
|
188
|
+
|
|
189
|
+
// Paused: the stream no longer chases the bottom.
|
|
190
|
+
expect(metrics.getScrollTop()).toBe(600);
|
|
191
|
+
controller.destroy();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("does NOT pause on keypress when the option is off (default)", () => {
|
|
195
|
+
const raf = installRafMock();
|
|
196
|
+
const mount = createMount();
|
|
197
|
+
const controller = createAgentExperience(
|
|
198
|
+
mount,
|
|
199
|
+
baseConfig({ features: { scrollBehavior: { mode: "follow" } } })
|
|
200
|
+
);
|
|
201
|
+
const sc = getScrollContainer(mount);
|
|
202
|
+
const metrics = installScrollMetrics(sc, { scrollHeight: 1000, clientHeight: 400 });
|
|
203
|
+
|
|
204
|
+
emitStreamingMessage(controller, "a1", "First chunk");
|
|
205
|
+
raf.flush();
|
|
206
|
+
|
|
207
|
+
sc.dispatchEvent(new KeyboardEvent("keydown", { key: "PageUp", bubbles: true }));
|
|
208
|
+
|
|
209
|
+
metrics.setScrollHeight(1080);
|
|
210
|
+
emitStreamingMessage(controller, "a1", "First chunk + more");
|
|
211
|
+
raf.flush();
|
|
212
|
+
|
|
213
|
+
// Still following: chases the new bottom.
|
|
214
|
+
expect(metrics.getScrollTop()).toBe(metrics.getBottom());
|
|
215
|
+
controller.destroy();
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("scrollBehavior.showActivityWhilePinned (Principle 8)", () => {
|
|
220
|
+
beforeEach(() => {
|
|
221
|
+
installRafMock();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("counts the anchored response arriving below the pinned turn by default", () => {
|
|
225
|
+
const mount = createMount();
|
|
226
|
+
// showActivityWhilePinned now defaults on (alongside the anchor-top default).
|
|
227
|
+
const controller = createAgentExperience(
|
|
228
|
+
mount,
|
|
229
|
+
baseConfig({ features: { scrollBehavior: { mode: "anchor-top" } } })
|
|
230
|
+
);
|
|
231
|
+
const sc = getScrollContainer(mount);
|
|
232
|
+
const metrics = installScrollMetrics(sc, { scrollHeight: 1000, clientHeight: 400 });
|
|
233
|
+
anchorUserTurn(controller);
|
|
234
|
+
metrics.setScrollTop(0); // reading the pinned question, away from the bottom
|
|
235
|
+
|
|
236
|
+
emitAssistantMessage(controller, "a1", "Arrived below");
|
|
237
|
+
|
|
238
|
+
expect(getCountBadge(mount).textContent).toBe("1");
|
|
239
|
+
expect(getScrollToBottomButton(mount).getAttribute("aria-label")).toContain("1 new");
|
|
240
|
+
controller.destroy();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("stays silent when showActivityWhilePinned is disabled", () => {
|
|
244
|
+
const mount = createMount();
|
|
245
|
+
const controller = createAgentExperience(
|
|
246
|
+
mount,
|
|
247
|
+
baseConfig({
|
|
248
|
+
features: {
|
|
249
|
+
scrollBehavior: { mode: "anchor-top", showActivityWhilePinned: false },
|
|
250
|
+
},
|
|
251
|
+
})
|
|
252
|
+
);
|
|
253
|
+
const sc = getScrollContainer(mount);
|
|
254
|
+
const metrics = installScrollMetrics(sc, { scrollHeight: 1000, clientHeight: 400 });
|
|
255
|
+
anchorUserTurn(controller);
|
|
256
|
+
metrics.setScrollTop(0);
|
|
257
|
+
|
|
258
|
+
emitAssistantMessage(controller, "a1", "Arrived below");
|
|
259
|
+
|
|
260
|
+
expect(getCountBadge(mount).textContent).toBe("");
|
|
261
|
+
controller.destroy();
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe("scrollBehavior anchor-top no-anchor fallback", () => {
|
|
266
|
+
let raf: ReturnType<typeof installRafMock>;
|
|
267
|
+
beforeEach(() => {
|
|
268
|
+
raf = installRafMock();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const makeAnchorTop = (mount: HTMLElement) =>
|
|
272
|
+
createAgentExperience(
|
|
273
|
+
mount,
|
|
274
|
+
baseConfig({ features: { scrollBehavior: { mode: "anchor-top" } } })
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
it("follows to the bottom for an assistant turn with no user anchor", () => {
|
|
278
|
+
const mount = createMount();
|
|
279
|
+
const controller = makeAnchorTop(mount);
|
|
280
|
+
const sc = getScrollContainer(mount);
|
|
281
|
+
const metrics = installScrollMetrics(sc, { scrollHeight: 1000, clientHeight: 400 });
|
|
282
|
+
|
|
283
|
+
// A proactive/first-load assistant stream with no preceding user send: it
|
|
284
|
+
// has no anchor, so it falls back to follow-to-bottom rather than streaming
|
|
285
|
+
// in off-screen.
|
|
286
|
+
emitStreamingAssistant(controller, "a1", "Proactive reply");
|
|
287
|
+
raf.flush();
|
|
288
|
+
|
|
289
|
+
expect(metrics.getScrollTop()).toBe(metrics.getBottom());
|
|
290
|
+
controller.destroy();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("anchors near the top (does not follow) when the turn follows a user send", () => {
|
|
294
|
+
const mount = createMount();
|
|
295
|
+
const controller = makeAnchorTop(mount);
|
|
296
|
+
const sc = getScrollContainer(mount);
|
|
297
|
+
const metrics = installScrollMetrics(sc, { scrollHeight: 1000, clientHeight: 400 });
|
|
298
|
+
|
|
299
|
+
anchorUserTurn(controller); // seed + user send → real anchor
|
|
300
|
+
raf.flush();
|
|
301
|
+
emitStreamingAssistant(controller, "a1", "Answer below the pinned question");
|
|
302
|
+
raf.flush();
|
|
303
|
+
|
|
304
|
+
// The anchored response is pinned near the top (jsdom offsetTop 0 → 0), not
|
|
305
|
+
// chased to the bottom.
|
|
306
|
+
expect(metrics.getScrollTop()).toBe(0);
|
|
307
|
+
controller.destroy();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("keeps follow-on assistant content in an anchored turn pinned (no late-embed yank)", () => {
|
|
311
|
+
const mount = createMount();
|
|
312
|
+
const controller = makeAnchorTop(mount);
|
|
313
|
+
const sc = getScrollContainer(mount);
|
|
314
|
+
const metrics = installScrollMetrics(sc, { scrollHeight: 1000, clientHeight: 400 });
|
|
315
|
+
|
|
316
|
+
anchorUserTurn(controller); // user send → anchor
|
|
317
|
+
raf.flush();
|
|
318
|
+
emitAssistantMessage(controller, "a1", "Anchored answer");
|
|
319
|
+
raf.flush();
|
|
320
|
+
metrics.setScrollTop(0); // reading the pinned question from the top
|
|
321
|
+
|
|
322
|
+
// A second assistant message in the same anchored conversation — a
|
|
323
|
+
// multi-part reply or a late-injected embed (tweet/image/tool result) — must
|
|
324
|
+
// NOT re-arm the fallback or yank the viewport to the bottom.
|
|
325
|
+
emitStreamingAssistant(controller, "a2", "Late-injected embed content");
|
|
326
|
+
raf.flush();
|
|
327
|
+
|
|
328
|
+
expect(metrics.getScrollTop()).toBe(0);
|
|
329
|
+
controller.destroy();
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
describe("scrollBehavior.restorePosition (Principle 11)", () => {
|
|
334
|
+
beforeEach(() => {
|
|
335
|
+
installRafMock();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const history: AgentWidgetMessage[] = [
|
|
339
|
+
{ id: "u1", role: "user", content: "First question", createdAt: CREATED_AT },
|
|
340
|
+
{ id: "a1", role: "assistant", content: "First answer", createdAt: CREATED_AT },
|
|
341
|
+
{ id: "u2", role: "user", content: "Second question", createdAt: CREATED_AT },
|
|
342
|
+
{ id: "a2", role: "assistant", content: "Second answer", createdAt: CREATED_AT },
|
|
343
|
+
];
|
|
344
|
+
|
|
345
|
+
it("pins the last user message near the top on open when set to last-user-turn", () => {
|
|
346
|
+
const raf = installRafMock();
|
|
347
|
+
const mount = createMount();
|
|
348
|
+
const controller = createAgentExperience(
|
|
349
|
+
mount,
|
|
350
|
+
baseConfig({
|
|
351
|
+
launcher: { enabled: true, autoExpand: false },
|
|
352
|
+
initialMessages: history,
|
|
353
|
+
features: { scrollBehavior: { mode: "follow", restorePosition: "last-user-turn" } },
|
|
354
|
+
})
|
|
355
|
+
);
|
|
356
|
+
const sc = getScrollContainer(mount);
|
|
357
|
+
const metrics = installScrollMetrics(sc, { scrollHeight: 1000, clientHeight: 400 });
|
|
358
|
+
|
|
359
|
+
// jsdom computes no layout; give the last user bubble a known offsetTop so
|
|
360
|
+
// the anchor geometry has something to target.
|
|
361
|
+
const lastUserBubble = sc.querySelector<HTMLElement>('[data-message-id="u2"]')!;
|
|
362
|
+
Object.defineProperty(lastUserBubble, "offsetTop", { configurable: true, get: () => 320 });
|
|
363
|
+
|
|
364
|
+
controller.open();
|
|
365
|
+
raf.flush();
|
|
366
|
+
|
|
367
|
+
// 320 - 16 (anchorTopOffset) = 304, above the bottom (600).
|
|
368
|
+
expect(metrics.getScrollTop()).toBe(304);
|
|
369
|
+
controller.destroy();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("jumps to the bottom on open by default", () => {
|
|
373
|
+
const raf = installRafMock();
|
|
374
|
+
const mount = createMount();
|
|
375
|
+
const controller = createAgentExperience(
|
|
376
|
+
mount,
|
|
377
|
+
baseConfig({
|
|
378
|
+
launcher: { enabled: true, autoExpand: false },
|
|
379
|
+
initialMessages: history,
|
|
380
|
+
features: { scrollBehavior: { mode: "follow" } },
|
|
381
|
+
})
|
|
382
|
+
);
|
|
383
|
+
const sc = getScrollContainer(mount);
|
|
384
|
+
const metrics = installScrollMetrics(sc, { scrollHeight: 1000, clientHeight: 400 });
|
|
385
|
+
|
|
386
|
+
controller.open();
|
|
387
|
+
raf.flush();
|
|
388
|
+
|
|
389
|
+
expect(metrics.getScrollTop()).toBe(metrics.getBottom());
|
|
390
|
+
controller.destroy();
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
describe("scrollBehavior.announce (Principle 15)", () => {
|
|
395
|
+
it("always creates a polite live region", () => {
|
|
396
|
+
installRafMock();
|
|
397
|
+
const mount = createMount();
|
|
398
|
+
const controller = createAgentExperience(mount, baseConfig({}));
|
|
399
|
+
const region = getLiveRegion(mount);
|
|
400
|
+
expect(region).not.toBeNull();
|
|
401
|
+
expect(region!.getAttribute("aria-live")).toBe("polite");
|
|
402
|
+
controller.destroy();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("announces new-content arrival at a debounced cadence when enabled", () => {
|
|
406
|
+
vi.useFakeTimers();
|
|
407
|
+
const raf = installRafMock();
|
|
408
|
+
const mount = createMount();
|
|
409
|
+
const controller = createAgentExperience(
|
|
410
|
+
mount,
|
|
411
|
+
baseConfig({ features: { scrollBehavior: { mode: "follow", announce: true } } })
|
|
412
|
+
);
|
|
413
|
+
const sc = getScrollContainer(mount);
|
|
414
|
+
const metrics = installScrollMetrics(sc, { scrollHeight: 1000, clientHeight: 400 });
|
|
415
|
+
|
|
416
|
+
emitStreamingMessage(controller, "seed", "hi");
|
|
417
|
+
raf.flush();
|
|
418
|
+
// Scroll up so the next message counts as "below".
|
|
419
|
+
metrics.setScrollTop(100);
|
|
420
|
+
sc.dispatchEvent(new Event("scroll"));
|
|
421
|
+
|
|
422
|
+
emitAssistantMessage(controller, "a1", "Arrived below");
|
|
423
|
+
vi.advanceTimersByTime(400);
|
|
424
|
+
|
|
425
|
+
expect(getLiveRegion(mount)!.textContent).toContain("new message");
|
|
426
|
+
controller.destroy();
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("stays silent when announce is off (default)", () => {
|
|
430
|
+
vi.useFakeTimers();
|
|
431
|
+
const raf = installRafMock();
|
|
432
|
+
const mount = createMount();
|
|
433
|
+
const controller = createAgentExperience(
|
|
434
|
+
mount,
|
|
435
|
+
baseConfig({ features: { scrollBehavior: { mode: "follow" } } })
|
|
436
|
+
);
|
|
437
|
+
const sc = getScrollContainer(mount);
|
|
438
|
+
const metrics = installScrollMetrics(sc, { scrollHeight: 1000, clientHeight: 400 });
|
|
439
|
+
|
|
440
|
+
emitStreamingMessage(controller, "seed", "hi");
|
|
441
|
+
raf.flush();
|
|
442
|
+
metrics.setScrollTop(100);
|
|
443
|
+
sc.dispatchEvent(new Event("scroll"));
|
|
444
|
+
|
|
445
|
+
emitAssistantMessage(controller, "a1", "Arrived below");
|
|
446
|
+
vi.advanceTimersByTime(400);
|
|
447
|
+
|
|
448
|
+
expect(getLiveRegion(mount)!.textContent).toBe("");
|
|
449
|
+
controller.destroy();
|
|
450
|
+
});
|
|
451
|
+
});
|
package/src/ui.scroll.test.ts
CHANGED
|
@@ -912,9 +912,12 @@ describe("createAgentExperience streaming scroll", () => {
|
|
|
912
912
|
it("re-sticks to the bottom when the user sends a message after scrolling up", () => {
|
|
913
913
|
const raf = installRafMock();
|
|
914
914
|
const mount = createMount();
|
|
915
|
+
// Follow-specific: a user send re-sticks to the bottom (anchor-top, the
|
|
916
|
+
// default, would pin the sent message near the top instead).
|
|
915
917
|
const controller = createAgentExperience(mount, {
|
|
916
918
|
apiUrl: "https://api.example.com/chat",
|
|
917
|
-
launcher: { enabled: false }
|
|
919
|
+
launcher: { enabled: false },
|
|
920
|
+
features: { scrollBehavior: { mode: "follow" } }
|
|
918
921
|
});
|
|
919
922
|
|
|
920
923
|
const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
|
|
@@ -941,9 +944,12 @@ describe("createAgentExperience streaming scroll", () => {
|
|
|
941
944
|
it("shows a count badge for messages that arrive while paused", () => {
|
|
942
945
|
const raf = installRafMock();
|
|
943
946
|
const mount = createMount();
|
|
947
|
+
// Follow-specific paused-badge semantics (wheel-up pauses auto-follow, then
|
|
948
|
+
// a message arrives while paused and below the fold).
|
|
944
949
|
const controller = createAgentExperience(mount, {
|
|
945
950
|
apiUrl: "https://api.example.com/chat",
|
|
946
|
-
launcher: { enabled: false }
|
|
951
|
+
launcher: { enabled: false },
|
|
952
|
+
features: { scrollBehavior: { mode: "follow" } }
|
|
947
953
|
});
|
|
948
954
|
|
|
949
955
|
const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
|