@runtypelabs/persona 3.30.0 → 3.31.1
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/README.md +15 -2602
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-B_Qazlm4.d.cts → types-quh7NmYD.d.cts} +9 -0
- package/dist/animations/{types-B_Qazlm4.d.ts → types-quh7NmYD.d.ts} +9 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/codegen.cjs +4 -4
- package/dist/codegen.js +3 -3
- package/dist/index.cjs +46 -46
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +52 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.global.js +77 -80
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +45 -45
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +2 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +50 -0
- package/dist/smart-dom-reader.d.ts +50 -0
- package/dist/theme-editor.cjs +39 -39
- package/dist/theme-editor.d.cts +50 -0
- package/dist/theme-editor.d.ts +50 -0
- package/dist/theme-editor.js +39 -39
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.js +1 -1
- package/dist/webmcp-polyfill.js +4 -0
- package/dist/widget.css +19 -0
- package/package.json +4 -3
- package/src/client.ts +6 -0
- package/src/components/approval-bubble.test.ts +52 -0
- package/src/components/approval-bubble.ts +20 -0
- package/src/components/panel.ts +7 -1
- package/src/defaults.ts +14 -0
- package/src/generated/runtype-openapi-contract.ts +4 -0
- package/src/index-global.ts +39 -0
- package/src/session.test.ts +62 -0
- package/src/session.ts +27 -0
- package/src/styles/widget.css +19 -0
- package/src/theme-reference.ts +1 -1
- package/src/types.ts +50 -0
- package/src/ui.scroll.test.ts +383 -0
- package/src/ui.ts +390 -40
- package/src/utils/auto-follow.test.ts +91 -0
- package/src/utils/auto-follow.ts +68 -0
- package/src/version.ts +5 -2
- package/src/webmcp-bridge.test.ts +60 -0
- package/src/webmcp-bridge.ts +34 -1
- package/src/webmcp-polyfill.ts +16 -0
package/src/session.test.ts
CHANGED
|
@@ -894,6 +894,68 @@ describe('AgentWidgetSession.resolveApproval', () => {
|
|
|
894
894
|
});
|
|
895
895
|
});
|
|
896
896
|
|
|
897
|
+
describe('AgentWidgetSession - approval context across agent_approval_complete', () => {
|
|
898
|
+
const sseStream = (events: Array<Record<string, unknown>>): ReadableStream<Uint8Array> => {
|
|
899
|
+
const encoder = new TextEncoder();
|
|
900
|
+
const body = events
|
|
901
|
+
.map((e) => `event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`)
|
|
902
|
+
.join('');
|
|
903
|
+
return new ReadableStream({
|
|
904
|
+
start(controller) {
|
|
905
|
+
controller.enqueue(encoder.encode(body));
|
|
906
|
+
controller.close();
|
|
907
|
+
},
|
|
908
|
+
});
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
it('keeps toolName/description/toolType/reason/parameters when the sparse complete event resolves the bubble', async () => {
|
|
912
|
+
let messages: AgentWidgetMessage[] = [];
|
|
913
|
+
const session = new AgentWidgetSession(
|
|
914
|
+
{ apiUrl: 'http://localhost:8000' },
|
|
915
|
+
{
|
|
916
|
+
onMessagesChanged: (m) => { messages = m; },
|
|
917
|
+
onStatusChanged: () => {},
|
|
918
|
+
onStreamingChanged: () => {},
|
|
919
|
+
onError: () => {},
|
|
920
|
+
}
|
|
921
|
+
);
|
|
922
|
+
|
|
923
|
+
// `agent_approval_complete` carries only the resolution — none of the
|
|
924
|
+
// context fields from `agent_approval_start`. The session merge must keep
|
|
925
|
+
// them so a full re-render of the resolved bubble (morph, virtual-scroll
|
|
926
|
+
// re-mount, storage restore) still shows the tool, description, and the
|
|
927
|
+
// agent's stated reason.
|
|
928
|
+
await session.connectStream(sseStream([
|
|
929
|
+
{
|
|
930
|
+
type: 'agent_approval_start',
|
|
931
|
+
executionId: 'exec_abc',
|
|
932
|
+
approvalId: 'appr_1',
|
|
933
|
+
toolName: 'send_email',
|
|
934
|
+
toolType: 'external',
|
|
935
|
+
description: 'Send an email to the customer',
|
|
936
|
+
reason: 'The user asked me to notify the customer.',
|
|
937
|
+
parameters: { to: 'customer@example.com' },
|
|
938
|
+
},
|
|
939
|
+
{
|
|
940
|
+
type: 'agent_approval_complete',
|
|
941
|
+
executionId: 'exec_abc',
|
|
942
|
+
approvalId: 'appr_1',
|
|
943
|
+
decision: 'approved',
|
|
944
|
+
resolvedBy: 'user',
|
|
945
|
+
},
|
|
946
|
+
]));
|
|
947
|
+
|
|
948
|
+
const bubble = messages.find((m) => m.id === 'approval-appr_1');
|
|
949
|
+
expect(bubble?.approval?.status).toBe('approved');
|
|
950
|
+
expect(bubble?.approval?.resolvedAt).toBeDefined();
|
|
951
|
+
expect(bubble?.approval?.toolName).toBe('send_email');
|
|
952
|
+
expect(bubble?.approval?.toolType).toBe('external');
|
|
953
|
+
expect(bubble?.approval?.description).toBe('Send an email to the customer');
|
|
954
|
+
expect(bubble?.approval?.reason).toBe('The user asked me to notify the customer.');
|
|
955
|
+
expect(bubble?.approval?.parameters).toEqual({ to: 'customer@example.com' });
|
|
956
|
+
});
|
|
957
|
+
});
|
|
958
|
+
|
|
897
959
|
describe('AgentWidgetSession - dispatch error fallback', () => {
|
|
898
960
|
const originalFetch = global.fetch;
|
|
899
961
|
|
package/src/session.ts
CHANGED
|
@@ -2659,6 +2659,33 @@ export class AgentWidgetSession {
|
|
|
2659
2659
|
awaitingLocalTool: false,
|
|
2660
2660
|
};
|
|
2661
2661
|
}
|
|
2662
|
+
// Approval equivalent: `agent_approval_complete` carries only the
|
|
2663
|
+
// resolution (approvalId, decision, resolvedBy) — the runtime does not
|
|
2664
|
+
// re-send toolName/description/toolType/reason/parameters, so client.ts
|
|
2665
|
+
// rebuilds the approval with empty required fields and the optional
|
|
2666
|
+
// ones absent. A wholesale `approval` replacement would wipe that
|
|
2667
|
+
// context from the resolved bubble on the next full re-render (morph,
|
|
2668
|
+
// virtual-scroll re-mount, storage restore). Merge field-wise instead:
|
|
2669
|
+
// take the resolution from the incoming event, keep existing context
|
|
2670
|
+
// wherever the event is silent or empty.
|
|
2671
|
+
if (
|
|
2672
|
+
existing.approval &&
|
|
2673
|
+
withSequence.approval &&
|
|
2674
|
+
existing.approval.id === withSequence.approval.id
|
|
2675
|
+
) {
|
|
2676
|
+
const prior = existing.approval;
|
|
2677
|
+
const incoming = withSequence.approval;
|
|
2678
|
+
merged.approval = {
|
|
2679
|
+
...prior,
|
|
2680
|
+
...incoming,
|
|
2681
|
+
executionId: incoming.executionId || prior.executionId,
|
|
2682
|
+
toolName: incoming.toolName || prior.toolName,
|
|
2683
|
+
description: incoming.description || prior.description,
|
|
2684
|
+
toolType: incoming.toolType ?? prior.toolType,
|
|
2685
|
+
reason: incoming.reason ?? prior.reason,
|
|
2686
|
+
parameters: incoming.parameters ?? prior.parameters,
|
|
2687
|
+
};
|
|
2688
|
+
}
|
|
2662
2689
|
// WebMCP equivalent: once a `webmcp:*` tool has started resolving
|
|
2663
2690
|
// (inflight) or resolved, a duplicate `step_await` re-emit must not
|
|
2664
2691
|
// flip `awaitingLocalTool` back to true and resurrect the "waiting on
|
package/src/styles/widget.css
CHANGED
|
@@ -2616,6 +2616,25 @@
|
|
|
2616
2616
|
height: var(--persona-scroll-to-bottom-icon-size, 14px);
|
|
2617
2617
|
}
|
|
2618
2618
|
|
|
2619
|
+
/* New-messages count badge pinned to the affordance's top-right corner. */
|
|
2620
|
+
[data-persona-root] .persona-scroll-to-bottom-indicator [data-persona-scroll-to-bottom-count] {
|
|
2621
|
+
position: absolute;
|
|
2622
|
+
top: -6px;
|
|
2623
|
+
right: -6px;
|
|
2624
|
+
box-sizing: border-box;
|
|
2625
|
+
min-width: 18px;
|
|
2626
|
+
height: 18px;
|
|
2627
|
+
padding: 0 5px;
|
|
2628
|
+
border-radius: var(--persona-radius-full, 9999px);
|
|
2629
|
+
border: 1px solid var(--persona-scroll-to-bottom-border, var(--persona-primary, #111827));
|
|
2630
|
+
background: var(--persona-scroll-to-bottom-count-bg, var(--persona-surface, #ffffff));
|
|
2631
|
+
color: var(--persona-scroll-to-bottom-count-fg, var(--persona-primary, #111827));
|
|
2632
|
+
font-size: 11px;
|
|
2633
|
+
font-weight: 600;
|
|
2634
|
+
line-height: 16px;
|
|
2635
|
+
text-align: center;
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2619
2638
|
[data-persona-root] .persona-scroll-to-bottom-indicator:hover {
|
|
2620
2639
|
opacity: 0.92;
|
|
2621
2640
|
}
|
package/src/theme-reference.ts
CHANGED
|
@@ -229,7 +229,7 @@ export const THEME_TOKEN_DOCS = {
|
|
|
229
229
|
description:
|
|
230
230
|
'Tool approval bubble styling and behavior. Set to false to disable.',
|
|
231
231
|
properties:
|
|
232
|
-
'backgroundColor, borderColor, titleColor, descriptionColor, approveButtonColor, approveButtonTextColor, denyButtonColor, denyButtonTextColor, parameterBackgroundColor, parameterTextColor, title, approveLabel, denyLabel.',
|
|
232
|
+
'backgroundColor, borderColor, titleColor, descriptionColor, reasonColor, reasonLabel, approveButtonColor, approveButtonTextColor, denyButtonColor, denyButtonTextColor, parameterBackgroundColor, parameterTextColor, title, approveLabel, denyLabel.',
|
|
233
233
|
},
|
|
234
234
|
copy: {
|
|
235
235
|
description: 'Widget text content.',
|
package/src/types.ts
CHANGED
|
@@ -757,6 +757,31 @@ export type AgentWidgetArtifactsFeature = {
|
|
|
757
757
|
}) => HTMLElement | null;
|
|
758
758
|
};
|
|
759
759
|
|
|
760
|
+
/**
|
|
761
|
+
* How the transcript scrolls while an assistant response streams in.
|
|
762
|
+
*
|
|
763
|
+
* - `"follow"` (default): keep the newest content pinned to the bottom of the
|
|
764
|
+
* viewport, pausing when the user scrolls up and resuming when they return
|
|
765
|
+
* to the bottom.
|
|
766
|
+
* - `"anchor-top"`: on send, scroll the user's message near the top of the
|
|
767
|
+
* viewport and hold it there while the response streams in beneath it
|
|
768
|
+
* (ChatGPT-style). The transcript never auto-scrolls during streaming.
|
|
769
|
+
* - `"none"`: never auto-scroll; the scroll-to-bottom affordance is the only
|
|
770
|
+
* way back to the latest content.
|
|
771
|
+
*/
|
|
772
|
+
export type AgentWidgetScrollMode = "follow" | "anchor-top" | "none";
|
|
773
|
+
|
|
774
|
+
export type AgentWidgetScrollBehaviorFeature = {
|
|
775
|
+
/** Scroll behavior during streamed responses. @default "follow" */
|
|
776
|
+
mode?: AgentWidgetScrollMode;
|
|
777
|
+
/**
|
|
778
|
+
* Gap (px) kept between the anchored user message and the top of the
|
|
779
|
+
* viewport in `"anchor-top"` mode.
|
|
780
|
+
* @default 16
|
|
781
|
+
*/
|
|
782
|
+
anchorTopOffset?: number;
|
|
783
|
+
};
|
|
784
|
+
|
|
760
785
|
export type AgentWidgetScrollToBottomFeature = {
|
|
761
786
|
/**
|
|
762
787
|
* When true, Persona shows a scroll-to-bottom affordance when the user breaks
|
|
@@ -1049,6 +1074,8 @@ export type AgentWidgetFeatureFlags = {
|
|
|
1049
1074
|
composerHistory?: boolean;
|
|
1050
1075
|
/** Shared transcript + event stream scroll-to-bottom affordance. */
|
|
1051
1076
|
scrollToBottom?: AgentWidgetScrollToBottomFeature;
|
|
1077
|
+
/** Transcript scroll behavior during streamed responses. */
|
|
1078
|
+
scrollBehavior?: AgentWidgetScrollBehaviorFeature;
|
|
1052
1079
|
/** Collapsed transcript behavior for tool call rows. */
|
|
1053
1080
|
toolCallDisplay?: AgentWidgetToolCallDisplayFeature;
|
|
1054
1081
|
/** Collapsed transcript behavior for reasoning rows. */
|
|
@@ -1872,6 +1899,16 @@ export type AgentWidgetApprovalConfig = {
|
|
|
1872
1899
|
approveLabel?: string;
|
|
1873
1900
|
/** Label for the deny button */
|
|
1874
1901
|
denyLabel?: string;
|
|
1902
|
+
/**
|
|
1903
|
+
* Color for the agent-authored reason line (the agent's per-call
|
|
1904
|
+
* justification, shown attributed below the summary when present).
|
|
1905
|
+
*/
|
|
1906
|
+
reasonColor?: string;
|
|
1907
|
+
/**
|
|
1908
|
+
* Label prefix for the agent-authored reason line.
|
|
1909
|
+
* Defaults to "Agent's stated reason:".
|
|
1910
|
+
*/
|
|
1911
|
+
reasonLabel?: string;
|
|
1875
1912
|
/**
|
|
1876
1913
|
* How the technical details (the tool's agent-facing description and the
|
|
1877
1914
|
* raw parameters JSON) are presented:
|
|
@@ -1897,6 +1934,12 @@ export type AgentWidgetApprovalConfig = {
|
|
|
1897
1934
|
description: string;
|
|
1898
1935
|
parameters?: unknown;
|
|
1899
1936
|
displayTitle?: string;
|
|
1937
|
+
/**
|
|
1938
|
+
* Agent-authored justification for this specific call, when the agent
|
|
1939
|
+
* provided one. It is the agent's own claim — if you fold it into the
|
|
1940
|
+
* summary, keep it attributed to the agent.
|
|
1941
|
+
*/
|
|
1942
|
+
reason?: string;
|
|
1900
1943
|
}) => string | undefined;
|
|
1901
1944
|
/**
|
|
1902
1945
|
* Custom handler for approval decisions.
|
|
@@ -3856,6 +3899,13 @@ export type AgentWidgetApproval = {
|
|
|
3856
3899
|
toolName: string;
|
|
3857
3900
|
toolType?: string;
|
|
3858
3901
|
description: string;
|
|
3902
|
+
/**
|
|
3903
|
+
* Agent-authored justification for this specific call (the agent's own
|
|
3904
|
+
* claim about its intent, extracted server-side from the reserved
|
|
3905
|
+
* `_approvalReason` parameter). Render it attributed to the agent and as
|
|
3906
|
+
* plain text — it is approver context, not a system statement.
|
|
3907
|
+
*/
|
|
3908
|
+
reason?: string;
|
|
3859
3909
|
parameters?: unknown;
|
|
3860
3910
|
resolvedAt?: number;
|
|
3861
3911
|
};
|
package/src/ui.scroll.test.ts
CHANGED
|
@@ -61,6 +61,27 @@ const installRafMock = () => {
|
|
|
61
61
|
};
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
+
const installResizeObserverMock = () => {
|
|
65
|
+
const triggers: Array<() => void> = [];
|
|
66
|
+
|
|
67
|
+
class ResizeObserverMock {
|
|
68
|
+
constructor(callback: (entries: unknown[], observer: unknown) => void) {
|
|
69
|
+
triggers.push(() => callback([], this));
|
|
70
|
+
}
|
|
71
|
+
observe() {}
|
|
72
|
+
unobserve() {}
|
|
73
|
+
disconnect() {}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
vi.stubGlobal("ResizeObserver", ResizeObserverMock);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
trigger() {
|
|
80
|
+
triggers.forEach((fire) => fire());
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
|
|
64
85
|
const installScrollMetrics = (
|
|
65
86
|
element: HTMLElement,
|
|
66
87
|
initial: { scrollHeight: number; clientHeight: number }
|
|
@@ -123,6 +144,22 @@ const emitStreamingMessage = (
|
|
|
123
144
|
});
|
|
124
145
|
};
|
|
125
146
|
|
|
147
|
+
const emitUserMessage = (
|
|
148
|
+
controller: ReturnType<typeof createAgentExperience>,
|
|
149
|
+
id: string,
|
|
150
|
+
content = "Hello"
|
|
151
|
+
) => {
|
|
152
|
+
controller.injectTestMessage({
|
|
153
|
+
type: "message",
|
|
154
|
+
message: {
|
|
155
|
+
id,
|
|
156
|
+
role: "user",
|
|
157
|
+
content,
|
|
158
|
+
createdAt: STREAM_CREATED_AT
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
};
|
|
162
|
+
|
|
126
163
|
const emitReasoningMessage = (
|
|
127
164
|
controller: ReturnType<typeof createAgentExperience>,
|
|
128
165
|
chunks: string[]
|
|
@@ -200,7 +237,12 @@ describe("createAgentExperience streaming scroll", () => {
|
|
|
200
237
|
|
|
201
238
|
afterEach(() => {
|
|
202
239
|
document.body.innerHTML = "";
|
|
240
|
+
// Widgets persist chat history to localStorage by default; without
|
|
241
|
+
// clearing it, a later widget restores an earlier test's messages at
|
|
242
|
+
// construction and "new message" assertions see stale ids.
|
|
243
|
+
localStorage.clear();
|
|
203
244
|
vi.restoreAllMocks();
|
|
245
|
+
vi.unstubAllGlobals();
|
|
204
246
|
});
|
|
205
247
|
|
|
206
248
|
it("stops auto-follow after a small upward scroll during streaming", () => {
|
|
@@ -744,4 +786,345 @@ describe("createAgentExperience streaming scroll", () => {
|
|
|
744
786
|
controller.destroy();
|
|
745
787
|
});
|
|
746
788
|
|
|
789
|
+
it("re-pins to the bottom when content grows without a render event", () => {
|
|
790
|
+
const raf = installRafMock();
|
|
791
|
+
const resize = installResizeObserverMock();
|
|
792
|
+
const mount = createMount();
|
|
793
|
+
const controller = createAgentExperience(mount, {
|
|
794
|
+
apiUrl: "https://api.example.com/chat",
|
|
795
|
+
launcher: { enabled: false }
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
|
|
799
|
+
const metrics = installScrollMetrics(scrollContainer!, {
|
|
800
|
+
scrollHeight: 1000,
|
|
801
|
+
clientHeight: 400
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
emitStreamingStatus(controller);
|
|
805
|
+
emitStreamingMessage(controller, "First chunk");
|
|
806
|
+
raf.flush();
|
|
807
|
+
|
|
808
|
+
expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
|
|
809
|
+
|
|
810
|
+
// Content grows with no render event (e.g. an image finishing loading
|
|
811
|
+
// mid-stream) — only the ResizeObserver sees it.
|
|
812
|
+
metrics.setScrollHeight(1200);
|
|
813
|
+
resize.trigger();
|
|
814
|
+
raf.flush();
|
|
815
|
+
|
|
816
|
+
expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
|
|
817
|
+
|
|
818
|
+
controller.destroy();
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
it("does not yank a paused reader when content grows without a render event", () => {
|
|
822
|
+
const raf = installRafMock();
|
|
823
|
+
const resize = installResizeObserverMock();
|
|
824
|
+
const mount = createMount();
|
|
825
|
+
const controller = createAgentExperience(mount, {
|
|
826
|
+
apiUrl: "https://api.example.com/chat",
|
|
827
|
+
launcher: { enabled: false }
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
|
|
831
|
+
const metrics = installScrollMetrics(scrollContainer!, {
|
|
832
|
+
scrollHeight: 1000,
|
|
833
|
+
clientHeight: 400
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
emitStreamingStatus(controller);
|
|
837
|
+
emitStreamingMessage(controller, "First chunk");
|
|
838
|
+
raf.flush();
|
|
839
|
+
|
|
840
|
+
scrollContainer!.dispatchEvent(new WheelEvent("wheel", { deltaY: -24 }));
|
|
841
|
+
metrics.setScrollTop(420);
|
|
842
|
+
|
|
843
|
+
metrics.setScrollHeight(1200);
|
|
844
|
+
resize.trigger();
|
|
845
|
+
raf.flush();
|
|
846
|
+
|
|
847
|
+
expect(metrics.getScrollTop()).toBe(420);
|
|
848
|
+
|
|
849
|
+
controller.destroy();
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
it("pauses auto-follow while the user selects transcript text during streaming", () => {
|
|
853
|
+
const raf = installRafMock();
|
|
854
|
+
const mount = createMount();
|
|
855
|
+
const controller = createAgentExperience(mount, {
|
|
856
|
+
apiUrl: "https://api.example.com/chat",
|
|
857
|
+
launcher: { enabled: false }
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
|
|
861
|
+
const metrics = installScrollMetrics(scrollContainer!, {
|
|
862
|
+
scrollHeight: 1000,
|
|
863
|
+
clientHeight: 400
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
emitStreamingStatus(controller);
|
|
867
|
+
emitStreamingMessage(controller, "First chunk");
|
|
868
|
+
raf.flush();
|
|
869
|
+
|
|
870
|
+
expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
|
|
871
|
+
|
|
872
|
+
// A selection forms inside the transcript (mouse drag or keyboard —
|
|
873
|
+
// both surface as selectionchange).
|
|
874
|
+
let currentSelection: Partial<Selection> | null = {
|
|
875
|
+
isCollapsed: false,
|
|
876
|
+
anchorNode: scrollContainer,
|
|
877
|
+
focusNode: scrollContainer
|
|
878
|
+
};
|
|
879
|
+
vi.spyOn(document, "getSelection").mockImplementation(
|
|
880
|
+
() => currentSelection as Selection | null
|
|
881
|
+
);
|
|
882
|
+
document.dispatchEvent(new Event("selectionchange"));
|
|
883
|
+
|
|
884
|
+
metrics.setScrollHeight(1100);
|
|
885
|
+
emitStreamingMessage(controller, "Second chunk");
|
|
886
|
+
raf.flush();
|
|
887
|
+
|
|
888
|
+
// Auto-follow paused: the selection isn't dragged out from under the user.
|
|
889
|
+
expect(metrics.getScrollTop()).toBe(600);
|
|
890
|
+
|
|
891
|
+
// Drag-selecting toward the bottom edge auto-scrolls down — that must
|
|
892
|
+
// not read as a resume gesture while the selection is still active.
|
|
893
|
+
metrics.setScrollTop(metrics.getBottomScrollTop());
|
|
894
|
+
scrollContainer!.dispatchEvent(new Event("scroll"));
|
|
895
|
+
const heldPosition = metrics.getScrollTop();
|
|
896
|
+
metrics.setScrollHeight(1200);
|
|
897
|
+
emitStreamingMessage(controller, "Third chunk");
|
|
898
|
+
raf.flush();
|
|
899
|
+
expect(metrics.getScrollTop()).toBe(heldPosition);
|
|
900
|
+
|
|
901
|
+
// Once the selection clears, scrolling down near the bottom resumes.
|
|
902
|
+
currentSelection = null;
|
|
903
|
+
metrics.setScrollTop(metrics.getBottomScrollTop());
|
|
904
|
+
scrollContainer!.dispatchEvent(new Event("scroll"));
|
|
905
|
+
metrics.setScrollHeight(1300);
|
|
906
|
+
emitStreamingMessage(controller, "Fourth chunk");
|
|
907
|
+
raf.flush();
|
|
908
|
+
expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
|
|
909
|
+
|
|
910
|
+
controller.destroy();
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
it("re-sticks to the bottom when the user sends a message after scrolling up", () => {
|
|
914
|
+
const raf = installRafMock();
|
|
915
|
+
const mount = createMount();
|
|
916
|
+
const controller = createAgentExperience(mount, {
|
|
917
|
+
apiUrl: "https://api.example.com/chat",
|
|
918
|
+
launcher: { enabled: false }
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
|
|
922
|
+
const metrics = installScrollMetrics(scrollContainer!, {
|
|
923
|
+
scrollHeight: 1000,
|
|
924
|
+
clientHeight: 400
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
emitStreamingStatus(controller);
|
|
928
|
+
emitStreamingMessage(controller, "First chunk");
|
|
929
|
+
raf.flush();
|
|
930
|
+
|
|
931
|
+
scrollContainer!.dispatchEvent(new WheelEvent("wheel", { deltaY: -24 }));
|
|
932
|
+
metrics.setScrollTop(300);
|
|
933
|
+
|
|
934
|
+
emitUserMessage(controller, "user-resend", "Follow-up question");
|
|
935
|
+
raf.flush();
|
|
936
|
+
|
|
937
|
+
expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
|
|
938
|
+
|
|
939
|
+
controller.destroy();
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
it("shows a count badge for messages that arrive while paused", () => {
|
|
943
|
+
const raf = installRafMock();
|
|
944
|
+
const mount = createMount();
|
|
945
|
+
const controller = createAgentExperience(mount, {
|
|
946
|
+
apiUrl: "https://api.example.com/chat",
|
|
947
|
+
launcher: { enabled: false }
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
|
|
951
|
+
const metrics = installScrollMetrics(scrollContainer!, {
|
|
952
|
+
scrollHeight: 1000,
|
|
953
|
+
clientHeight: 400
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
emitStreamingStatus(controller);
|
|
957
|
+
emitStreamingMessage(controller, "First chunk");
|
|
958
|
+
raf.flush();
|
|
959
|
+
|
|
960
|
+
scrollContainer!.dispatchEvent(new WheelEvent("wheel", { deltaY: -24 }));
|
|
961
|
+
metrics.setScrollTop(300);
|
|
962
|
+
|
|
963
|
+
controller.injectTestMessage({
|
|
964
|
+
type: "message",
|
|
965
|
+
message: {
|
|
966
|
+
id: "ast-while-paused",
|
|
967
|
+
role: "assistant",
|
|
968
|
+
content: "Another answer",
|
|
969
|
+
createdAt: STREAM_CREATED_AT
|
|
970
|
+
}
|
|
971
|
+
});
|
|
972
|
+
raf.flush();
|
|
973
|
+
|
|
974
|
+
const badge = mount.querySelector<HTMLElement>(
|
|
975
|
+
"[data-persona-scroll-to-bottom-count]"
|
|
976
|
+
);
|
|
977
|
+
expect(badge?.textContent).toBe("1");
|
|
978
|
+
expect(badge?.style.display).not.toBe("none");
|
|
979
|
+
|
|
980
|
+
// Jumping back to the latest clears the count.
|
|
981
|
+
getScrollToBottomButton(mount)!.click();
|
|
982
|
+
raf.flush();
|
|
983
|
+
expect(badge?.textContent).toBe("");
|
|
984
|
+
expect(badge?.style.display).toBe("none");
|
|
985
|
+
|
|
986
|
+
controller.destroy();
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
it("anchor-top mode pins the sent user message near the viewport top and never follows the stream", () => {
|
|
990
|
+
const raf = installRafMock();
|
|
991
|
+
const resize = installResizeObserverMock();
|
|
992
|
+
const mount = createMount();
|
|
993
|
+
const controller = createAgentExperience(mount, {
|
|
994
|
+
apiUrl: "https://api.example.com/chat",
|
|
995
|
+
launcher: { enabled: false },
|
|
996
|
+
features: {
|
|
997
|
+
scrollBehavior: { mode: "anchor-top", anchorTopOffset: 16 }
|
|
998
|
+
}
|
|
999
|
+
} as any);
|
|
1000
|
+
|
|
1001
|
+
const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
|
|
1002
|
+
const metrics = installScrollMetrics(scrollContainer!, {
|
|
1003
|
+
scrollHeight: 1000,
|
|
1004
|
+
clientHeight: 400
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
emitStreamingStatus(controller);
|
|
1008
|
+
emitUserMessage(controller, "user-anchor", "Long question");
|
|
1009
|
+
|
|
1010
|
+
// Give the rendered bubble layout geometry before the anchor rAF runs.
|
|
1011
|
+
const bubble = scrollContainer!.querySelector<HTMLElement>(
|
|
1012
|
+
'[data-message-id="user-anchor"]'
|
|
1013
|
+
);
|
|
1014
|
+
expect(bubble).not.toBeNull();
|
|
1015
|
+
Object.defineProperty(bubble!, "offsetTop", { value: 700 });
|
|
1016
|
+
|
|
1017
|
+
// Run just the anchor frame: it sizes the spacer and starts the scroll.
|
|
1018
|
+
raf.step(1);
|
|
1019
|
+
|
|
1020
|
+
// target = 700 - 16 = 684; spacer = 684 + 400 - 1000 = 84.
|
|
1021
|
+
const spacer = scrollContainer!.querySelector<HTMLElement>(
|
|
1022
|
+
"[data-persona-anchor-spacer]"
|
|
1023
|
+
);
|
|
1024
|
+
expect(spacer?.style.height).toBe("84px");
|
|
1025
|
+
|
|
1026
|
+
// The spacer's height is invisible to the mocked scroll metrics — apply
|
|
1027
|
+
// it manually so the anchor target is reachable, as in a real browser.
|
|
1028
|
+
metrics.setScrollHeight(1084);
|
|
1029
|
+
raf.flush();
|
|
1030
|
+
expect(metrics.getScrollTop()).toBe(684);
|
|
1031
|
+
|
|
1032
|
+
// Streaming below the anchor never moves the viewport.
|
|
1033
|
+
metrics.setScrollHeight(1150);
|
|
1034
|
+
emitStreamingMessage(controller, "Streaming response");
|
|
1035
|
+
raf.flush();
|
|
1036
|
+
expect(metrics.getScrollTop()).toBe(684);
|
|
1037
|
+
|
|
1038
|
+
// As real content grows, the spacer gives room back (shrink-only):
|
|
1039
|
+
// content grew from 1000 to 1150 - 84 = 1066, so spacer 84 - 66 = 18.
|
|
1040
|
+
resize.trigger();
|
|
1041
|
+
expect(spacer?.style.height).toBe("18px");
|
|
1042
|
+
|
|
1043
|
+
// Jumping to the latest abandons the anchor: the spacer is dropped so
|
|
1044
|
+
// "bottom" is the real end of content.
|
|
1045
|
+
getScrollToBottomButton(mount)!.click();
|
|
1046
|
+
raf.flush();
|
|
1047
|
+
expect(spacer?.style.height).toBe("0px");
|
|
1048
|
+
expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
|
|
1049
|
+
|
|
1050
|
+
controller.destroy();
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
it("jump-to-latest cancels an in-flight anchor scroll animation", () => {
|
|
1054
|
+
const raf = installRafMock();
|
|
1055
|
+
const mount = createMount();
|
|
1056
|
+
const controller = createAgentExperience(mount, {
|
|
1057
|
+
apiUrl: "https://api.example.com/chat",
|
|
1058
|
+
launcher: { enabled: false },
|
|
1059
|
+
features: {
|
|
1060
|
+
scrollBehavior: { mode: "anchor-top", anchorTopOffset: 16 }
|
|
1061
|
+
}
|
|
1062
|
+
} as any);
|
|
1063
|
+
|
|
1064
|
+
const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
|
|
1065
|
+
const metrics = installScrollMetrics(scrollContainer!, {
|
|
1066
|
+
scrollHeight: 1000,
|
|
1067
|
+
clientHeight: 400
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
emitStreamingStatus(controller);
|
|
1071
|
+
emitUserMessage(controller, "user-anchor-cancel", "Question");
|
|
1072
|
+
|
|
1073
|
+
// Anchor target (484) is reachable without a spacer, and differs from
|
|
1074
|
+
// the bottom (600) so a stale animation is distinguishable.
|
|
1075
|
+
const bubble = scrollContainer!.querySelector<HTMLElement>(
|
|
1076
|
+
'[data-message-id="user-anchor-cancel"]'
|
|
1077
|
+
);
|
|
1078
|
+
Object.defineProperty(bubble!, "offsetTop", { value: 500 });
|
|
1079
|
+
|
|
1080
|
+
raf.step(1); // anchor frame: starts the scroll animation
|
|
1081
|
+
raf.step(1); // first animation frame — animation now in flight
|
|
1082
|
+
|
|
1083
|
+
// Jump to the latest mid-animation: the stale anchor animation must not
|
|
1084
|
+
// keep easing scrollTop back toward the old target.
|
|
1085
|
+
getScrollToBottomButton(mount)!.click();
|
|
1086
|
+
raf.flush();
|
|
1087
|
+
|
|
1088
|
+
expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
|
|
1089
|
+
|
|
1090
|
+
controller.destroy();
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
it("scroll mode none never auto-scrolls during streaming", () => {
|
|
1094
|
+
const raf = installRafMock();
|
|
1095
|
+
const mount = createMount();
|
|
1096
|
+
const controller = createAgentExperience(mount, {
|
|
1097
|
+
apiUrl: "https://api.example.com/chat",
|
|
1098
|
+
launcher: { enabled: false },
|
|
1099
|
+
features: {
|
|
1100
|
+
scrollBehavior: { mode: "none" }
|
|
1101
|
+
}
|
|
1102
|
+
} as any);
|
|
1103
|
+
|
|
1104
|
+
const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
|
|
1105
|
+
const metrics = installScrollMetrics(scrollContainer!, {
|
|
1106
|
+
scrollHeight: 1000,
|
|
1107
|
+
clientHeight: 400
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
emitStreamingStatus(controller);
|
|
1111
|
+
emitStreamingMessage(controller, "First chunk");
|
|
1112
|
+
metrics.setScrollHeight(1200);
|
|
1113
|
+
emitStreamingMessage(controller, "Second chunk");
|
|
1114
|
+
raf.flush();
|
|
1115
|
+
|
|
1116
|
+
expect(metrics.getScrollTop()).toBe(0);
|
|
1117
|
+
|
|
1118
|
+
// The affordance is still available to get back to the latest content,
|
|
1119
|
+
// and messages that arrived while away from the bottom are counted.
|
|
1120
|
+
scrollContainer!.dispatchEvent(new Event("scroll"));
|
|
1121
|
+
expect(getScrollToBottomButton(mount)?.style.display).not.toBe("none");
|
|
1122
|
+
const badge = mount.querySelector<HTMLElement>(
|
|
1123
|
+
"[data-persona-scroll-to-bottom-count]"
|
|
1124
|
+
);
|
|
1125
|
+
expect(badge?.textContent).toBe("1");
|
|
1126
|
+
|
|
1127
|
+
controller.destroy();
|
|
1128
|
+
});
|
|
1129
|
+
|
|
747
1130
|
});
|