@runtypelabs/persona 4.4.2 → 4.6.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 (46) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/{types-C6tFDxKy.d.cts → types-8RICZWQe.d.cts} +6 -0
  4. package/dist/animations/{types-C6tFDxKy.d.ts → types-8RICZWQe.d.ts} +6 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/chunk-DFBSCFYN.js +1 -0
  8. package/dist/codegen.cjs +1 -1
  9. package/dist/codegen.js +1 -1
  10. package/dist/index.cjs +50 -50
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +286 -11
  13. package/dist/index.d.ts +286 -11
  14. package/dist/index.global.js +39 -39
  15. package/dist/index.global.js.map +1 -1
  16. package/dist/index.js +51 -51
  17. package/dist/index.js.map +1 -1
  18. package/dist/launcher.global.js.map +1 -1
  19. package/dist/markdown-parsers-entry-NVFT3TE6.js +1 -0
  20. package/dist/runtype-tts-entry-HFUV2UF7.js +1 -0
  21. package/dist/session-reconnect-U77QFUR7.js +1 -0
  22. package/dist/smart-dom-reader.d.cts +147 -8
  23. package/dist/smart-dom-reader.d.ts +147 -8
  24. package/dist/theme-editor-preview.cjs +48 -48
  25. package/dist/theme-editor-preview.d.cts +187 -9
  26. package/dist/theme-editor-preview.d.ts +187 -9
  27. package/dist/theme-editor-preview.js +48 -48
  28. package/dist/theme-editor.cjs +1 -1
  29. package/dist/theme-editor.d.cts +147 -8
  30. package/dist/theme-editor.d.ts +147 -8
  31. package/dist/theme-editor.js +1 -1
  32. package/package.json +2 -2
  33. package/src/client.ts +48 -7
  34. package/src/defaults.ts +9 -1
  35. package/src/generated/runtype-openapi-contract.ts +6 -0
  36. package/src/index-core.ts +5 -0
  37. package/src/reconnect-wake.test.ts +162 -0
  38. package/src/reconnect.test.ts +430 -0
  39. package/src/session-reconnect.ts +282 -0
  40. package/src/session.ts +408 -5
  41. package/src/types.ts +165 -9
  42. package/src/ui.scroll-additive.test.ts +451 -0
  43. package/src/ui.scroll.test.ts +8 -2
  44. package/src/ui.stream-animation-update.test.ts +99 -0
  45. package/src/ui.ts +371 -41
  46. package/src/utils/constants.ts +3 -1
package/src/types.ts CHANGED
@@ -386,6 +386,28 @@ export type AgentExecutionState = {
386
386
  stopReason?: 'complete' | 'end_turn' | 'max_turns' | 'max_cost' | 'timeout' | 'error';
387
387
  };
388
388
 
389
+ /**
390
+ * The coordinates needed to resume a durable agent turn after the SSE
391
+ * connection drops (any resumable, server-persisted execution, e.g. Claude
392
+ * Managed agents or async/background runs). Held on the session while a
393
+ * resumable stream is in flight
394
+ * and surfaced to the host via {@link AgentWidgetConfig.onExecutionState} so it
395
+ * can persist `{ executionId, lastEventId }` next to its own `conversationId`
396
+ * for the tab-reload path.
397
+ *
398
+ * `conversationId` is intentionally absent: it is host-owned (it lives in the
399
+ * host's `reconnectStream`/`customFetch` closure; the widget never sees it).
400
+ */
401
+ export type ResumableHandle = {
402
+ /** The durable turn to reconnect to (from `agentMetadata.executionId`). */
403
+ executionId: string;
404
+ /** Highest SSE `id:` seq applied: the `?after=` reconnect cursor. */
405
+ lastEventId: string;
406
+ /** The open assistant message being streamed into, kept open on resume. */
407
+ assistantMessageId: string;
408
+ status: 'running';
409
+ };
410
+
389
411
  /**
390
412
  * Metadata attached to messages created during agent execution.
391
413
  */
@@ -689,6 +711,12 @@ export type AgentWidgetControllerEventMap = {
689
711
  "eventStream:closed": { timestamp: number };
690
712
  "approval:requested": { approval: AgentWidgetApproval; message: AgentWidgetMessage };
691
713
  "approval:resolved": { approval: AgentWidgetApproval; decision: string };
714
+ /** A durable stream dropped; the widget is about to reconnect. */
715
+ "stream:paused": { executionId: string; after: string };
716
+ /** A reconnect attempt is in flight (`attempt` is 1-based). */
717
+ "stream:resuming": { executionId: string; after: string; attempt: number };
718
+ /** The durable turn resumed and reached its terminal after a reconnect. */
719
+ "stream:resumed": { executionId: string; after: string };
692
720
  };
693
721
 
