@runtypelabs/persona 4.12.0 → 4.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/context-mentions.d.cts +98 -6
- package/dist/context-mentions.d.ts +98 -6
- package/dist/index.cjs +64 -64
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +183 -8
- package/dist/index.d.ts +183 -8
- package/dist/index.global.js +54 -54
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +67 -67
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +1 -1
- package/dist/launcher.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +98 -6
- package/dist/smart-dom-reader.d.ts +98 -6
- package/dist/theme-editor-preview.cjs +66 -66
- package/dist/theme-editor-preview.d.cts +100 -6
- package/dist/theme-editor-preview.d.ts +100 -6
- package/dist/theme-editor-preview.js +75 -75
- package/dist/theme-editor.cjs +10 -10
- package/dist/theme-editor.d.cts +134 -7
- package/dist/theme-editor.d.ts +134 -7
- package/dist/theme-editor.js +10 -10
- package/dist/theme-reference.cjs +1 -1
- package/dist/widget.css +1 -1
- package/package.json +1 -1
- package/src/artifacts-session.test.ts +106 -0
- package/src/client.test.ts +2 -2
- package/src/client.ts +4 -1
- package/src/components/artifact-card.ts +13 -2
- package/src/components/artifact-inline.ts +11 -2
- package/src/components/composer-builder.test.ts +2 -0
- package/src/components/composer-builder.ts +1 -1
- package/src/components/composer-parts.test.ts +2 -0
- package/src/components/composer-parts.ts +1 -1
- package/src/components/message-bubble.test.ts +7 -1
- package/src/components/message-bubble.ts +11 -8
- package/src/index-core.ts +34 -0
- package/src/session.ts +45 -4
- package/src/styles/widget.css +10 -1
- package/src/theme-editor/index.ts +5 -0
- package/src/theme-editor/presets.test.ts +43 -0
- package/src/theme-editor/presets.ts +101 -87
- package/src/theme-editor/sections.test.ts +39 -7
- package/src/theme-editor/sections.ts +60 -8
- package/src/theme-editor/state-preferences.test.ts +15 -0
- package/src/theme-editor/state.ts +46 -0
- package/src/theme-editor/types.ts +15 -1
- package/src/theme-editor/webmcp/tools.test.ts +13 -0
- package/src/theme-editor/webmcp/tools.ts +5 -0
- package/src/types.ts +139 -9
- package/src/ui.artifact-pane-gating.test.ts +5 -5
- package/src/ui.config-preferences.test.ts +166 -0
- package/src/ui.scroll-additive.test.ts +365 -0
- package/src/ui.scroll.test.ts +19 -6
- package/src/ui.ts +158 -53
- package/src/ui.typing-indicator.test.ts +135 -0
- package/src/utils/artifact-display.test.ts +180 -16
- package/src/utils/artifact-display.ts +155 -14
- package/src/utils/auto-follow.test.ts +38 -0
- package/src/utils/auto-follow.ts +35 -0
- package/src/utils/config-merge.test.ts +60 -0
- package/src/utils/config-merge.ts +6 -1
- package/src/utils/feature-preferences.test.ts +326 -0
- package/src/utils/feature-preferences.ts +498 -0
- package/src/utils/theme.test.ts +92 -0
- package/src/utils/tokens.ts +7 -7
|
@@ -449,3 +449,368 @@ describe("scrollBehavior.announce (Principle 15)", () => {
|
|
|
449
449
|
controller.destroy();
|
|
450
450
|
});
|
|
451
451
|
});
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
// Anchor-top parks on the first UNREAD attention-worthy block of a turn:
|
|
455
|
+
// tool calls and reasoning are chrome and never targeted, so a response that
|
|
456
|
+
// opens with them scrolls them past rather than stranding the answer below
|
|
457
|
+
// the fold.
|
|
458
|
+
describe("anchor-top first-unread positioning", () => {
|
|
459
|
+
beforeEach(() => {
|
|
460
|
+
installRafMock();
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const anchorTopConfig = () =>
|
|
464
|
+
baseConfig({
|
|
465
|
+
features: { scrollBehavior: { mode: "anchor-top", anchorTopOffset: 16 } },
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
const emitToolMessage = (
|
|
469
|
+
controller: ReturnType<typeof createAgentExperience>,
|
|
470
|
+
id: string
|
|
471
|
+
) => {
|
|
472
|
+
controller.injectTestMessage({
|
|
473
|
+
type: "message",
|
|
474
|
+
message: {
|
|
475
|
+
id,
|
|
476
|
+
role: "assistant",
|
|
477
|
+
content: "",
|
|
478
|
+
createdAt: CREATED_AT,
|
|
479
|
+
variant: "tool",
|
|
480
|
+
toolCall: { id, name: "search", status: "complete" },
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
// An artifact / component block: substance lives in rawContent, `content`
|
|
486
|
+
// is empty. Testing text alone would silently skip these.
|
|
487
|
+
const emitArtifactMessage = (
|
|
488
|
+
controller: ReturnType<typeof createAgentExperience>,
|
|
489
|
+
id: string
|
|
490
|
+
) => {
|
|
491
|
+
controller.injectTestMessage({
|
|
492
|
+
type: "message",
|
|
493
|
+
message: {
|
|
494
|
+
id,
|
|
495
|
+
role: "assistant",
|
|
496
|
+
content: "",
|
|
497
|
+
createdAt: CREATED_AT,
|
|
498
|
+
rawContent: JSON.stringify({
|
|
499
|
+
text: "",
|
|
500
|
+
component: "PersonaArtifactInline",
|
|
501
|
+
props: { artifactId: "art-1", title: "Report" },
|
|
502
|
+
}),
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
const setBubbleOffsetTop = (mount: HTMLElement, id: string, value: number) => {
|
|
508
|
+
const bubble = getScrollContainer(mount).querySelector<HTMLElement>(
|
|
509
|
+
`[data-message-id="${id}"]`
|
|
510
|
+
);
|
|
511
|
+
expect(bubble).not.toBeNull();
|
|
512
|
+
Object.defineProperty(bubble!, "offsetTop", { configurable: true, value });
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const startTurn = (
|
|
516
|
+
controller: ReturnType<typeof createAgentExperience>,
|
|
517
|
+
mount: HTMLElement,
|
|
518
|
+
raf: ReturnType<typeof installRafMock>
|
|
519
|
+
) => {
|
|
520
|
+
emitAssistantMessage(controller, "seed", "Earlier reply");
|
|
521
|
+
emitUserMessage(controller, "u1", "Where is my order?");
|
|
522
|
+
setBubbleOffsetTop(mount, "u1", 300);
|
|
523
|
+
raf.flush();
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
it("skips a run of tool calls and anchors the answer", () => {
|
|
527
|
+
const raf = installRafMock();
|
|
528
|
+
const mount = createMount();
|
|
529
|
+
const controller = createAgentExperience(mount, anchorTopConfig());
|
|
530
|
+
const metrics = installScrollMetrics(getScrollContainer(mount), {
|
|
531
|
+
scrollHeight: 1000,
|
|
532
|
+
clientHeight: 400,
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
startTurn(controller, mount, raf);
|
|
536
|
+
expect(metrics.getScrollTop()).toBe(284); // parked on the sent message
|
|
537
|
+
|
|
538
|
+
for (let i = 0; i < 6; i += 1) emitToolMessage(controller, `t${i}`);
|
|
539
|
+
metrics.setScrollHeight(1500);
|
|
540
|
+
raf.flush();
|
|
541
|
+
// Tool calls are chrome: they never move the anchor.
|
|
542
|
+
expect(metrics.getScrollTop()).toBe(284);
|
|
543
|
+
|
|
544
|
+
emitStreamingAssistant(controller, "a1", "Good news — your order is on track.");
|
|
545
|
+
setBubbleOffsetTop(mount, "a1", 700);
|
|
546
|
+
raf.flush();
|
|
547
|
+
expect(metrics.getScrollTop()).toBe(684);
|
|
548
|
+
|
|
549
|
+
controller.destroy();
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("anchors an artifact block, whose content lives in rawContent", () => {
|
|
553
|
+
const raf = installRafMock();
|
|
554
|
+
const mount = createMount();
|
|
555
|
+
const controller = createAgentExperience(mount, anchorTopConfig());
|
|
556
|
+
const metrics = installScrollMetrics(getScrollContainer(mount), {
|
|
557
|
+
scrollHeight: 1000,
|
|
558
|
+
clientHeight: 400,
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
startTurn(controller, mount, raf);
|
|
562
|
+
for (let i = 0; i < 3; i += 1) emitToolMessage(controller, `t${i}`);
|
|
563
|
+
metrics.setScrollHeight(1500);
|
|
564
|
+
raf.flush();
|
|
565
|
+
|
|
566
|
+
emitArtifactMessage(controller, "art-msg");
|
|
567
|
+
setBubbleOffsetTop(mount, "art-msg", 620);
|
|
568
|
+
raf.flush();
|
|
569
|
+
|
|
570
|
+
expect(metrics.getScrollTop()).toBe(604);
|
|
571
|
+
controller.destroy();
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("holds the FIRST unread block when more text streams in after it", () => {
|
|
575
|
+
const raf = installRafMock();
|
|
576
|
+
const mount = createMount();
|
|
577
|
+
const controller = createAgentExperience(mount, anchorTopConfig());
|
|
578
|
+
const metrics = installScrollMetrics(getScrollContainer(mount), {
|
|
579
|
+
scrollHeight: 1000,
|
|
580
|
+
clientHeight: 400,
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
startTurn(controller, mount, raf);
|
|
584
|
+
emitStreamingAssistant(controller, "a1", "First segment.");
|
|
585
|
+
setBubbleOffsetTop(mount, "a1", 500);
|
|
586
|
+
metrics.setScrollHeight(1400);
|
|
587
|
+
raf.flush();
|
|
588
|
+
expect(metrics.getScrollTop()).toBe(484);
|
|
589
|
+
|
|
590
|
+
// Interleaved: tool, then a second text segment. The anchor stays on the
|
|
591
|
+
// first unread block — later blocks stream in below it.
|
|
592
|
+
emitToolMessage(controller, "t0");
|
|
593
|
+
emitStreamingAssistant(controller, "a2", "Second segment.");
|
|
594
|
+
setBubbleOffsetTop(mount, "a2", 900);
|
|
595
|
+
metrics.setScrollHeight(1800);
|
|
596
|
+
raf.flush();
|
|
597
|
+
|
|
598
|
+
expect(metrics.getScrollTop()).toBe(484);
|
|
599
|
+
controller.destroy();
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("stands down once the reader scrolls", () => {
|
|
603
|
+
const raf = installRafMock();
|
|
604
|
+
const mount = createMount();
|
|
605
|
+
const controller = createAgentExperience(mount, anchorTopConfig());
|
|
606
|
+
const sc = getScrollContainer(mount);
|
|
607
|
+
const metrics = installScrollMetrics(sc, { scrollHeight: 1000, clientHeight: 400 });
|
|
608
|
+
|
|
609
|
+
startTurn(controller, mount, raf);
|
|
610
|
+
for (let i = 0; i < 6; i += 1) emitToolMessage(controller, `t${i}`);
|
|
611
|
+
metrics.setScrollHeight(1500);
|
|
612
|
+
raf.flush();
|
|
613
|
+
|
|
614
|
+
// The reader takes over: wheels down to watch the tools run. Engagement
|
|
615
|
+
// is keyed on real input, NOT on the scroll event (streaming content
|
|
616
|
+
// clamps and restores scrollTop on its own — see the regression test
|
|
617
|
+
// below), so the wheel is what must register here.
|
|
618
|
+
metrics.setScrollTop(520);
|
|
619
|
+
sc.dispatchEvent(new WheelEvent("wheel", { deltaY: -120, bubbles: true }));
|
|
620
|
+
sc.dispatchEvent(new Event("scroll"));
|
|
621
|
+
|
|
622
|
+
emitStreamingAssistant(controller, "a1", "Good news — your order is on track.");
|
|
623
|
+
setBubbleOffsetTop(mount, "a1", 700);
|
|
624
|
+
raf.flush();
|
|
625
|
+
|
|
626
|
+
// Their position wins: no re-anchor under them.
|
|
627
|
+
expect(metrics.getScrollTop()).toBe(520);
|
|
628
|
+
controller.destroy();
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it("re-arms on the next send after the reader scrolled away", () => {
|
|
632
|
+
const raf = installRafMock();
|
|
633
|
+
const mount = createMount();
|
|
634
|
+
const controller = createAgentExperience(mount, anchorTopConfig());
|
|
635
|
+
const sc = getScrollContainer(mount);
|
|
636
|
+
const metrics = installScrollMetrics(sc, { scrollHeight: 1000, clientHeight: 400 });
|
|
637
|
+
|
|
638
|
+
startTurn(controller, mount, raf);
|
|
639
|
+
metrics.setScrollTop(520);
|
|
640
|
+
sc.dispatchEvent(new WheelEvent("wheel", { deltaY: -120, bubbles: true }));
|
|
641
|
+
sc.dispatchEvent(new Event("scroll"));
|
|
642
|
+
|
|
643
|
+
// A new send is an explicit "take me along".
|
|
644
|
+
emitUserMessage(controller, "u2", "And the second order?");
|
|
645
|
+
setBubbleOffsetTop(mount, "u2", 600);
|
|
646
|
+
metrics.setScrollHeight(1400);
|
|
647
|
+
raf.flush();
|
|
648
|
+
|
|
649
|
+
emitStreamingAssistant(controller, "a2", "That one shipped Tuesday.");
|
|
650
|
+
setBubbleOffsetTop(mount, "a2", 800);
|
|
651
|
+
metrics.setScrollHeight(1600);
|
|
652
|
+
raf.flush();
|
|
653
|
+
|
|
654
|
+
expect(metrics.getScrollTop()).toBe(784);
|
|
655
|
+
controller.destroy();
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// Two shapes the browser testbed covers but that are worth pinning down here,
|
|
660
|
+
// since they are where the policy's judgement calls actually bite.
|
|
661
|
+
describe("anchor-top first-unread — edge shapes", () => {
|
|
662
|
+
beforeEach(() => {
|
|
663
|
+
installRafMock();
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
const anchorTopConfig = () =>
|
|
667
|
+
baseConfig({
|
|
668
|
+
features: { scrollBehavior: { mode: "anchor-top", anchorTopOffset: 16 } },
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
const emitTool = (
|
|
672
|
+
controller: ReturnType<typeof createAgentExperience>,
|
|
673
|
+
id: string
|
|
674
|
+
) => {
|
|
675
|
+
controller.injectTestMessage({
|
|
676
|
+
type: "message",
|
|
677
|
+
message: {
|
|
678
|
+
id,
|
|
679
|
+
role: "assistant",
|
|
680
|
+
content: "",
|
|
681
|
+
createdAt: CREATED_AT,
|
|
682
|
+
variant: "tool",
|
|
683
|
+
toolCall: { id, name: "search", status: "complete" },
|
|
684
|
+
},
|
|
685
|
+
});
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
const setTop = (mount: HTMLElement, id: string, value: number) => {
|
|
689
|
+
const bubble = getScrollContainer(mount).querySelector<HTMLElement>(
|
|
690
|
+
`[data-message-id="${id}"]`
|
|
691
|
+
);
|
|
692
|
+
expect(bubble).not.toBeNull();
|
|
693
|
+
Object.defineProperty(bubble!, "offsetTop", { configurable: true, value });
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
// KNOWN TRADEOFF: a chatty preamble ("Let me look that up…") is itself the
|
|
697
|
+
// first unread block, so the anchor lands there and the tool run still
|
|
698
|
+
// pushes the real answer toward the fold. That is the policy behaving as
|
|
699
|
+
// specified — chronological, honest about what arrived first — but it is
|
|
700
|
+
// the case most likely to want revisiting.
|
|
701
|
+
it("anchors a preamble, not the answer that follows the tool run", () => {
|
|
702
|
+
const raf = installRafMock();
|
|
703
|
+
const mount = createMount();
|
|
704
|
+
const controller = createAgentExperience(mount, anchorTopConfig());
|
|
705
|
+
const metrics = installScrollMetrics(getScrollContainer(mount), {
|
|
706
|
+
scrollHeight: 1000,
|
|
707
|
+
clientHeight: 400,
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
emitAssistantMessage(controller, "seed", "Earlier reply");
|
|
711
|
+
emitUserMessage(controller, "u1", "Look into my order");
|
|
712
|
+
setTop(mount, "u1", 300);
|
|
713
|
+
raf.flush();
|
|
714
|
+
|
|
715
|
+
emitStreamingAssistant(controller, "pre", "Sure — let me pull that up.");
|
|
716
|
+
setTop(mount, "pre", 420);
|
|
717
|
+
metrics.setScrollHeight(1200);
|
|
718
|
+
raf.flush();
|
|
719
|
+
expect(metrics.getScrollTop()).toBe(404);
|
|
720
|
+
|
|
721
|
+
for (let i = 0; i < 6; i += 1) emitTool(controller, `t${i}`);
|
|
722
|
+
emitStreamingAssistant(controller, "answer", "Good news — it is on track.");
|
|
723
|
+
setTop(mount, "answer", 900);
|
|
724
|
+
metrics.setScrollHeight(1800);
|
|
725
|
+
raf.flush();
|
|
726
|
+
|
|
727
|
+
// Still parked on the preamble: the answer is NOT re-targeted.
|
|
728
|
+
expect(metrics.getScrollTop()).toBe(404);
|
|
729
|
+
controller.destroy();
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
it("holds the sent message when a turn produces only tool calls", () => {
|
|
733
|
+
const raf = installRafMock();
|
|
734
|
+
const mount = createMount();
|
|
735
|
+
const controller = createAgentExperience(mount, anchorTopConfig());
|
|
736
|
+
const metrics = installScrollMetrics(getScrollContainer(mount), {
|
|
737
|
+
scrollHeight: 1000,
|
|
738
|
+
clientHeight: 400,
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
emitAssistantMessage(controller, "seed", "Earlier reply");
|
|
742
|
+
emitUserMessage(controller, "u1", "Just refresh the tracking data");
|
|
743
|
+
setTop(mount, "u1", 300);
|
|
744
|
+
raf.flush();
|
|
745
|
+
expect(metrics.getScrollTop()).toBe(284);
|
|
746
|
+
|
|
747
|
+
for (let i = 0; i < 6; i += 1) emitTool(controller, `t${i}`);
|
|
748
|
+
metrics.setScrollHeight(1600);
|
|
749
|
+
raf.flush();
|
|
750
|
+
|
|
751
|
+
// Nothing attention-worthy ever arrived: never chase the tool bubbles.
|
|
752
|
+
expect(metrics.getScrollTop()).toBe(284);
|
|
753
|
+
controller.destroy();
|
|
754
|
+
});
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
// Regression: streaming content under a pinned anchor makes the browser clamp
|
|
758
|
+
// scrollTop and then restore it — a matched pair of real scroll events with no
|
|
759
|
+
// user involved (observed live as -12 then +12 during a tool run). Treating
|
|
760
|
+
// those as reader intent silently disabled every later re-anchor, so a
|
|
761
|
+
// response that opened with tool calls stayed stuck on the sent message.
|
|
762
|
+
describe("anchor-top first-unread — layout scroll is not reader intent", () => {
|
|
763
|
+
beforeEach(() => {
|
|
764
|
+
installRafMock();
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
it("still anchors the answer after a clamp/restore scroll pair", () => {
|
|
768
|
+
const raf = installRafMock();
|
|
769
|
+
const mount = createMount();
|
|
770
|
+
const controller = createAgentExperience(
|
|
771
|
+
mount,
|
|
772
|
+
baseConfig({
|
|
773
|
+
features: { scrollBehavior: { mode: "anchor-top", anchorTopOffset: 16 } },
|
|
774
|
+
})
|
|
775
|
+
);
|
|
776
|
+
const sc = getScrollContainer(mount);
|
|
777
|
+
const metrics = installScrollMetrics(sc, { scrollHeight: 1000, clientHeight: 400 });
|
|
778
|
+
|
|
779
|
+
emitAssistantMessage(controller, "seed", "Earlier reply");
|
|
780
|
+
emitUserMessage(controller, "u1", "Where is my order?");
|
|
781
|
+
const userBubble = sc.querySelector<HTMLElement>('[data-message-id="u1"]');
|
|
782
|
+
Object.defineProperty(userBubble!, "offsetTop", { configurable: true, value: 300 });
|
|
783
|
+
raf.flush();
|
|
784
|
+
expect(metrics.getScrollTop()).toBe(284);
|
|
785
|
+
|
|
786
|
+
// Tool run: the spacer shrinks, scrollTop is clamped down, then the
|
|
787
|
+
// growing transcript restores it. Both are layout, not the reader.
|
|
788
|
+
for (let i = 0; i < 6; i += 1) {
|
|
789
|
+
controller.injectTestMessage({
|
|
790
|
+
type: "message",
|
|
791
|
+
message: {
|
|
792
|
+
id: `t${i}`,
|
|
793
|
+
role: "assistant",
|
|
794
|
+
content: "",
|
|
795
|
+
createdAt: CREATED_AT,
|
|
796
|
+
variant: "tool",
|
|
797
|
+
toolCall: { id: `t${i}`, name: "search", status: "complete" },
|
|
798
|
+
},
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
metrics.setScrollTop(272);
|
|
802
|
+
sc.dispatchEvent(new Event("scroll"));
|
|
803
|
+
metrics.setScrollHeight(1500);
|
|
804
|
+
metrics.setScrollTop(284);
|
|
805
|
+
sc.dispatchEvent(new Event("scroll"));
|
|
806
|
+
raf.flush();
|
|
807
|
+
|
|
808
|
+
emitStreamingAssistant(controller, "a1", "Good news — your order is on track.");
|
|
809
|
+
const answer = sc.querySelector<HTMLElement>('[data-message-id="a1"]');
|
|
810
|
+
Object.defineProperty(answer!, "offsetTop", { configurable: true, value: 700 });
|
|
811
|
+
raf.flush();
|
|
812
|
+
|
|
813
|
+
expect(metrics.getScrollTop()).toBe(684);
|
|
814
|
+
controller.destroy();
|
|
815
|
+
});
|
|
816
|
+
});
|
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 mode pins the
|
|
994
|
+
it("anchor-top mode pins the turn's first unread block near the viewport top and never follows the stream", () => {
|
|
995
995
|
const raf = installRafMock();
|
|
996
996
|
const resize = installResizeObserverMock();
|
|
997
997
|
const mount = createMount();
|
|
@@ -1034,16 +1034,29 @@ describe("createAgentExperience streaming scroll", () => {
|
|
|
1034
1034
|
raf.flush();
|
|
1035
1035
|
expect(metrics.getScrollTop()).toBe(684);
|
|
1036
1036
|
|
|
1037
|
-
//
|
|
1038
|
-
|
|
1037
|
+
// The response's first block is the turn's first unread output, so the
|
|
1038
|
+
// anchor hands off to it: target = 900 - 16 = 884, and with content at
|
|
1039
|
+
// 1284 - 84 = 1200 the spacer re-sizes to 884 + 400 - 1200 = 84.
|
|
1040
|
+
metrics.setScrollHeight(1284);
|
|
1039
1041
|
emitStreamingMessage(controller, "Streaming response");
|
|
1042
|
+
const streamBubble = scrollContainer!.querySelector<HTMLElement>(
|
|
1043
|
+
`[data-message-id="${STREAM_MESSAGE_ID}"]`
|
|
1044
|
+
);
|
|
1045
|
+
expect(streamBubble).not.toBeNull();
|
|
1046
|
+
Object.defineProperty(streamBubble!, "offsetTop", { value: 900 });
|
|
1040
1047
|
raf.flush();
|
|
1041
|
-
expect(metrics.getScrollTop()).toBe(
|
|
1048
|
+
expect(metrics.getScrollTop()).toBe(884);
|
|
1049
|
+
|
|
1050
|
+
// Continuing to stream below that anchor never moves the viewport again.
|
|
1051
|
+
metrics.setScrollHeight(1324);
|
|
1052
|
+
emitStreamingMessage(controller, "Streaming response, now longer");
|
|
1053
|
+
raf.flush();
|
|
1054
|
+
expect(metrics.getScrollTop()).toBe(884);
|
|
1042
1055
|
|
|
1043
1056
|
// As real content grows, the spacer gives room back (shrink-only):
|
|
1044
|
-
// content grew from
|
|
1057
|
+
// content grew from 1200 to 1324 - 84 = 1240, so spacer 84 - 40 = 44.
|
|
1045
1058
|
resize.trigger();
|
|
1046
|
-
expect(spacer?.style.height).toBe("
|
|
1059
|
+
expect(spacer?.style.height).toBe("44px");
|
|
1047
1060
|
|
|
1048
1061
|
// Jumping to the latest abandons the anchor: the spacer is dropped so
|
|
1049
1062
|
// "bottom" is the real end of content.
|