@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtypelabs/persona",
3
- "version": "4.12.0",
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
+ });
@@ -991,7 +991,7 @@ describe("createAgentExperience streaming scroll", () => {
991
991
  controller.destroy();
992
992
  });
993
993
 
994
- it("anchor-top mode pins the sent user message near the viewport top and never follows the stream", () => {
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
- // Streaming below the anchor never moves the viewport.
1038
- metrics.setScrollHeight(1150);
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(684);
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 1000 to 1150 - 84 = 1066, so spacer 84 - 66 = 18.
1057
+ // content grew from 1200 to 1324 - 84 = 1240, so spacer 84 - 40 = 44.
1045
1058
  resize.trigger();
1046
- expect(spacer?.style.height).toBe("18px");
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 the just-sent user message to rest
4006
- // `anchorTopOffset` px below the viewport top and hold it there while the
4007
- // response streams in beneath it. Deferred one frame so the message bubble
4008
- // has been rendered and laid out.
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
- scheduleAnchorToUserMessage(messageId);
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
- if (NAV_KEYS.has(event.key)) {
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
- const target = event.target as Element | null;
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