694
722
  /**
@@ -847,26 +875,76 @@ export type AgentWidgetArtifactsFeature = {
847
875
  /**
848
876
  * How the transcript scrolls while an assistant response streams in.
849
877
  *
850
- * - `"follow"` (default): keep the newest content pinned to the bottom of the
851
- * viewport, pausing when the user scrolls up and resuming when they return
852
- * to the bottom.
853
- * - `"anchor-top"`: on send, scroll the user's message near the top of the
854
- * viewport and hold it there while the response streams in beneath it
855
- * (ChatGPT-style). The transcript never auto-scrolls during streaming.
878
+ * - `"anchor-top"` (default): on send, scroll the user's message near the top
879
+ * of the viewport and hold it there while the response streams in beneath it
880
+ * (ChatGPT-style). When a turn has no user send to anchor to (a proactive
881
+ * greeting, an injected assistant message, a resubmit, or first-load
882
+ * streaming), it falls back to `"follow"` for that turn so content never
883
+ * streams in off-screen.
884
+ * - `"follow"`: keep the newest content pinned to the bottom of the viewport,
885
+ * pausing when the user scrolls up and resuming when they return to the
886
+ * bottom. This was the default before 4.x.
856
887
  * - `"none"`: never auto-scroll; the scroll-to-bottom affordance is the only
857
888
  * way back to the latest content.
858
889
  */
859
890
  export type AgentWidgetScrollMode = "follow" | "anchor-top" | "none";
860
891
 
892
+ /**
893
+ * Where the transcript lands when a *saved* conversation is reopened (restored
894
+ * from the storage adapter / `initialMessages`), as opposed to a fresh send.
895
+ *
896
+ * - `"bottom"` (default): jump to the absolute end of the transcript, matching
897
+ * the historical behavior.
898
+ * - `"last-user-turn"`: pin the last user message near the top of the viewport
899
+ * (the last point the reader was actively driving the conversation) so a long
900
+ * restored thread opens at a readable place rather than at the very bottom.
901
+ */
902
+ export type AgentWidgetScrollRestorePosition = "bottom" | "last-user-turn";
903
+
861
904
  export type AgentWidgetScrollBehaviorFeature = {
862
- /** Scroll behavior during streamed responses. @default "follow" */
905
+ /** Scroll behavior during streamed responses. @default "anchor-top" */
863
906
  mode?: AgentWidgetScrollMode;
864
907
  /**
865
908
  * Gap (px) kept between the anchored user message and the top of the
866
- * viewport in `"anchor-top"` mode.
909
+ * viewport in `"anchor-top"` mode. Also used as the gap for
910
+ * `restorePosition: "last-user-turn"`.
867
911
  * @default 16
868
912
  */
869
913
  anchorTopOffset?: number;
914
+ /**
915
+ * Where to land when a saved conversation reopens. Additive and opt-in: the
916
+ * default preserves the historical "jump to the bottom" behavior.
917
+ * @default "bottom"
918
+ */
919
+ restorePosition?: AgentWidgetScrollRestorePosition;
920
+ /**
921
+ * When true, interactions beyond text selection — keyboard navigation
922
+ * (PageUp/PageDown/Home/End/arrows) inside the transcript and focusing a
923
+ * link or other interactive element within it — also pause auto-follow in
924
+ * `"follow"` mode. Treats "the reader is doing something here" as intent to
925
+ * stay put, not just "the reader dragged a selection". Opt-in; default keeps
926
+ * only the existing wheel/scroll/selection triggers.
927
+ * @default false
928
+ */
929
+ pauseOnInteraction?: boolean;
930
+ /**
931
+ * When true, the scroll-to-bottom affordance also surfaces new-content and
932
+ * still-streaming activity while the reader is pinned away from the latest
933
+ * content in `"anchor-top"` mode. Lets the reader know a response is arriving
934
+ * offscreen below. Defaults on alongside the `"anchor-top"` default; set
935
+ * `false` to keep the count + streaming hint silent while pinned.
936
+ * @default true
937
+ */
938
+ showActivityWhilePinned?: boolean;
939
+ /**
940
+ * When true, Persona maintains a visually-hidden `aria-live="polite"` region
941
+ * that announces important transcript events — a response starting, a
942
+ * response finishing, and "N new messages below" while the reader is scrolled
943
+ * away — at a comfortable cadence (never token-by-token). Opt-in so existing
944
+ * embeds don't suddenly gain screen-reader announcements.
945
+ * @default false
946
+ */
947
+ announce?: boolean;
870
948
  };
871
949
 
