@runtypelabs/persona 4.14.0 → 4.16.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/README.md +4 -4
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-DveIaNx6.d.cts → types-BpWGm7_6.d.cts} +17 -0
- package/dist/animations/{types-DveIaNx6.d.ts → types-BpWGm7_6.d.ts} +17 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-QJ4LV2KW.js +1 -0
- package/dist/codegen.cjs +12 -11
- package/dist/codegen.js +14 -13
- package/dist/{context-mentions-ONG7A7M6.js → context-mentions-BTSTIBFC.js} +1 -1
- package/dist/context-mentions.cjs +11 -8
- package/dist/context-mentions.d.cts +520 -21
- package/dist/context-mentions.d.ts +520 -21
- package/dist/context-mentions.js +11 -8
- package/dist/index.cjs +69 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +595 -46
- package/dist/index.d.ts +595 -46
- package/dist/index.global.js +60 -57
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +69 -68
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +5 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +520 -21
- package/dist/smart-dom-reader.d.ts +520 -21
- package/dist/testing.cjs +3 -3
- package/dist/testing.d.cts +7 -0
- package/dist/testing.d.ts +7 -0
- package/dist/testing.js +3 -3
- package/dist/theme-editor-preview.cjs +67 -67
- package/dist/theme-editor-preview.d.cts +534 -22
- package/dist/theme-editor-preview.d.ts +534 -22
- package/dist/theme-editor-preview.js +57 -57
- package/dist/theme-editor.cjs +10 -10
- package/dist/theme-editor.d.cts +521 -23
- package/dist/theme-editor.d.ts +521 -23
- package/dist/theme-editor.js +10 -10
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +104 -3
- package/dist/theme-reference.d.ts +104 -3
- package/dist/theme-reference.js +1 -1
- package/dist/widget.css +1 -1
- package/package.json +1 -1
- package/src/ask-user-question-tool.ts +10 -7
- package/src/components/artifact-pane.test.ts +27 -0
- package/src/components/artifact-pane.ts +7 -3
- package/src/components/artifact-preview.ts +1 -1
- package/src/components/composer-parts.test.ts +103 -4
- package/src/components/composer-parts.ts +53 -15
- package/src/components/header-builder.ts +27 -2
- package/src/components/header-layouts.test.ts +56 -0
- package/src/components/header-layouts.ts +29 -9
- package/src/components/launcher-surface.test.ts +403 -0
- package/src/components/launcher.ts +297 -2
- package/src/components/messages.ts +5 -2
- package/src/components/panel.ts +92 -71
- package/src/components/pill-composer-builder.test.ts +18 -0
- package/src/components/suggestions.test.ts +273 -0
- package/src/components/suggestions.ts +528 -86
- package/src/components/welcome.test.ts +121 -0
- package/src/components/welcome.ts +305 -0
- package/src/components/widget-view.test.ts +5 -3
- package/src/components/widget-view.ts +18 -7
- package/src/defaults.ts +7 -5
- package/src/generated/runtype-openapi-contract.ts +17 -0
- package/src/index-core.ts +29 -2
- package/src/launcher-global.ts +11 -8
- package/src/plugins/types.ts +136 -3
- package/src/presets.ts +3 -3
- package/src/session.ts +10 -8
- package/src/styles/widget-styles.test.ts +382 -0
- package/src/styles/widget.css +778 -4
- package/src/suggest-replies-tool.test.ts +233 -2
- package/src/suggest-replies-tool.ts +115 -39
- package/src/testing/mock-stream.test.ts +19 -0
- package/src/testing/mock-stream.ts +20 -0
- package/src/theme-editor/preview-utils.test.ts +34 -0
- package/src/theme-editor/preview-utils.ts +77 -2
- package/src/theme-editor/sections.test.ts +33 -0
- package/src/theme-editor/sections.ts +83 -6
- package/src/theme-reference.ts +34 -5
- package/src/types/theme.ts +72 -3
- package/src/types.ts +312 -18
- package/src/ui.body-top-slot.test.ts +95 -0
- package/src/ui.content-width.test.ts +91 -0
- package/src/ui.detached-panel.test.ts +1 -1
- package/src/ui.docked.test.ts +7 -4
- package/src/ui.launcher-teaser.test.ts +162 -0
- package/src/ui.rebuild-composer.test.ts +491 -0
- package/src/ui.render-welcome.test.ts +505 -0
- package/src/ui.scroll-additive.test.ts +91 -21
- package/src/ui.scroll.test.ts +302 -14
- package/src/ui.suggest-replies.test.ts +1112 -1
- package/src/ui.ts +1008 -149
- package/src/ui.welcome.test.ts +504 -0
- package/src/utils/artifact-file.test.ts +1 -1
- package/src/utils/artifact-gate.test.ts +27 -0
- package/src/utils/artifact-gate.ts +9 -0
- package/src/utils/attachment-manager.test.ts +37 -0
- package/src/utils/attachment-manager.ts +15 -0
- package/src/utils/auto-follow.test.ts +23 -0
- package/src/utils/auto-follow.ts +11 -1
- package/src/utils/code-generators.test.ts +40 -0
- package/src/utils/code-generators.ts +22 -0
- package/src/utils/config-merge.test.ts +24 -0
- package/src/utils/config-merge.ts +2 -0
- package/src/utils/content-width.test.ts +37 -0
- package/src/utils/content-width.ts +17 -0
- package/src/utils/icons.test.ts +26 -0
- package/src/utils/icons.ts +8 -0
- package/src/utils/plugin-storage.test.ts +94 -0
- package/src/utils/plugin-storage.ts +98 -0
- package/src/utils/theme.test.ts +216 -0
- package/src/utils/tokens.ts +113 -3
- package/src/welcome.test.ts +213 -0
- package/src/welcome.ts +130 -0
- package/dist/chunk-MMUPR2JW.js +0 -1
|
@@ -262,6 +262,53 @@ describe("scrollBehavior.showActivityWhilePinned (Principle 8)", () => {
|
|
|
262
262
|
});
|
|
263
263
|
});
|
|
264
264
|
|
|
265
|
+
describe("anchor-top scroll-to-bottom affordance ignores spacer air", () => {
|
|
266
|
+
let raf: ReturnType<typeof installRafMock>;
|
|
267
|
+
beforeEach(() => {
|
|
268
|
+
raf = installRafMock();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("stays hidden while the whole transcript is in view above the spacer, then shows once content grows past the viewport", () => {
|
|
272
|
+
const mount = createMount();
|
|
273
|
+
const controller = createAgentExperience(
|
|
274
|
+
mount,
|
|
275
|
+
baseConfig({ features: { scrollBehavior: { mode: "anchor-top" } } })
|
|
276
|
+
);
|
|
277
|
+
const sc = getScrollContainer(mount);
|
|
278
|
+
// Short transcript (300px of content) in a 400px viewport.
|
|
279
|
+
const metrics = installScrollMetrics(sc, { scrollHeight: 300, clientHeight: 400 });
|
|
280
|
+
|
|
281
|
+
anchorUserTurn(controller);
|
|
282
|
+
// Give the anchored user bubble a real offset so the anchor computation
|
|
283
|
+
// produces a spacer (jsdom offsets are otherwise all 0).
|
|
284
|
+
const bubble = sc.querySelector<HTMLElement>('[data-message-id="u1"]')!;
|
|
285
|
+
Object.defineProperty(bubble, "offsetTop", { configurable: true, get: () => 200 });
|
|
286
|
+
raf.flush();
|
|
287
|
+
|
|
288
|
+
// Reflect the spacer in the mocked scroll metrics, as a browser would.
|
|
289
|
+
const spacer = sc.querySelector<HTMLElement>("[data-persona-anchor-spacer]")!;
|
|
290
|
+
const spacerHeight = parseFloat(spacer.style.height);
|
|
291
|
+
expect(spacerHeight).toBeGreaterThan(0);
|
|
292
|
+
metrics.setScrollHeight(300 + spacerHeight);
|
|
293
|
+
|
|
294
|
+
emitStreamingAssistant(controller, "a1", "Streaming reply");
|
|
295
|
+
|
|
296
|
+
// All 300px of real content fit in the [60, 460] viewport; the only
|
|
297
|
+
// scrollable space below is spacer air, so no jump affordance.
|
|
298
|
+
metrics.setScrollTop(60);
|
|
299
|
+
sc.dispatchEvent(new Event("scroll"));
|
|
300
|
+
expect(getScrollToBottomButton(mount).style.display).toBe("none");
|
|
301
|
+
|
|
302
|
+
// Content streams past the viewport bottom (300px -> 900px): now there is
|
|
303
|
+
// real content below the fold and the affordance appears.
|
|
304
|
+
metrics.setScrollHeight(900 + spacerHeight);
|
|
305
|
+
sc.dispatchEvent(new Event("scroll"));
|
|
306
|
+
expect(getScrollToBottomButton(mount).style.display).toBe("");
|
|
307
|
+
|
|
308
|
+
controller.destroy();
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
265
312
|
describe("scrollBehavior anchor-top no-anchor fallback", () => {
|
|
266
313
|
let raf: ReturnType<typeof installRafMock>;
|
|
267
314
|
beforeEach(() => {
|
|
@@ -451,10 +498,11 @@ describe("scrollBehavior.announce (Principle 15)", () => {
|
|
|
451
498
|
});
|
|
452
499
|
|
|
453
500
|
|
|
454
|
-
// Anchor-top
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
//
|
|
501
|
+
// Anchor-top pins the sent user message (market pattern) and hands off to
|
|
502
|
+
// the turn's first UNREAD attention-worthy block only when that block starts
|
|
503
|
+
// below the fold: tool calls and reasoning are chrome and never targeted, so
|
|
504
|
+
// a response that opens with them scrolls them past rather than stranding
|
|
505
|
+
// the answer. A block starting within view keeps the user message anchored.
|
|
458
506
|
describe("anchor-top first-unread positioning", () => {
|
|
459
507
|
beforeEach(() => {
|
|
460
508
|
installRafMock();
|
|
@@ -541,8 +589,13 @@ describe("anchor-top first-unread positioning", () => {
|
|
|
541
589
|
// Tool calls are chrome: they never move the anchor.
|
|
542
590
|
expect(metrics.getScrollTop()).toBe(284);
|
|
543
591
|
|
|
592
|
+
// The answer starts 400px below the pinned question — past the fold at a
|
|
593
|
+
// 400px viewport — so the anchor hands off to it. (The strandedness probe
|
|
594
|
+
// reads layout at render time, so the mocked offset needs one more
|
|
595
|
+
// render to be seen.)
|
|
544
596
|
emitStreamingAssistant(controller, "a1", "Good news — your order is on track.");
|
|
545
597
|
setBubbleOffsetTop(mount, "a1", 700);
|
|
598
|
+
emitStreamingAssistant(controller, "a1", "Good news — your order is on track!");
|
|
546
599
|
raf.flush();
|
|
547
600
|
expect(metrics.getScrollTop()).toBe(684);
|
|
548
601
|
|
|
@@ -564,7 +617,11 @@ describe("anchor-top first-unread positioning", () => {
|
|
|
564
617
|
raf.flush();
|
|
565
618
|
|
|
566
619
|
emitArtifactMessage(controller, "art-msg");
|
|
620
|
+
// 320px below the question: stranded past the 400 - 96 visibility floor,
|
|
621
|
+
// so the anchor hands off. The trailing tool render lets the probe see
|
|
622
|
+
// the mocked offset.
|
|
567
623
|
setBubbleOffsetTop(mount, "art-msg", 620);
|
|
624
|
+
emitToolMessage(controller, "t-after");
|
|
568
625
|
raf.flush();
|
|
569
626
|
|
|
570
627
|
expect(metrics.getScrollTop()).toBe(604);
|
|
@@ -581,21 +638,24 @@ describe("anchor-top first-unread positioning", () => {
|
|
|
581
638
|
});
|
|
582
639
|
|
|
583
640
|
startTurn(controller, mount, raf);
|
|
641
|
+
// First segment starts past the fold (delta 400 at a 400px viewport):
|
|
642
|
+
// the anchor hands off to it.
|
|
584
643
|
emitStreamingAssistant(controller, "a1", "First segment.");
|
|
585
|
-
setBubbleOffsetTop(mount, "a1",
|
|
644
|
+
setBubbleOffsetTop(mount, "a1", 700);
|
|
586
645
|
metrics.setScrollHeight(1400);
|
|
646
|
+
emitStreamingAssistant(controller, "a1", "First segment!");
|
|
587
647
|
raf.flush();
|
|
588
|
-
expect(metrics.getScrollTop()).toBe(
|
|
648
|
+
expect(metrics.getScrollTop()).toBe(684);
|
|
589
649
|
|
|
590
650
|
// Interleaved: tool, then a second text segment. The anchor stays on the
|
|
591
651
|
// first unread block — later blocks stream in below it.
|
|
592
652
|
emitToolMessage(controller, "t0");
|
|
593
653
|
emitStreamingAssistant(controller, "a2", "Second segment.");
|
|
594
|
-
setBubbleOffsetTop(mount, "a2",
|
|
654
|
+
setBubbleOffsetTop(mount, "a2", 1100);
|
|
595
655
|
metrics.setScrollHeight(1800);
|
|
596
656
|
raf.flush();
|
|
597
657
|
|
|
598
|
-
expect(metrics.getScrollTop()).toBe(
|
|
658
|
+
expect(metrics.getScrollTop()).toBe(684);
|
|
599
659
|
controller.destroy();
|
|
600
660
|
});
|
|
601
661
|
|
|
@@ -640,7 +700,9 @@ describe("anchor-top first-unread positioning", () => {
|
|
|
640
700
|
sc.dispatchEvent(new WheelEvent("wheel", { deltaY: -120, bubbles: true }));
|
|
641
701
|
sc.dispatchEvent(new Event("scroll"));
|
|
642
702
|
|
|
643
|
-
// A new send is an explicit "take me along"
|
|
703
|
+
// A new send is an explicit "take me along": the new user message
|
|
704
|
+
// anchors (600 - 16 = 584) and, with the reply starting only 200px below
|
|
705
|
+
// it — within view — stays anchored while the reply streams beneath.
|
|
644
706
|
emitUserMessage(controller, "u2", "And the second order?");
|
|
645
707
|
setBubbleOffsetTop(mount, "u2", 600);
|
|
646
708
|
metrics.setScrollHeight(1400);
|
|
@@ -649,9 +711,10 @@ describe("anchor-top first-unread positioning", () => {
|
|
|
649
711
|
emitStreamingAssistant(controller, "a2", "That one shipped Tuesday.");
|
|
650
712
|
setBubbleOffsetTop(mount, "a2", 800);
|
|
651
713
|
metrics.setScrollHeight(1600);
|
|
714
|
+
emitStreamingAssistant(controller, "a2", "That one shipped Tuesday!");
|
|
652
715
|
raf.flush();
|
|
653
716
|
|
|
654
|
-
expect(metrics.getScrollTop()).toBe(
|
|
717
|
+
expect(metrics.getScrollTop()).toBe(584);
|
|
655
718
|
controller.destroy();
|
|
656
719
|
});
|
|
657
720
|
});
|
|
@@ -694,16 +757,18 @@ describe("anchor-top first-unread — edge shapes", () => {
|
|
|
694
757
|
};
|
|
695
758
|
|
|
696
759
|
// KNOWN TRADEOFF: a chatty preamble ("Let me look that up…") is itself the
|
|
697
|
-
// first unread block, so
|
|
698
|
-
// pushes the real answer toward the fold.
|
|
699
|
-
//
|
|
700
|
-
// the case most likely to want revisiting.
|
|
701
|
-
|
|
760
|
+
// first unread block, so when a long question strands it the anchor lands
|
|
761
|
+
// there — and the tool run still pushes the real answer toward the fold.
|
|
762
|
+
// That is the policy behaving as specified — chronological, honest about
|
|
763
|
+
// what arrived first — but it is the case most likely to want revisiting.
|
|
764
|
+
// (A preamble starting within view never moves the anchor at all: the user
|
|
765
|
+
// message keeps it.)
|
|
766
|
+
it("anchors a stranded preamble, not the answer that follows the tool run", () => {
|
|
702
767
|
const raf = installRafMock();
|
|
703
768
|
const mount = createMount();
|
|
704
769
|
const controller = createAgentExperience(mount, anchorTopConfig());
|
|
705
770
|
const metrics = installScrollMetrics(getScrollContainer(mount), {
|
|
706
|
-
scrollHeight:
|
|
771
|
+
scrollHeight: 1200,
|
|
707
772
|
clientHeight: 400,
|
|
708
773
|
});
|
|
709
774
|
|
|
@@ -712,20 +777,21 @@ describe("anchor-top first-unread — edge shapes", () => {
|
|
|
712
777
|
setTop(mount, "u1", 300);
|
|
713
778
|
raf.flush();
|
|
714
779
|
|
|
780
|
+
// A tall question strands the preamble (delta 400 > 400 - 96): handoff.
|
|
715
781
|
emitStreamingAssistant(controller, "pre", "Sure — let me pull that up.");
|
|
716
|
-
setTop(mount, "pre",
|
|
717
|
-
|
|
782
|
+
setTop(mount, "pre", 700);
|
|
783
|
+
emitStreamingAssistant(controller, "pre", "Sure — let me pull that up!");
|
|
718
784
|
raf.flush();
|
|
719
|
-
expect(metrics.getScrollTop()).toBe(
|
|
785
|
+
expect(metrics.getScrollTop()).toBe(684);
|
|
720
786
|
|
|
721
787
|
for (let i = 0; i < 6; i += 1) emitTool(controller, `t${i}`);
|
|
722
788
|
emitStreamingAssistant(controller, "answer", "Good news — it is on track.");
|
|
723
|
-
setTop(mount, "answer",
|
|
789
|
+
setTop(mount, "answer", 1300);
|
|
724
790
|
metrics.setScrollHeight(1800);
|
|
725
791
|
raf.flush();
|
|
726
792
|
|
|
727
793
|
// Still parked on the preamble: the answer is NOT re-targeted.
|
|
728
|
-
expect(metrics.getScrollTop()).toBe(
|
|
794
|
+
expect(metrics.getScrollTop()).toBe(684);
|
|
729
795
|
controller.destroy();
|
|
730
796
|
});
|
|
731
797
|
|
|
@@ -805,9 +871,13 @@ describe("anchor-top first-unread — layout scroll is not reader intent", () =>
|
|
|
805
871
|
sc.dispatchEvent(new Event("scroll"));
|
|
806
872
|
raf.flush();
|
|
807
873
|
|
|
874
|
+
// Stranded answer (delta 400): the clamp/restore pair must not have been
|
|
875
|
+
// read as reader intent, so the handoff still fires. One more render so
|
|
876
|
+
// the strandedness probe sees the mocked offset.
|
|
808
877
|
emitStreamingAssistant(controller, "a1", "Good news — your order is on track.");
|
|
809
878
|
const answer = sc.querySelector<HTMLElement>('[data-message-id="a1"]');
|
|
810
879
|
Object.defineProperty(answer!, "offsetTop", { configurable: true, value: 700 });
|
|
880
|
+
emitStreamingAssistant(controller, "a1", "Good news — your order is on track!");
|
|
811
881
|
raf.flush();
|
|
812
882
|
|
|
813
883
|
expect(metrics.getScrollTop()).toBe(684);
|
package/src/ui.scroll.test.ts
CHANGED
|
@@ -991,7 +991,7 @@ describe("createAgentExperience streaming scroll", () => {
|
|
|
991
991
|
controller.destroy();
|
|
992
992
|
});
|
|
993
993
|
|
|
994
|
-
it("anchor-top
|
|
994
|
+
it("anchor-top keeps the sent user message pinned while the reply starts in view", () => {
|
|
995
995
|
const raf = installRafMock();
|
|
996
996
|
const resize = installResizeObserverMock();
|
|
997
997
|
const mount = createMount();
|
|
@@ -1022,21 +1022,27 @@ describe("createAgentExperience streaming scroll", () => {
|
|
|
1022
1022
|
// Run just the anchor frame: it sizes the spacer and starts the scroll.
|
|
1023
1023
|
raf.step(1);
|
|
1024
1024
|
|
|
1025
|
-
// target = 700 - 16 = 684; spacer = 684 + 400 - 1000 =
|
|
1025
|
+
// target = 700 - 16 = 684; spacer = 684 + 400 - 1000 + 24 slack = 108.
|
|
1026
|
+
// The 24px slack keeps the held position off the clamp boundary so
|
|
1027
|
+
// transient content dips during streaming can't bounce the viewport.
|
|
1026
1028
|
const spacer = scrollContainer!.querySelector<HTMLElement>(
|
|
1027
1029
|
"[data-persona-anchor-spacer]"
|
|
1028
1030
|
);
|
|
1029
|
-
expect(spacer?.style.height).toBe("
|
|
1031
|
+
expect(spacer?.style.height).toBe("108px");
|
|
1032
|
+
// Sized means visible: unsized it stays display:none so the body's flex
|
|
1033
|
+
// gap doesn't add phantom scrollable space below the transcript.
|
|
1034
|
+
expect(spacer?.style.display).toBe("");
|
|
1030
1035
|
|
|
1031
1036
|
// The spacer's height is invisible to the mocked scroll metrics: apply
|
|
1032
1037
|
// it manually so the anchor target is reachable, as in a real browser.
|
|
1033
|
-
metrics.setScrollHeight(
|
|
1038
|
+
metrics.setScrollHeight(1108);
|
|
1034
1039
|
raf.flush();
|
|
1035
1040
|
expect(metrics.getScrollTop()).toBe(684);
|
|
1036
1041
|
|
|
1037
|
-
// The
|
|
1038
|
-
//
|
|
1039
|
-
//
|
|
1042
|
+
// The reply's first block starts 200px below the pinned user message —
|
|
1043
|
+
// well within the 400px viewport — so the anchor STAYS on the user
|
|
1044
|
+
// message (market pattern: the sent message remains visible as the
|
|
1045
|
+
// header of the streaming reply). No handoff.
|
|
1040
1046
|
metrics.setScrollHeight(1284);
|
|
1041
1047
|
emitStreamingMessage(controller, "Streaming response");
|
|
1042
1048
|
const streamBubble = scrollContainer!.querySelector<HTMLElement>(
|
|
@@ -1044,30 +1050,204 @@ describe("createAgentExperience streaming scroll", () => {
|
|
|
1044
1050
|
);
|
|
1045
1051
|
expect(streamBubble).not.toBeNull();
|
|
1046
1052
|
Object.defineProperty(streamBubble!, "offsetTop", { value: 900 });
|
|
1047
|
-
raf.flush();
|
|
1048
|
-
expect(metrics.getScrollTop()).toBe(884);
|
|
1049
|
-
|
|
1050
|
-
// Continuing to stream below that anchor never moves the viewport again.
|
|
1051
1053
|
metrics.setScrollHeight(1324);
|
|
1052
1054
|
emitStreamingMessage(controller, "Streaming response, now longer");
|
|
1053
1055
|
raf.flush();
|
|
1054
|
-
expect(metrics.getScrollTop()).toBe(
|
|
1056
|
+
expect(metrics.getScrollTop()).toBe(684);
|
|
1055
1057
|
|
|
1056
1058
|
// As real content grows, the spacer gives room back (shrink-only):
|
|
1057
|
-
// content grew from
|
|
1059
|
+
// content grew from 1000 to 1324 - 108 = 1216, past the 108px reserve.
|
|
1058
1060
|
resize.trigger();
|
|
1059
|
-
expect(spacer?.style.height).toBe("
|
|
1061
|
+
expect(spacer?.style.height).toBe("0px");
|
|
1060
1062
|
|
|
1061
1063
|
// Jumping to the latest abandons the anchor: the spacer is dropped so
|
|
1062
1064
|
// "bottom" is the real end of content.
|
|
1063
1065
|
getScrollToBottomButton(mount)!.click();
|
|
1064
1066
|
raf.flush();
|
|
1065
1067
|
expect(spacer?.style.height).toBe("0px");
|
|
1068
|
+
expect(spacer?.style.display).toBe("none");
|
|
1066
1069
|
expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
|
|
1067
1070
|
|
|
1068
1071
|
controller.destroy();
|
|
1069
1072
|
});
|
|
1070
1073
|
|
|
1074
|
+
it("trims leftover anchor spacer air when the stream ends", () => {
|
|
1075
|
+
const raf = installRafMock();
|
|
1076
|
+
installResizeObserverMock();
|
|
1077
|
+
const mount = createMount();
|
|
1078
|
+
const controller = createAgentExperience(mount, {
|
|
1079
|
+
apiUrl: "https://api.example.com/chat",
|
|
1080
|
+
launcher: { enabled: false },
|
|
1081
|
+
features: {
|
|
1082
|
+
scrollBehavior: { mode: "anchor-top", anchorTopOffset: 16 }
|
|
1083
|
+
}
|
|
1084
|
+
} as any);
|
|
1085
|
+
|
|
1086
|
+
const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
|
|
1087
|
+
const metrics = installScrollMetrics(scrollContainer!, {
|
|
1088
|
+
scrollHeight: 1000,
|
|
1089
|
+
clientHeight: 400
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
emitStreamingStatus(controller);
|
|
1093
|
+
emitUserMessage(controller, "user-trim", "Long question");
|
|
1094
|
+
const bubble = scrollContainer!.querySelector<HTMLElement>(
|
|
1095
|
+
'[data-message-id="user-trim"]'
|
|
1096
|
+
);
|
|
1097
|
+
Object.defineProperty(bubble!, "offsetTop", { value: 700 });
|
|
1098
|
+
raf.step(1);
|
|
1099
|
+
const spacer = scrollContainer!.querySelector<HTMLElement>(
|
|
1100
|
+
"[data-persona-anchor-spacer]"
|
|
1101
|
+
);
|
|
1102
|
+
expect(spacer?.style.height).toBe("108px");
|
|
1103
|
+
metrics.setScrollHeight(1108);
|
|
1104
|
+
raf.flush();
|
|
1105
|
+
expect(metrics.getScrollTop()).toBe(684);
|
|
1106
|
+
|
|
1107
|
+
// Reply streams in-view (anchor stays on the user message); content grows
|
|
1108
|
+
// but the ResizeObserver drain never fires — the real-browser failure
|
|
1109
|
+
// shape where the growth-driven shrink stops before converging.
|
|
1110
|
+
emitStreamingMessage(controller, "Streaming response");
|
|
1111
|
+
metrics.setScrollHeight(1174);
|
|
1112
|
+
|
|
1113
|
+
// Stream end: the spacer trims to what the held position needs plus the
|
|
1114
|
+
// 24px anti-bounce slack (684 + 400 - 1066 + 24 = 42). The remaining
|
|
1115
|
+
// below-fold air equals the near-bottom threshold, so the
|
|
1116
|
+
// scroll-to-bottom arrow stays hidden.
|
|
1117
|
+
controller.injectTestMessage({ type: "status", status: "idle" });
|
|
1118
|
+
expect(spacer?.style.height).toBe("42px");
|
|
1119
|
+
|
|
1120
|
+
controller.destroy();
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
it("re-pins the anchored message when the hero welcome collapses mid-stream", async () => {
|
|
1124
|
+
// Hero dismissal fades via WAAPI while still occupying layout, then drops
|
|
1125
|
+
// display (and the roomier empty-state body gap) in one frame — after the
|
|
1126
|
+
// anchor ease already scrolled against the old layout. Without a re-pin
|
|
1127
|
+
// the browser clamps scrollTop by the removed height and visibly yanks
|
|
1128
|
+
// the pinned user message.
|
|
1129
|
+
const raf = installRafMock();
|
|
1130
|
+
installResizeObserverMock();
|
|
1131
|
+
let resolveFinished: () => void = () => {};
|
|
1132
|
+
const finished = new Promise<void>((resolve) => {
|
|
1133
|
+
resolveFinished = resolve;
|
|
1134
|
+
});
|
|
1135
|
+
const animate = vi.fn(
|
|
1136
|
+
() => ({ finished, cancel: vi.fn() }) as unknown as Animation
|
|
1137
|
+
);
|
|
1138
|
+
const originalAnimate = (Element.prototype as { animate?: unknown })
|
|
1139
|
+
.animate;
|
|
1140
|
+
(Element.prototype as { animate?: unknown }).animate = animate;
|
|
1141
|
+
|
|
1142
|
+
try {
|
|
1143
|
+
const mount = createMount();
|
|
1144
|
+
const controller = createAgentExperience(mount, {
|
|
1145
|
+
apiUrl: "https://api.example.com/chat",
|
|
1146
|
+
launcher: { enabled: false },
|
|
1147
|
+
welcome: { variant: "hero" },
|
|
1148
|
+
features: {
|
|
1149
|
+
scrollBehavior: { mode: "anchor-top", anchorTopOffset: 16 }
|
|
1150
|
+
}
|
|
1151
|
+
} as any);
|
|
1152
|
+
|
|
1153
|
+
const scrollContainer = mount.querySelector<HTMLElement>(
|
|
1154
|
+
"#persona-scroll-container"
|
|
1155
|
+
);
|
|
1156
|
+
const metrics = installScrollMetrics(scrollContainer!, {
|
|
1157
|
+
scrollHeight: 1000,
|
|
1158
|
+
clientHeight: 400
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
emitStreamingStatus(controller);
|
|
1162
|
+
emitUserMessage(controller, "user-hero", "Long question");
|
|
1163
|
+
|
|
1164
|
+
const bubble = scrollContainer!.querySelector<HTMLElement>(
|
|
1165
|
+
'[data-message-id="user-hero"]'
|
|
1166
|
+
);
|
|
1167
|
+
expect(bubble).not.toBeNull();
|
|
1168
|
+
Object.defineProperty(bubble!, "offsetTop", {
|
|
1169
|
+
value: 700,
|
|
1170
|
+
configurable: true
|
|
1171
|
+
});
|
|
1172
|
+
raf.step(1);
|
|
1173
|
+
const spacer = scrollContainer!.querySelector<HTMLElement>(
|
|
1174
|
+
"[data-persona-anchor-spacer]"
|
|
1175
|
+
);
|
|
1176
|
+
expect(spacer?.style.height).toBe("108px");
|
|
1177
|
+
metrics.setScrollHeight(1108);
|
|
1178
|
+
raf.flush();
|
|
1179
|
+
expect(metrics.getScrollTop()).toBe(684);
|
|
1180
|
+
|
|
1181
|
+
// The dismiss animation completes: 300px of hero layout leaves in one
|
|
1182
|
+
// frame, shifting the bubble up and shrinking scroll content. The
|
|
1183
|
+
// clamp alone would drop scrollTop to 808 - 400 = 408.
|
|
1184
|
+
Object.defineProperty(bubble!, "offsetTop", {
|
|
1185
|
+
value: 400,
|
|
1186
|
+
configurable: true
|
|
1187
|
+
});
|
|
1188
|
+
metrics.setScrollHeight(808);
|
|
1189
|
+
resolveFinished();
|
|
1190
|
+
await finished;
|
|
1191
|
+
await Promise.resolve();
|
|
1192
|
+
|
|
1193
|
+
// Re-pinned: the bubble rests at the anchor offset again (400 - 16)
|
|
1194
|
+
// with the spacer recomputed for the collapsed layout, not left stale.
|
|
1195
|
+
expect(metrics.getScrollTop()).toBe(384);
|
|
1196
|
+
expect(spacer?.style.height).toBe("108px");
|
|
1197
|
+
|
|
1198
|
+
controller.destroy();
|
|
1199
|
+
} finally {
|
|
1200
|
+
if (originalAnimate === undefined) {
|
|
1201
|
+
delete (Element.prototype as { animate?: unknown }).animate;
|
|
1202
|
+
} else {
|
|
1203
|
+
(Element.prototype as { animate?: unknown }).animate = originalAnimate;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
it("anchor-top hands off to the first unread block only when it starts below the fold", () => {
|
|
1209
|
+
const raf = installRafMock();
|
|
1210
|
+
installResizeObserverMock();
|
|
1211
|
+
const mount = createMount();
|
|
1212
|
+
const controller = createAgentExperience(mount, {
|
|
1213
|
+
apiUrl: "https://api.example.com/chat",
|
|
1214
|
+
launcher: { enabled: false },
|
|
1215
|
+
features: {
|
|
1216
|
+
scrollBehavior: { mode: "anchor-top", anchorTopOffset: 16 }
|
|
1217
|
+
}
|
|
1218
|
+
} as any);
|
|
1219
|
+
|
|
1220
|
+
const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
|
|
1221
|
+
const metrics = installScrollMetrics(scrollContainer!, {
|
|
1222
|
+
scrollHeight: 1600,
|
|
1223
|
+
clientHeight: 400
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
emitStreamingStatus(controller);
|
|
1227
|
+
emitUserMessage(controller, "user-strand", "Very long question");
|
|
1228
|
+
const bubble = scrollContainer!.querySelector<HTMLElement>(
|
|
1229
|
+
'[data-message-id="user-strand"]'
|
|
1230
|
+
);
|
|
1231
|
+
Object.defineProperty(bubble!, "offsetTop", { value: 100 });
|
|
1232
|
+
raf.flush();
|
|
1233
|
+
expect(metrics.getScrollTop()).toBe(84);
|
|
1234
|
+
|
|
1235
|
+
// The reply's first block starts 500px below the user message: with the
|
|
1236
|
+
// user message pinned it would sit past the fold (16 + 500 > 400 - 96),
|
|
1237
|
+
// so the anchor hands off to the block: target = 600 - 16 = 584. This is
|
|
1238
|
+
// the stranded case the handoff exists for (tall send / tool prelude).
|
|
1239
|
+
emitStreamingMessage(controller, "Streaming response");
|
|
1240
|
+
const streamBubble = scrollContainer!.querySelector<HTMLElement>(
|
|
1241
|
+
`[data-message-id="${STREAM_MESSAGE_ID}"]`
|
|
1242
|
+
);
|
|
1243
|
+
Object.defineProperty(streamBubble!, "offsetTop", { value: 600 });
|
|
1244
|
+
emitStreamingMessage(controller, "Streaming response, longer");
|
|
1245
|
+
raf.flush();
|
|
1246
|
+
expect(metrics.getScrollTop()).toBe(584);
|
|
1247
|
+
|
|
1248
|
+
controller.destroy();
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1071
1251
|
it("jump-to-latest cancels an in-flight anchor scroll animation", () => {
|
|
1072
1252
|
const raf = installRafMock();
|
|
1073
1253
|
const mount = createMount();
|
|
@@ -1145,4 +1325,112 @@ describe("createAgentExperience streaming scroll", () => {
|
|
|
1145
1325
|
controller.destroy();
|
|
1146
1326
|
});
|
|
1147
1327
|
|
|
1328
|
+
it("keeps a welcome-only transcript at the top on open with no jump affordance", () => {
|
|
1329
|
+
const raf = installRafMock();
|
|
1330
|
+
const mount = createMount();
|
|
1331
|
+
const controller = createAgentExperience(mount, {
|
|
1332
|
+
apiUrl: "https://api.example.com/chat",
|
|
1333
|
+
persistState: false,
|
|
1334
|
+
launcher: { enabled: true },
|
|
1335
|
+
welcome: { title: "How can we help?" }
|
|
1336
|
+
} as any);
|
|
1337
|
+
|
|
1338
|
+
const scrollContainer = mount.querySelector<HTMLElement>(
|
|
1339
|
+
"#persona-scroll-container"
|
|
1340
|
+
);
|
|
1341
|
+
// Welcome content slightly taller than the viewport: pre-fix, opening
|
|
1342
|
+
// scrolled to the bottom (clipping the greeting) and the >24px overflow
|
|
1343
|
+
// surfaced the scroll-to-bottom arrow with no messages to jump to.
|
|
1344
|
+
const metrics = installScrollMetrics(scrollContainer!, {
|
|
1345
|
+
scrollHeight: 430,
|
|
1346
|
+
clientHeight: 400
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
controller.open();
|
|
1350
|
+
raf.flush();
|
|
1351
|
+
|
|
1352
|
+
expect(metrics.getScrollTop()).toBe(0);
|
|
1353
|
+
const button = getScrollToBottomButton(mount);
|
|
1354
|
+
expect(!button || button.style.display === "none").toBe(true);
|
|
1355
|
+
|
|
1356
|
+
// A real message restores the historical open behavior: land on latest.
|
|
1357
|
+
// Flush first so the inject-time anchor animation settles before reopen.
|
|
1358
|
+
controller.injectUserMessage({ content: "hi" });
|
|
1359
|
+
raf.flush();
|
|
1360
|
+
metrics.setScrollHeight(800);
|
|
1361
|
+
controller.close();
|
|
1362
|
+
controller.open();
|
|
1363
|
+
raf.flush();
|
|
1364
|
+
expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
|
|
1365
|
+
|
|
1366
|
+
controller.destroy();
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
it("stamps the on-scroll scrollbar policy and reveals only on reader input", () => {
|
|
1370
|
+
vi.useFakeTimers();
|
|
1371
|
+
installResizeObserverMock();
|
|
1372
|
+
const mount = createMount();
|
|
1373
|
+
const controller = createAgentExperience(mount, {
|
|
1374
|
+
apiUrl: "https://api.example.com/chat",
|
|
1375
|
+
launcher: { enabled: false }
|
|
1376
|
+
} as any);
|
|
1377
|
+
|
|
1378
|
+
expect(mount.getAttribute("data-persona-scrollbar")).toBe("on-scroll");
|
|
1379
|
+
const scrollContainer = mount.querySelector<HTMLElement>(
|
|
1380
|
+
"#persona-scroll-container"
|
|
1381
|
+
)!;
|
|
1382
|
+
// At rest, and during widget-driven scrolling (which never routes through
|
|
1383
|
+
// markReaderEngaged), the bar stays hidden.
|
|
1384
|
+
expect(scrollContainer.hasAttribute("data-persona-scrollbar-visible")).toBe(
|
|
1385
|
+
false
|
|
1386
|
+
);
|
|
1387
|
+
|
|
1388
|
+
// A wheel tick is reader input: reveal, then hide after the idle delay
|
|
1389
|
+
// because the reader sits at the resting position (jsdom measures the
|
|
1390
|
+
// body as scrolled to the bottom).
|
|
1391
|
+
scrollContainer.dispatchEvent(
|
|
1392
|
+
new WheelEvent("wheel", { deltaY: 5, bubbles: true })
|
|
1393
|
+
);
|
|
1394
|
+
expect(scrollContainer.hasAttribute("data-persona-scrollbar-visible")).toBe(
|
|
1395
|
+
true
|
|
1396
|
+
);
|
|
1397
|
+
vi.advanceTimersByTime(1000);
|
|
1398
|
+
expect(scrollContainer.hasAttribute("data-persona-scrollbar-visible")).toBe(
|
|
1399
|
+
false
|
|
1400
|
+
);
|
|
1401
|
+
|
|
1402
|
+
// Away from the resting position the bar survives the idle delay: it is
|
|
1403
|
+
// the positional affordance while the reader is scrolled up.
|
|
1404
|
+
const metrics = installScrollMetrics(scrollContainer, {
|
|
1405
|
+
scrollHeight: 1000,
|
|
1406
|
+
clientHeight: 400
|
|
1407
|
+
});
|
|
1408
|
+
metrics.setScrollTop(100);
|
|
1409
|
+
scrollContainer.dispatchEvent(
|
|
1410
|
+
new WheelEvent("wheel", { deltaY: -5, bubbles: true })
|
|
1411
|
+
);
|
|
1412
|
+
vi.advanceTimersByTime(1000);
|
|
1413
|
+
expect(scrollContainer.hasAttribute("data-persona-scrollbar-visible")).toBe(
|
|
1414
|
+
true
|
|
1415
|
+
);
|
|
1416
|
+
|
|
1417
|
+
// Switching policy clears the reveal state and restamps the root.
|
|
1418
|
+
controller.update({
|
|
1419
|
+
features: { scrollBehavior: { scrollbar: "hidden" } }
|
|
1420
|
+
});
|
|
1421
|
+
expect(mount.getAttribute("data-persona-scrollbar")).toBe("hidden");
|
|
1422
|
+
expect(scrollContainer.hasAttribute("data-persona-scrollbar-visible")).toBe(
|
|
1423
|
+
false
|
|
1424
|
+
);
|
|
1425
|
+
scrollContainer.dispatchEvent(
|
|
1426
|
+
new WheelEvent("wheel", { deltaY: 5, bubbles: true })
|
|
1427
|
+
);
|
|
1428
|
+
expect(scrollContainer.hasAttribute("data-persona-scrollbar-visible")).toBe(
|
|
1429
|
+
false
|
|
1430
|
+
);
|
|
1431
|
+
|
|
1432
|
+
controller.destroy();
|
|
1433
|
+
vi.useRealTimers();
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1148
1436
|
});
|