@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.
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-C6tFDxKy.d.cts → types-8RICZWQe.d.cts} +6 -0
- package/dist/animations/{types-C6tFDxKy.d.ts → types-8RICZWQe.d.ts} +6 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-DFBSCFYN.js +1 -0
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/index.cjs +50 -50
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +286 -11
- package/dist/index.d.ts +286 -11
- package/dist/index.global.js +39 -39
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +51 -51
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js.map +1 -1
- package/dist/markdown-parsers-entry-NVFT3TE6.js +1 -0
- package/dist/runtype-tts-entry-HFUV2UF7.js +1 -0
- package/dist/session-reconnect-U77QFUR7.js +1 -0
- package/dist/smart-dom-reader.d.cts +147 -8
- package/dist/smart-dom-reader.d.ts +147 -8
- package/dist/theme-editor-preview.cjs +48 -48
- package/dist/theme-editor-preview.d.cts +187 -9
- package/dist/theme-editor-preview.d.ts +187 -9
- package/dist/theme-editor-preview.js +48 -48
- package/dist/theme-editor.cjs +1 -1
- package/dist/theme-editor.d.cts +147 -8
- package/dist/theme-editor.d.ts +147 -8
- package/dist/theme-editor.js +1 -1
- package/package.json +2 -2
- package/src/client.ts +48 -7
- package/src/defaults.ts +9 -1
- package/src/generated/runtype-openapi-contract.ts +6 -0
- package/src/index-core.ts +5 -0
- package/src/reconnect-wake.test.ts +162 -0
- package/src/reconnect.test.ts +430 -0
- package/src/session-reconnect.ts +282 -0
- package/src/session.ts +408 -5
- package/src/types.ts +165 -9
- package/src/ui.scroll-additive.test.ts +451 -0
- package/src/ui.scroll.test.ts +8 -2
- package/src/ui.stream-animation-update.test.ts +99 -0
- package/src/ui.ts +371 -41
- 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
|
-
* - `"
|
|
851
|
-
* viewport
|
|
852
|
-
* to
|
|
853
|
-
*
|
|
854
|
-
*
|
|
855
|
-
*
|
|
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 "
|
|
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
|
-
| {
|
|
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
|
+
});
|