872
950
  export type AgentWidgetScrollToBottomFeature = {
@@ -1801,6 +1879,10 @@ export type AgentWidgetStatusIndicatorConfig = {
1801
1879
  connectingText?: string;
1802
1880
  connectedText?: string;
1803
1881
  errorText?: string;
1882
+ /** Status text while a dropped durable stream is awaiting reconnect. */
1883
+ pausedText?: string;
1884
+ /** Status text while a reconnect attempt is in flight. */
1885
+ resumingText?: string;
1804
1886
  };
1805
1887
 
1806
1888
  export type AgentWidgetVoiceRecognitionConfig = {
@@ -4110,6 +4192,62 @@ export type AgentWidgetConfig = {
4110
4192
  * ```
4111
4193
  */
4112
4194
  customFetch?: AgentWidgetCustomFetch;
4195
+ /**
4196
+ * Durable-session reconnect transport (host-owned, symmetric to
4197
+ * {@link customFetch}). When a durable agent stream drops mid-turn (any
4198
+ * resumable, server-persisted execution, e.g. Claude Managed agents or
4199
+ * async/background runs), the widget calls this to fetch the read-only
4200
+ * reconnect stream and pipes its body through the normal event pipeline to
4201
+ * resume from where it left off.
4202
+ *
4203
+ * The host closure owns `agentId` / `conversationId` / base URL / auth and
4204
+ * builds the events request, e.g.:
4205
+ *
4206
+ * ```typescript
4207
+ * reconnectStream: ({ executionId, after, signal }) =>
4208
+ * fetch(
4209
+ * `${baseUrl}/v1/agents/${agentId}/executions/${executionId}` +
4210
+ * `/events?conversationId=${conversationId}&after=${after}`,
4211
+ * { headers: { Authorization: `Bearer ${token}`, "X-Persona-Version": ver }, signal }
4212
+ * )
4213
+ * ```
4214
+ *
4215
+ * Resolve with a `text/event-stream` `Response`. Throw or resolve non-ok to
4216
+ * signal "this attempt failed" (the widget backs off and retries, then gives
4217
+ * up after the bounded attempts). Reconnect only ever arms on the durable
4218
+ * lane (streams carrying SSE `id:` lines); without this hook a drop finalizes
4219
+ * exactly as before.
4220
+ */
4221
+ reconnectStream?: (ctx: {
4222
+ executionId: string;
4223
+ after: string;
4224
+ signal: AbortSignal;
4225
+ }) => Promise<Response>;
4226
+ /**
4227
+ * Tuning for the auto-reconnect backoff. Defaults to ~5 attempts over ~30s
4228
+ * (`backoffMs: [1000, 2000, 4000, 8000, 8000]`).
4229
+ */
4230
+ reconnect?: {
4231
+ /** Max reconnect attempts before giving up and finalizing. @default backoffMs.length */
4232
+ maxAttempts?: number;
4233
+ /** Per-attempt delay (ms) before each retry. @default [1000, 2000, 4000, 8000, 8000] */
4234
+ backoffMs?: number[];
4235
+ };
4236
+ /**
4237
+ * Called whenever the durable resume handle changes: created when a durable
4238
+ * turn starts streaming, updated as the cursor advances (throttled), and
4239
+ * `null` when the turn finishes, errors, or is torn down. Persist
4240
+ * `{ executionId, lastEventId }` next to your `conversationId` and pass it
4241
+ * back via {@link resume} on the next mount to survive a tab reload.
4242
+ */
4243
+ onExecutionState?: (handle: ResumableHandle | null) => void;
4244
+ /**
4245
+ * Resume a durable turn on boot (tab-reload path). When present alongside
4246
+ * {@link reconnectStream}, the widget enters `resuming` immediately on mount
4247
+ * and reconnects from `after`, replaying everything past the cursor into the
4248
+ * restored conversation.
4249
+ */
4250
+ resume?: { executionId: string; after: string };
4113
4251
  /**
4114
4252
  * Custom SSE event parser for non-standard streaming response formats.
4115
4253
  *
@@ -4686,8 +4824,26 @@ export type PersonaArtifactManualUpsert =
4686
4824
 
4687
4825
  export type AgentWidgetEvent =
4688
4826
  | { type: "message"; message: AgentWidgetMessage }
4689
- | { type: "status"; status: "connecting" | "connected" | "error" | "idle" }
4827
+ | {
4828
+ type: "status";
4829
+ status: "connecting" | "connected" | "error" | "idle";
4830
+ /**
4831
+ * Set on the `idle` emitted from a graceful execution terminal
4832
+ * (`execution_complete`). Distinguishes a real finish from the plain
4833
+ * `idle` the dispatch wrappers emit in their `finally` on a dropped
4834
+ * connection. The durable-reconnect drop detection keys off this.
4835
+ */
4836
+ terminal?: boolean;
4837
+ }
4690
4838
  | { type: "error"; error: Error }
4839
+ /**
4840
+ * Durable-session reconnect cursor. Carries the SSE `id:` line (the durable
4841
+ * row seq) of a fully-parsed frame so the session can track `lastEventId`
4842
+ * and resume from `?after=<id>` after a drop. Only emitted on the durable
4843
+ * lane (any resumable execution that stamps `id:` lines, e.g. Claude Managed
4844
+ * agents or async/background runs).
4845
+ */
4846
+ | { type: "cursor"; id: string }
4691
4847
  | {
4692
4848
  type: "artifact_start";
4693
4849
  id: string;
@@ -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
+ });