@runtypelabs/persona 4.12.0 → 4.13.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/index.cjs +45 -45
- package/dist/index.cjs.map +1 -1
- package/dist/index.global.js +34 -34
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +45 -45
- package/dist/index.js.map +1 -1
- package/dist/theme-editor-preview.cjs +44 -44
- package/dist/theme-editor-preview.js +33 -33
- package/package.json +1 -1
- package/src/ui.scroll-additive.test.ts +365 -0
- package/src/ui.scroll.test.ts +19 -6
- package/src/ui.ts +115 -28
- package/src/utils/auto-follow.test.ts +38 -0
- package/src/utils/auto-follow.ts +35 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runtypelabs/persona",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.13.0",
|
|
4
4
|
"description": "Themeable, pluggable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -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.
|
package/src/ui.ts
CHANGED
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
createFollowStateController,
|
|
65
65
|
getScrollBottomOffset,
|
|
66
66
|
hasSelectionWithin,
|
|
67
|
+
isAttentionWorthyMessage,
|
|
67
68
|
isElementNearBottom,
|
|
68
69
|
resolveFollowStateFromScroll,
|
|
69
70
|
resolveFollowStateFromWheel
|
|
@@ -3537,6 +3538,24 @@ export const createAgentExperience = (
|
|
|
3537
3538
|
// pinned and never re-arms the fallback, so a late-loading embed can't yank
|
|
3538
3539
|
// the viewport down to the bottom.
|
|
3539
3540
|
let currentTurnAnchored = false;
|
|
3541
|
+
// Which message the anchor is currently parked on. Starts as the sent user
|
|
3542
|
+
// message and moves once to the turn's first attention-worthy block (see
|
|
3543
|
+
// `maybeAnchorToFirstUnread`).
|
|
3544
|
+
let anchoredMessageId: string | null = null;
|
|
3545
|
+
// Has the reader taken over positioning this turn? First-unread anchoring is
|
|
3546
|
+
// a guess about where someone who ISN'T watching wants to land; the moment
|
|
3547
|
+
// they demonstrably are watching, the guess stands down and never moves the
|
|
3548
|
+
// viewport under them. Reset on each user send (an explicit "take me along").
|
|
3549
|
+
//
|
|
3550
|
+
// Driven by real INPUT (wheel, touch drag, pointer press, transcript
|
|
3551
|
+
// keyboard nav, focus, selection) rather than by scroll deltas: streaming
|
|
3552
|
+
// content under a pinned anchor makes the browser clamp and then restore
|
|
3553
|
+
// scrollTop, which is indistinguishable from a deliberate scroll if you only
|
|
3554
|
+
// look at the scroll event.
|
|
3555
|
+
let readerEngagedThisTurn = false;
|
|
3556
|
+
const markReaderEngaged = () => {
|
|
3557
|
+
readerEngagedThisTurn = true;
|
|
3558
|
+
};
|
|
3540
3559
|
// Dedupes assistant-turn detection across token-by-token re-renders.
|
|
3541
3560
|
let lastHandledAssistantId: string | null = null;
|
|
3542
3561
|
|
|
@@ -3940,6 +3959,24 @@ export const createAgentExperience = (
|
|
|
3940
3959
|
return top;
|
|
3941
3960
|
};
|
|
3942
3961
|
|
|
3962
|
+
// Defined here (rather than beside the scroll listeners) because
|
|
3963
|
+
// `maybeAnchorToFirstUnread` runs on every render and would otherwise hit
|
|
3964
|
+
// these consts in their temporal dead zone on a synchronous first paint.
|
|
3965
|
+
const getTranscriptSelection = (): Selection | null => {
|
|
3966
|
+
// Selections inside a shadow root are not always reflected by
|
|
3967
|
+
// document.getSelection(); prefer the shadow root's view when available
|
|
3968
|
+
// (non-standard but supported where it matters).
|
|
3969
|
+
const root = body.getRootNode();
|
|
3970
|
+
const shadowSelection =
|
|
3971
|
+
typeof (root as ShadowRoot & { getSelection?: () => Selection | null })
|
|
3972
|
+
.getSelection === "function"
|
|
3973
|
+
? (root as ShadowRoot & { getSelection: () => Selection | null }).getSelection()
|
|
3974
|
+
: null;
|
|
3975
|
+
return shadowSelection ?? body.ownerDocument.getSelection();
|
|
3976
|
+
};
|
|
3977
|
+
const hasActiveTranscriptSelection = () =>
|
|
3978
|
+
hasSelectionWithin(getTranscriptSelection(), body);
|
|
3979
|
+
|
|
3943
3980
|
// Principle 11: reopen where the reader left off. When `restorePosition` is
|
|
3944
3981
|
// "last-user-turn" and there is pre-existing history, land with the last user
|
|
3945
3982
|
// message pinned near the top of the viewport instead of jumping to the
|
|
@@ -4002,11 +4039,10 @@ export const createAgentExperience = (
|
|
|
4002
4039
|
anchorSpacer.style.height = "0px";
|
|
4003
4040
|
};
|
|
4004
4041
|
|
|
4005
|
-
// Anchor-top mode: scroll
|
|
4006
|
-
//
|
|
4007
|
-
//
|
|
4008
|
-
|
|
4009
|
-
const scheduleAnchorToUserMessage = (messageId: string) => {
|
|
4042
|
+
// Anchor-top mode: scroll `messageId` to rest `anchorTopOffset` px below the
|
|
4043
|
+
// viewport top and hold it there while the response streams in beneath it.
|
|
4044
|
+
// Deferred one frame so the message bubble has been rendered and laid out.
|
|
4045
|
+
const scheduleAnchorToMessage = (messageId: string) => {
|
|
4010
4046
|
if (anchorRAF !== null) {
|
|
4011
4047
|
cancelAnimationFrame(anchorRAF);
|
|
4012
4048
|
}
|
|
@@ -4087,7 +4123,11 @@ export const createAgentExperience = (
|
|
|
4087
4123
|
// next user send.
|
|
4088
4124
|
followFallbackActive = false;
|
|
4089
4125
|
currentTurnAnchored = true;
|
|
4090
|
-
|
|
4126
|
+
// A send is an explicit "take me along": it re-arms first-unread
|
|
4127
|
+
// positioning even if the reader had scrolled away during the last turn.
|
|
4128
|
+
readerEngagedThisTurn = false;
|
|
4129
|
+
anchoredMessageId = messageId;
|
|
4130
|
+
scheduleAnchorToMessage(messageId);
|
|
4091
4131
|
}
|
|
4092
4132
|
};
|
|
4093
4133
|
|
|
@@ -4110,6 +4150,44 @@ export const createAgentExperience = (
|
|
|
4110
4150
|
scheduleAutoScroll(true);
|
|
4111
4151
|
};
|
|
4112
4152
|
|
|
4153
|
+
// Anchor-top positioning: park on the turn's FIRST UNREAD attention-worthy
|
|
4154
|
+
// block.
|
|
4155
|
+
//
|
|
4156
|
+
// The read boundary is the last user send — the reader wrote it, so it (and
|
|
4157
|
+
// everything above) is read; every attention-worthy block after it is not.
|
|
4158
|
+
// Tool calls and reasoning are chrome and never targeted, so they scroll
|
|
4159
|
+
// past underneath rather than parking the reader on progress output.
|
|
4160
|
+
//
|
|
4161
|
+
// The policy assumes the reader is NOT watching the stream, and picks the
|
|
4162
|
+
// best place for them to land when they look back: the start of what they
|
|
4163
|
+
// haven't seen. `readerEngagedThisTurn` is the escape hatch — once someone
|
|
4164
|
+
// is demonstrably driving, their position wins and this stands down until
|
|
4165
|
+
// the next send.
|
|
4166
|
+
//
|
|
4167
|
+
// Anchoring to the first unread (not the newest) block means this fires at
|
|
4168
|
+
// most once per turn in practice, and later blocks stream in below the
|
|
4169
|
+
// anchor exactly as they do today.
|
|
4170
|
+
const maybeAnchorToFirstUnread = (messages: AgentWidgetMessage[]) => {
|
|
4171
|
+
if (getScrollMode() !== "anchor-top") return;
|
|
4172
|
+
if (!currentTurnAnchored) return;
|
|
4173
|
+
if (readerEngagedThisTurn) return;
|
|
4174
|
+
if (hasActiveTranscriptSelection()) return;
|
|
4175
|
+
|
|
4176
|
+
let pastReadBoundary = lastSentUserMessageId === null;
|
|
4177
|
+
for (const message of messages) {
|
|
4178
|
+
if (!pastReadBoundary) {
|
|
4179
|
+
if (message.id === lastSentUserMessageId) pastReadBoundary = true;
|
|
4180
|
+
continue;
|
|
4181
|
+
}
|
|
4182
|
+
if (!isAttentionWorthyMessage(message)) continue;
|
|
4183
|
+
// Already parked on it: hold still while it streams.
|
|
4184
|
+
if (message.id === anchoredMessageId) return;
|
|
4185
|
+
anchoredMessageId = message.id;
|
|
4186
|
+
scheduleAnchorToMessage(message.id);
|
|
4187
|
+
return;
|
|
4188
|
+
}
|
|
4189
|
+
};
|
|
4190
|
+
|
|
4113
4191
|
const trackMessages = (messages: AgentWidgetMessage[]) => {
|
|
4114
4192
|
const nextState = new Map<
|
|
4115
4193
|
string,
|
|
@@ -5998,6 +6076,8 @@ export const createAgentExperience = (
|
|
|
5998
6076
|
resetAnchorState();
|
|
5999
6077
|
followFallbackActive = true;
|
|
6000
6078
|
currentTurnAnchored = false;
|
|
6079
|
+
anchoredMessageId = null;
|
|
6080
|
+
readerEngagedThisTurn = false;
|
|
6001
6081
|
}
|
|
6002
6082
|
if (!scrollSendSeeded || suppressScrollSend) {
|
|
6003
6083
|
scrollSendSeeded = true;
|
|
@@ -6013,6 +6093,7 @@ export const createAgentExperience = (
|
|
|
6013
6093
|
handleAssistantTurnStarted();
|
|
6014
6094
|
}
|
|
6015
6095
|
if (lastAssistantMessage) lastHandledAssistantId = lastAssistantMessage.id;
|
|
6096
|
+
maybeAnchorToFirstUnread(messages);
|
|
6016
6097
|
|
|
6017
6098
|
const prevLastUserMessageId = voiceState.lastUserMessageId;
|
|
6018
6099
|
if (lastUserMessage && lastUserMessage.id !== prevLastUserMessageId) {
|
|
@@ -7341,21 +7422,6 @@ export const createAgentExperience = (
|
|
|
7341
7422
|
lastScrollTop = body.scrollTop;
|
|
7342
7423
|
let lastBottomOffset = getScrollBottomOffset(body);
|
|
7343
7424
|
|
|
7344
|
-
const getTranscriptSelection = (): Selection | null => {
|
|
7345
|
-
// Selections inside a shadow root are not always reflected by
|
|
7346
|
-
// document.getSelection(); prefer the shadow root's view when available
|
|
7347
|
-
// (non-standard but supported where it matters).
|
|
7348
|
-
const root = body.getRootNode();
|
|
7349
|
-
const shadowSelection =
|
|
7350
|
-
typeof (root as ShadowRoot & { getSelection?: () => Selection | null })
|
|
7351
|
-
.getSelection === "function"
|
|
7352
|
-
? (root as ShadowRoot & { getSelection: () => Selection | null }).getSelection()
|
|
7353
|
-
: null;
|
|
7354
|
-
return shadowSelection ?? body.ownerDocument.getSelection();
|
|
7355
|
-
};
|
|
7356
|
-
const hasActiveTranscriptSelection = () =>
|
|
7357
|
-
hasSelectionWithin(getTranscriptSelection(), body);
|
|
7358
|
-
|
|
7359
7425
|
const handleScroll = () => {
|
|
7360
7426
|
const scrollTop = body.scrollTop;
|
|
7361
7427
|
// When content mutates (e.g. stream-animation plugins re-rendering text)
|
|
@@ -7373,6 +7439,14 @@ export const createAgentExperience = (
|
|
|
7373
7439
|
if (!isFollowEffective()) {
|
|
7374
7440
|
// No follow state to manage (anchored anchor-top / none): just keep the
|
|
7375
7441
|
// scroll-to-bottom affordance in sync with the user's position.
|
|
7442
|
+
//
|
|
7443
|
+
// Reader intent is deliberately NOT inferred from scroll deltas here.
|
|
7444
|
+
// Growing the transcript under a pinned anchor makes the browser clamp
|
|
7445
|
+
// scrollTop and then restore it, which lands as a matched pair of real
|
|
7446
|
+
// scroll events (e.g. -12 then +12) with no user involved. The restore
|
|
7447
|
+
// arrives while the bottom offset is GROWING, so the shrink mask above
|
|
7448
|
+
// doesn't cover it, and it reads as a deliberate scroll. Engagement is
|
|
7449
|
+
// tracked from actual input instead (see `markReaderEngaged` callers).
|
|
7376
7450
|
lastScrollTop = scrollTop;
|
|
7377
7451
|
syncScrollToBottomButton();
|
|
7378
7452
|
return;
|
|
@@ -7459,21 +7533,23 @@ export const createAgentExperience = (
|
|
|
7459
7533
|
"ArrowDown",
|
|
7460
7534
|
]);
|
|
7461
7535
|
const handleTranscriptKeydown = (event: KeyboardEvent) => {
|
|
7536
|
+
if (!NAV_KEYS.has(event.key)) return;
|
|
7537
|
+
// Reader intent for first-unread anchoring is tracked unconditionally;
|
|
7538
|
+
// only the follow-mode *pause* below stays behind the opt-in flag.
|
|
7539
|
+
markReaderEngaged();
|
|
7462
7540
|
if (!isPauseOnInteractionEnabled()) return;
|
|
7463
7541
|
if (!isFollowEffective()) return;
|
|
7464
7542
|
if (!autoFollow.isFollowing()) return;
|
|
7465
|
-
|
|
7466
|
-
pauseAutoScroll();
|
|
7467
|
-
}
|
|
7543
|
+
pauseAutoScroll();
|
|
7468
7544
|
};
|
|
7469
7545
|
const handleTranscriptFocusIn = (event: FocusEvent) => {
|
|
7546
|
+
const target = event.target as Element | null;
|
|
7547
|
+
if (!target?.closest("a, button, [tabindex], input, textarea, select")) return;
|
|
7548
|
+
markReaderEngaged();
|
|
7470
7549
|
if (!isPauseOnInteractionEnabled()) return;
|
|
7471
7550
|
if (!isFollowEffective()) return;
|
|
7472
7551
|
if (!autoFollow.isFollowing()) return;
|
|
7473
|
-
|
|
7474
|
-
if (target && target.closest("a, button, [tabindex], input, textarea, select")) {
|
|
7475
|
-
pauseAutoScroll();
|
|
7476
|
-
}
|
|
7552
|
+
pauseAutoScroll();
|
|
7477
7553
|
};
|
|
7478
7554
|
body.addEventListener("keydown", handleTranscriptKeydown);
|
|
7479
7555
|
body.addEventListener("focusin", handleTranscriptFocusIn);
|
|
@@ -7483,6 +7559,9 @@ export const createAgentExperience = (
|
|
|
7483
7559
|
});
|
|
7484
7560
|
|
|
7485
7561
|
const handleWheel = (event: WheelEvent) => {
|
|
7562
|
+
// A wheel tick is unambiguous reader intent in every mode — including
|
|
7563
|
+
// anchored anchor-top, where there is no follow state to pause.
|
|
7564
|
+
if (event.deltaY !== 0) markReaderEngaged();
|
|
7486
7565
|
if (!isFollowEffective()) return;
|
|
7487
7566
|
const action = resolveFollowStateFromWheel({
|
|
7488
7567
|
following: autoFollow.isFollowing(),
|
|
@@ -7498,6 +7577,14 @@ export const createAgentExperience = (
|
|
|
7498
7577
|
}
|
|
7499
7578
|
};
|
|
7500
7579
|
body.addEventListener("wheel", handleWheel, { passive: true });
|
|
7580
|
+
// Touch drag and pointer press (scrollbar drags, tapping a tool bubble open)
|
|
7581
|
+
// are the remaining ways a reader takes over without a wheel or key event.
|
|
7582
|
+
body.addEventListener("touchmove", markReaderEngaged, { passive: true });
|
|
7583
|
+
body.addEventListener("pointerdown", markReaderEngaged, { passive: true });
|
|
7584
|
+
destroyCallbacks.push(() => {
|
|
7585
|
+
body.removeEventListener("touchmove", markReaderEngaged);
|
|
7586
|
+
body.removeEventListener("pointerdown", markReaderEngaged);
|
|
7587
|
+
});
|
|
7501
7588
|
destroyCallbacks.push(() => body.removeEventListener("wheel", handleWheel));
|
|
7502
7589
|
scrollToBottomButton.addEventListener("click", () => {
|
|
7503
7590
|
// Jumping to the latest abandons the current anchor: drop the spacer
|