@runtypelabs/persona 4.33.0 → 4.34.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.
Files changed (186) hide show
  1. package/dist/activity-ui.d.cts +3 -15
  2. package/dist/activity-ui.d.ts +3 -15
  3. package/dist/animations/glyph-cycle.d.cts +1 -3
  4. package/dist/animations/glyph-cycle.d.ts +1 -3
  5. package/dist/animations/wipe.d.cts +1 -3
  6. package/dist/animations/wipe.d.ts +1 -3
  7. package/dist/approval-ui.cjs +2 -2
  8. package/dist/approval-ui.d.cts +3 -15
  9. package/dist/approval-ui.d.ts +3 -15
  10. package/dist/approval-ui.js +1 -1
  11. package/dist/artifacts-ui.cjs +1 -1
  12. package/dist/artifacts-ui.d.cts +3 -21
  13. package/dist/artifacts-ui.d.ts +3 -21
  14. package/dist/background-runtime.d.cts +2 -3
  15. package/dist/background-runtime.d.ts +2 -3
  16. package/dist/{chunk-gu34lr-P.js → chunk-Brdx4NCz.js} +1 -1
  17. package/dist/{chunk-dLJWKfOb.js → chunk-C-rux8Q9.js} +1 -1
  18. package/dist/{chunk-Ds_twaZn.js → chunk-C4v-HNhS.js} +1 -1
  19. package/dist/{chunk-Bkma5hIU.js → chunk-CE4seJxF.js} +1 -1
  20. package/dist/{chunk-D6U0E82p.js → chunk-CV8EPIhT.js} +1 -1
  21. package/dist/{chunk-ql5N2I_t.js → chunk-CWzHNuVK.js} +1 -1
  22. package/dist/{chunk-DbGLjU2P.js → chunk-CYpt7PsO.js} +1 -1
  23. package/dist/{chunk-DOZmcve8.js → chunk-Csr59G5V.js} +1 -1
  24. package/dist/{chunk-DW7Vg_l2.js → chunk-CxNPSY-b.js} +1 -1
  25. package/dist/chunk-Dqa_GOcj.js +1 -0
  26. package/dist/chunk-DsWv3EGF.js +1 -0
  27. package/dist/{chunk-C7-m0EmI.js → chunk-NUNdcnAZ.js} +1 -1
  28. package/dist/{chunk-CXQJG7pb.js → chunk-YMkMvAsa.js} +2 -2
  29. package/dist/{chunk-C-EHtpbB.js → chunk-h5aOI_Pa.js} +1 -1
  30. package/dist/chunk-kizhspe5.js +1 -0
  31. package/dist/client-dispatch.js +1 -1
  32. package/dist/client-history.js +1 -1
  33. package/dist/client-stream.js +1 -1
  34. package/dist/{code-generators-DqauYyfX.d.cts → code-generators-DPAqTcAz.d.cts} +0 -2
  35. package/dist/{code-generators-DqauYyfX.d.ts → code-generators-DPAqTcAz.d.ts} +0 -2
  36. package/dist/codegen.cjs +1 -1
  37. package/dist/codegen.d.cts +1 -1
  38. package/dist/codegen.d.ts +1 -1
  39. package/dist/codegen.js +1 -1
  40. package/dist/composer-actions-runtime.cjs +1 -1
  41. package/dist/composer-actions-runtime.d.cts +4 -20
  42. package/dist/composer-actions-runtime.d.ts +4 -20
  43. package/dist/composer-actions-runtime.js +1 -1
  44. package/dist/{composer-input-DnqGCXZz.d.ts → composer-input-i_FiUp6J.d.cts} +1 -7
  45. package/dist/{composer-input-DnqGCXZz.d.cts → composer-input-i_FiUp6J.d.ts} +1 -7
  46. package/dist/{composer-store-6Iwy6WLI.d.ts → composer-store-DhGNgG7Z.d.cts} +1 -3
  47. package/dist/{composer-store-6Iwy6WLI.d.cts → composer-store-DhGNgG7Z.d.ts} +1 -3
  48. package/dist/config-diagnostics.d.cts +0 -2
  49. package/dist/config-diagnostics.d.ts +0 -2
  50. package/dist/{context-mention-manager-B-rHF98n.d.ts → context-mention-manager-DL-xwGfL.d.cts} +2 -6
  51. package/dist/{context-mention-manager-B-rHF98n.d.cts → context-mention-manager-DL-xwGfL.d.ts} +2 -6
  52. package/dist/context-mentions-inline.cjs +1 -1
  53. package/dist/context-mentions-inline.d.cts +2 -4
  54. package/dist/context-mentions-inline.d.ts +2 -4
  55. package/dist/context-mentions-inline.js +1 -1
  56. package/dist/context-mentions.cjs +1 -1
  57. package/dist/context-mentions.d.cts +3 -9
  58. package/dist/context-mentions.d.ts +3 -9
  59. package/dist/{defaults-BuOjqUAG.d.ts → defaults-Bga2Xrsp.d.cts} +1 -5
  60. package/dist/{defaults-BuOjqUAG.d.cts → defaults-Bga2Xrsp.d.ts} +1 -5
  61. package/dist/dictation-runtime.cjs +1 -1
  62. package/dist/dictation-runtime.d.cts +4 -13
  63. package/dist/dictation-runtime.d.ts +4 -13
  64. package/dist/dictation-runtime.js +1 -1
  65. package/dist/{dom-context-DHTabIn7.d.cts → dom-context-Bw-_9Evh.d.cts} +0 -2
  66. package/dist/{dom-context-DHTabIn7.d.ts → dom-context-Bw-_9Evh.d.ts} +0 -2
  67. package/dist/event-stream-view.cjs +1 -1
  68. package/dist/event-stream-view.d.cts +3 -16
  69. package/dist/event-stream-view.d.ts +3 -16
  70. package/dist/{fallback-speech-engine-SqEfkGda.d.ts → fallback-speech-engine-kLdyPcmk.d.cts} +1 -3
  71. package/dist/{fallback-speech-engine-SqEfkGda.d.cts → fallback-speech-engine-kLdyPcmk.d.ts} +1 -3
  72. package/dist/{feature-preferences-DqAmje-2.d.ts → feature-preferences-CmwZGKrf.d.cts} +1 -5
  73. package/dist/{feature-preferences-DqAmje-2.d.cts → feature-preferences-CmwZGKrf.d.ts} +1 -5
  74. package/dist/forms-ui.d.cts +2 -4
  75. package/dist/forms-ui.d.ts +2 -4
  76. package/dist/{history-confirm-dialog-B3L7QNcO.d.cts → history-confirm-dialog-i46-lPN_.d.cts} +2 -14
  77. package/dist/{history-confirm-dialog-B3L7QNcO.d.ts → history-confirm-dialog-i46-lPN_.d.ts} +2 -14
  78. package/dist/history-shell.cjs +1 -1
  79. package/dist/history-shell.d.cts +8 -27
  80. package/dist/history-shell.d.ts +8 -27
  81. package/dist/history-shell.js +1 -1
  82. package/dist/history-view.d.cts +1 -1
  83. package/dist/history-view.d.ts +1 -1
  84. package/dist/icons-extra.cjs +1 -1
  85. package/dist/icons-extra.d.cts +0 -2
  86. package/dist/icons-extra.d.ts +0 -2
  87. package/dist/index.cjs +45 -45
  88. package/dist/index.cjs.map +1 -1
  89. package/dist/index.d.cts +10 -10
  90. package/dist/index.d.ts +10 -10
  91. package/dist/index.global.js +1 -1
  92. package/dist/index.global.js.map +1 -1
  93. package/dist/index.js +49 -49
  94. package/dist/index.js.map +1 -1
  95. package/dist/install.global.js +1 -1
  96. package/dist/install.global.js.map +1 -1
  97. package/dist/launcher.global.js +2 -2
  98. package/dist/launcher.global.js.map +1 -1
  99. package/dist/{loading-label-runtime-loader-JecjOfJS.d.ts → loading-label-runtime-loader-Ccr3ETd3.d.cts} +1 -3
  100. package/dist/{loading-label-runtime-loader-JecjOfJS.d.cts → loading-label-runtime-loader-Ccr3ETd3.d.ts} +1 -3
  101. package/dist/loading-label-runtime.cjs +1 -1
  102. package/dist/loading-label-runtime.d.cts +1 -1
  103. package/dist/loading-label-runtime.d.ts +1 -1
  104. package/dist/loading-label-runtime.js +1 -1
  105. package/dist/markdown-parsers.js +1 -1
  106. package/dist/{message-bubble-CBYtpDsT.d.ts → message-bubble-C6AVaH7f.d.cts} +20 -3
  107. package/dist/{message-bubble-CBYtpDsT.d.cts → message-bubble-C6AVaH7f.d.ts} +20 -3
  108. package/dist/{plugin-kit-C1mRjLgW.d.cts → plugin-kit-BVggqE3C.d.cts} +0 -2
  109. package/dist/{plugin-kit-C1mRjLgW.d.ts → plugin-kit-BVggqE3C.d.ts} +0 -2
  110. package/dist/plugin-kit.d.cts +1 -1
  111. package/dist/plugin-kit.d.ts +1 -1
  112. package/dist/preferences-runtime.cjs +1 -1
  113. package/dist/preferences-runtime.d.cts +1 -1
  114. package/dist/preferences-runtime.d.ts +1 -1
  115. package/dist/preferences-runtime.js +1 -1
  116. package/dist/{preview-_UJwwt2L.d.cts → preview-BMdRffo4.d.cts} +2 -6
  117. package/dist/{preview-_UJwwt2L.d.ts → preview-BMdRffo4.d.ts} +2 -6
  118. package/dist/{registry-B5ZV3K-h.d.ts → registry-B16l8s1b.d.cts} +1 -8
  119. package/dist/{registry-B5ZV3K-h.d.cts → registry-B16l8s1b.d.ts} +1 -8
  120. package/dist/{session-Bys8_XuB.d.cts → session-CtZHe7fc.d.cts} +8 -92
  121. package/dist/{session-Bys8_XuB.d.ts → session-CtZHe7fc.d.ts} +8 -92
  122. package/dist/session-reconnect.d.cts +2 -4
  123. package/dist/session-reconnect.d.ts +2 -4
  124. package/dist/smart-dom-reader.d.cts +2 -8
  125. package/dist/smart-dom-reader.d.ts +2 -8
  126. package/dist/testing.d.cts +0 -2
  127. package/dist/testing.d.ts +0 -2
  128. package/dist/theme-editor-preview.cjs +42 -42
  129. package/dist/theme-editor-preview.d.cts +1 -1
  130. package/dist/theme-editor-preview.d.ts +1 -1
  131. package/dist/theme-editor-preview.js +25 -25
  132. package/dist/theme-editor.cjs +25 -5
  133. package/dist/theme-editor.d.cts +2 -22
  134. package/dist/theme-editor.d.ts +2 -22
  135. package/dist/theme-editor.js +1 -1
  136. package/dist/theme-reference.cjs +1 -1
  137. package/dist/theme-reference.d.cts +2 -4
  138. package/dist/theme-reference.d.ts +2 -4
  139. package/dist/theme-reference.js +1 -1
  140. package/dist/{tool-loading-animation-d5qAQcwv.d.ts → tool-loading-animation-D2cyFvmM.d.cts} +1 -7
  141. package/dist/{tool-loading-animation-d5qAQcwv.d.cts → tool-loading-animation-D2cyFvmM.d.ts} +1 -7
  142. package/dist/{tooltip-BkW-8MnD.d.cts → tooltip-DbXkAuJL.d.cts} +0 -2
  143. package/dist/{tooltip-BkW-8MnD.d.ts → tooltip-DbXkAuJL.d.ts} +0 -2
  144. package/dist/{turn-activity-ui-q6fHVnx-.d.cts → turn-activity-ui-Do1Nd-Ow.d.cts} +2 -12
  145. package/dist/{turn-activity-ui-q6fHVnx-.d.ts → turn-activity-ui-Do1Nd-Ow.d.ts} +2 -12
  146. package/dist/turn-activity-ui.d.cts +1 -1
  147. package/dist/turn-activity-ui.d.ts +1 -1
  148. package/dist/{types-B_mj21jg.d.cts → types-BC7MRxO5.d.cts} +76 -29
  149. package/dist/{types-B_mj21jg.d.ts → types-BC7MRxO5.d.ts} +76 -29
  150. package/dist/ui-extras.js +3 -3
  151. package/dist/voice-client-tools.d.cts +1 -3
  152. package/dist/voice-client-tools.d.ts +1 -3
  153. package/dist/voice-runtime.cjs +1 -1
  154. package/dist/voice-runtime.d.cts +2 -12
  155. package/dist/voice-runtime.d.ts +2 -12
  156. package/dist/voice-runtime.js +2 -2
  157. package/dist/voice-worklet-player.d.cts +2 -6
  158. package/dist/voice-worklet-player.d.ts +2 -6
  159. package/dist/{webmcp-runtime-entry-ClEO3qdI.d.ts → webmcp-runtime-entry-B8g46vS0.d.cts} +1 -5
  160. package/dist/{webmcp-runtime-entry-ClEO3qdI.d.cts → webmcp-runtime-entry-B8g46vS0.d.ts} +1 -5
  161. package/dist/webmcp-runtime.d.cts +1 -1
  162. package/dist/webmcp-runtime.d.ts +1 -1
  163. package/dist/widget.css +1 -1
  164. package/package.json +4 -4
  165. package/src/components/event-stream-view.test.ts +5 -0
  166. package/src/components/message-bubble.ts +26 -6
  167. package/src/components/scroll-to-bottom.test.ts +135 -0
  168. package/src/components/scroll-to-bottom.ts +50 -13
  169. package/src/components/user-message-collapse.ts +117 -0
  170. package/src/defaults.ts +6 -0
  171. package/src/styles/widget.css +56 -0
  172. package/src/theme-reference.ts +2 -2
  173. package/src/types/theme.ts +29 -0
  174. package/src/types.ts +49 -5
  175. package/src/ui.scroll-additive.test.ts +219 -117
  176. package/src/ui.scroll.test.ts +176 -13
  177. package/src/ui.ts +230 -107
  178. package/src/ui.turn-activity.test.ts +7 -7
  179. package/src/ui.user-message-collapse.test.ts +540 -0
  180. package/src/utils/auto-follow.test.ts +0 -38
  181. package/src/utils/auto-follow.ts +0 -35
  182. package/src/utils/theme.test.ts +23 -0
  183. package/src/utils/tokens.ts +16 -0
  184. package/dist/chunk-CVMIncEY.js +0 -1
  185. package/dist/chunk-DVgzoVCB.js +0 -1
  186. package/dist/chunk-d54BJdKd.js +0 -1
@@ -39,16 +39,22 @@ const installRafMock = () => {
39
39
  vi.stubGlobal("cancelAnimationFrame", (id: number) => {
40
40
  callbacks.delete(id);
41
41
  });
42
+ const run = (maxFrames: number) => {
43
+ let frames = 0;
44
+ while (callbacks.size > 0 && frames < maxFrames) {
45
+ const pending = [...callbacks.values()];
46
+ callbacks.clear();
47
+ frames += 1;
48
+ now += 16;
49
+ pending.forEach((cb) => cb(now));
50
+ }
51
+ };
42
52
  return {
53
+ step(frameCount = 1) {
54
+ run(frameCount);
55
+ },
43
56
  flush(maxFrames = 80) {
44
- let frames = 0;
45
- while (callbacks.size > 0 && frames < maxFrames) {
46
- const pending = [...callbacks.values()];
47
- callbacks.clear();
48
- frames += 1;
49
- now += 16;
50
- pending.forEach((cb) => cb(now));
51
- }
57
+ run(maxFrames);
52
58
  },
53
59
  };
54
60
  };
@@ -498,12 +504,11 @@ describe("scrollBehavior.announce (Principle 15)", () => {
498
504
  });
499
505
 
500
506
 
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.
506
- describe("anchor-top first-unread positioning", () => {
507
+ // Anchor-top pins the sent user message for the whole reply. It never hands
508
+ // off to the reply because the reply starts below the fold: tool runs,
509
+ // artifacts, multi-block answers, and tall questions all stream in beneath
510
+ // the anchor, and the jump-to-latest control is how the reader gets there.
511
+ describe("anchor-top holds the sent message for the whole reply", () => {
507
512
  beforeEach(() => {
508
513
  installRafMock();
509
514
  });
@@ -515,7 +520,8 @@ describe("anchor-top first-unread positioning", () => {
515
520
 
516
521
  const emitToolMessage = (
517
522
  controller: ReturnType<typeof createAgentExperience>,
518
- id: string
523
+ id: string,
524
+ status: "running" | "complete" = "complete"
519
525
  ) => {
520
526
  controller.injectTestMessage({
521
527
  type: "message",
@@ -525,13 +531,14 @@ describe("anchor-top first-unread positioning", () => {
525
531
  content: "",
526
532
  createdAt: CREATED_AT,
527
533
  variant: "tool",
528
- toolCall: { id, name: "search", status: "complete" },
534
+ streaming: status === "running",
535
+ toolCall: { id, name: "search", status },
529
536
  },
530
537
  });
531
538
  };
532
539
 
533
540
  // An artifact / component block: substance lives in rawContent, `content`
534
- // is empty. Testing text alone would silently skip these.
541
+ // is empty.
535
542
  const emitArtifactMessage = (
536
543
  controller: ReturnType<typeof createAgentExperience>,
537
544
  id: string
@@ -560,6 +567,9 @@ describe("anchor-top first-unread positioning", () => {
560
567
  Object.defineProperty(bubble!, "offsetTop", { configurable: true, value });
561
568
  };
562
569
 
570
+ const getSpacer = (mount: HTMLElement) =>
571
+ getScrollContainer(mount).querySelector<HTMLElement>("[data-persona-anchor-spacer]")!;
572
+
563
573
  const startTurn = (
564
574
  controller: ReturnType<typeof createAgentExperience>,
565
575
  mount: HTMLElement,
@@ -571,7 +581,7 @@ describe("anchor-top first-unread positioning", () => {
571
581
  raf.flush();
572
582
  };
573
583
 
574
- it("skips a run of tool calls and anchors the answer", () => {
584
+ it("keeps the sent message anchored through a tool run and an answer below the fold", () => {
575
585
  const raf = installRafMock();
576
586
  const mount = createMount();
577
587
  const controller = createAgentExperience(mount, anchorTopConfig());
@@ -583,26 +593,31 @@ describe("anchor-top first-unread positioning", () => {
583
593
  startTurn(controller, mount, raf);
584
594
  expect(metrics.getScrollTop()).toBe(284); // parked on the sent message
585
595
 
586
- for (let i = 0; i < 6; i += 1) emitToolMessage(controller, `t${i}`);
596
+ for (let i = 0; i < 6; i += 1) {
597
+ emitToolMessage(controller, `t${i}`, "running");
598
+ setBubbleOffsetTop(mount, `t${i}`, 360 + i * 60);
599
+ emitToolMessage(controller, `t${i}`);
600
+ }
587
601
  metrics.setScrollHeight(1500);
588
602
  raf.flush();
589
- // Tool calls are chrome: they never move the anchor.
590
603
  expect(metrics.getScrollTop()).toBe(284);
591
604
 
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.)
605
+ // The answer starts 400px below the pinned question, past the fold at a
606
+ // 400px viewport. A second render lets any render-time probe see the
607
+ // mocked offset; the anchor still stays on the question.
596
608
  emitStreamingAssistant(controller, "a1", "Good news — your order is on track.");
597
609
  setBubbleOffsetTop(mount, "a1", 700);
610
+ metrics.setScrollHeight(1700);
598
611
  emitStreamingAssistant(controller, "a1", "Good news — your order is on track!");
599
612
  raf.flush();
600
- expect(metrics.getScrollTop()).toBe(684);
613
+ expect(metrics.getScrollTop()).toBe(284);
601
614
 
615
+ // The jump control advertises the offscreen answer.
616
+ expect(getScrollToBottomButton(mount).style.display).toBe("");
602
617
  controller.destroy();
603
618
  });
604
619
 
605
- it("anchors an artifact block, whose content lives in rawContent", () => {
620
+ it("keeps the anchor when an artifact block lands below the fold", () => {
606
621
  const raf = installRafMock();
607
622
  const mount = createMount();
608
623
  const controller = createAgentExperience(mount, anchorTopConfig());
@@ -617,18 +632,15 @@ describe("anchor-top first-unread positioning", () => {
617
632
  raf.flush();
618
633
 
619
634
  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.
623
635
  setBubbleOffsetTop(mount, "art-msg", 620);
624
636
  emitToolMessage(controller, "t-after");
625
637
  raf.flush();
626
638
 
627
- expect(metrics.getScrollTop()).toBe(604);
639
+ expect(metrics.getScrollTop()).toBe(284);
628
640
  controller.destroy();
629
641
  });
630
642
 
631
- it("holds the FIRST unread block when more text streams in after it", () => {
643
+ it("holds the anchor across multiple assistant blocks, streaming updates, and completion", () => {
632
644
  const raf = installRafMock();
633
645
  const mount = createMount();
634
646
  const controller = createAgentExperience(mount, anchorTopConfig());
@@ -638,24 +650,26 @@ describe("anchor-top first-unread positioning", () => {
638
650
  });
639
651
 
640
652
  startTurn(controller, mount, raf);
641
- // First segment starts past the fold (delta 400 at a 400px viewport):
642
- // the anchor hands off to it.
643
653
  emitStreamingAssistant(controller, "a1", "First segment.");
644
654
  setBubbleOffsetTop(mount, "a1", 700);
645
655
  metrics.setScrollHeight(1400);
646
- emitStreamingAssistant(controller, "a1", "First segment!");
656
+ emitStreamingAssistant(controller, "a1", "First segment, still streaming.");
647
657
  raf.flush();
648
- expect(metrics.getScrollTop()).toBe(684);
658
+ expect(metrics.getScrollTop()).toBe(284);
649
659
 
650
- // Interleaved: tool, then a second text segment. The anchor stays on the
651
- // first unread block — later blocks stream in below it.
660
+ // Interleaved: tool, then a second text segment.
652
661
  emitToolMessage(controller, "t0");
653
662
  emitStreamingAssistant(controller, "a2", "Second segment.");
654
663
  setBubbleOffsetTop(mount, "a2", 1100);
655
664
  metrics.setScrollHeight(1800);
665
+ emitStreamingAssistant(controller, "a2", "Second segment, longer.");
656
666
  raf.flush();
667
+ expect(metrics.getScrollTop()).toBe(284);
657
668
 
658
- expect(metrics.getScrollTop()).toBe(684);
669
+ emitAssistantMessage(controller, "a2", "Second segment, complete.");
670
+ controller.injectTestMessage({ type: "status", status: "idle" });
671
+ raf.flush();
672
+ expect(metrics.getScrollTop()).toBe(284);
659
673
  controller.destroy();
660
674
  });
661
675
 
@@ -671,9 +685,9 @@ describe("anchor-top first-unread positioning", () => {
671
685
  metrics.setScrollHeight(1500);
672
686
  raf.flush();
673
687
 
674
- // The reader takes over: wheels down to watch the tools run. Engagement
675
- // is keyed on real input, NOT on the scroll event (streaming content
676
- // clamps and restores scrollTop on its own — see the regression test
688
+ // The reader takes over: wheels to watch the tools run. Engagement is
689
+ // keyed on real input, NOT on the scroll event (streaming content
690
+ // clamps and restores scrollTop on its own; see the regression test
677
691
  // below), so the wheel is what must register here.
678
692
  metrics.setScrollTop(520);
679
693
  sc.dispatchEvent(new WheelEvent("wheel", { deltaY: -120, bubbles: true }));
@@ -681,6 +695,8 @@ describe("anchor-top first-unread positioning", () => {
681
695
 
682
696
  emitStreamingAssistant(controller, "a1", "Good news — your order is on track.");
683
697
  setBubbleOffsetTop(mount, "a1", 700);
698
+ emitStreamingAssistant(controller, "a1", "Good news — your order is on track!");
699
+ metrics.setScrollHeight(1700);
684
700
  raf.flush();
685
701
 
686
702
  // Their position wins: no re-anchor under them.
@@ -688,6 +704,40 @@ describe("anchor-top first-unread positioning", () => {
688
704
  controller.destroy();
689
705
  });
690
706
 
707
+ it("does not move a reader who is selecting transcript text", () => {
708
+ const raf = installRafMock();
709
+ const mount = createMount();
710
+ const controller = createAgentExperience(mount, anchorTopConfig());
711
+ const sc = getScrollContainer(mount);
712
+ const metrics = installScrollMetrics(sc, { scrollHeight: 1000, clientHeight: 400 });
713
+
714
+ startTurn(controller, mount, raf);
715
+ emitStreamingAssistant(controller, "a1", "Tracking shows the parcel left the depot.");
716
+ setBubbleOffsetTop(mount, "a1", 420);
717
+ raf.flush();
718
+
719
+ // Press and drag-select the answer, scrolling a little in the process.
720
+ const answer = sc.querySelector<HTMLElement>('[data-message-id="a1"]')!;
721
+ answer.dispatchEvent(new Event("pointerdown", { bubbles: true }));
722
+ const range = document.createRange();
723
+ range.selectNodeContents(answer);
724
+ document.getSelection()!.removeAllRanges();
725
+ document.getSelection()!.addRange(range);
726
+ metrics.setScrollTop(360);
727
+ sc.dispatchEvent(new Event("scroll"));
728
+
729
+ for (let i = 0; i < 3; i += 1) emitToolMessage(controller, `t${i}`);
730
+ emitStreamingAssistant(controller, "a2", "It arrives tomorrow.");
731
+ setBubbleOffsetTop(mount, "a2", 900);
732
+ metrics.setScrollHeight(1600);
733
+ emitStreamingAssistant(controller, "a2", "It arrives tomorrow morning.");
734
+ raf.flush();
735
+
736
+ expect(metrics.getScrollTop()).toBe(360);
737
+ expect(document.getSelection()!.toString()).toContain("parcel left the depot");
738
+ controller.destroy();
739
+ });
740
+
691
741
  it("re-arms on the next send after the reader scrolled away", () => {
692
742
  const raf = installRafMock();
693
743
  const mount = createMount();
@@ -701,15 +751,16 @@ describe("anchor-top first-unread positioning", () => {
701
751
  sc.dispatchEvent(new Event("scroll"));
702
752
 
703
753
  // 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.
754
+ // anchors (600 - 16 = 584) and holds while the reply streams beneath,
755
+ // including once the reply grows past the fold.
706
756
  emitUserMessage(controller, "u2", "And the second order?");
707
757
  setBubbleOffsetTop(mount, "u2", 600);
708
758
  metrics.setScrollHeight(1400);
709
759
  raf.flush();
760
+ expect(metrics.getScrollTop()).toBe(584);
710
761
 
711
762
  emitStreamingAssistant(controller, "a2", "That one shipped Tuesday.");
712
- setBubbleOffsetTop(mount, "a2", 800);
763
+ setBubbleOffsetTop(mount, "a2", 1100);
713
764
  metrics.setScrollHeight(1600);
714
765
  emitStreamingAssistant(controller, "a2", "That one shipped Tuesday!");
715
766
  raf.flush();
@@ -717,33 +768,78 @@ describe("anchor-top first-unread positioning", () => {
717
768
  expect(metrics.getScrollTop()).toBe(584);
718
769
  controller.destroy();
719
770
  });
771
+
772
+ it("releases the anchor for good when the reader jumps to the latest", () => {
773
+ const raf = installRafMock();
774
+ const mount = createMount();
775
+ const controller = createAgentExperience(mount, anchorTopConfig());
776
+ const sc = getScrollContainer(mount);
777
+ const metrics = installScrollMetrics(sc, { scrollHeight: 500, clientHeight: 400 });
778
+
779
+ // Short history: the anchor needs a spacer to rest the question at the
780
+ // top (284 + 400 - 500 + 24 slack = 208).
781
+ emitAssistantMessage(controller, "seed", "Earlier reply");
782
+ emitUserMessage(controller, "u1", "Where is my order?");
783
+ setBubbleOffsetTop(mount, "u1", 300);
784
+ raf.step(1);
785
+ expect(getSpacer(mount).style.height).toBe("208px");
786
+ // The mocked metrics don't see the spacer: apply it as a browser would.
787
+ metrics.setScrollHeight(708);
788
+ raf.flush();
789
+ expect(metrics.getScrollTop()).toBe(284);
790
+
791
+ for (let i = 0; i < 4; i += 1) emitToolMessage(controller, `t${i}`);
792
+ emitStreamingAssistant(controller, "a1", "Answer below the tools.");
793
+ setBubbleOffsetTop(mount, "a1", 760);
794
+ metrics.setScrollHeight(1400);
795
+ emitStreamingAssistant(controller, "a1", "Answer below the tools, longer.");
796
+ raf.flush();
797
+ expect(metrics.getScrollTop()).toBe(284);
798
+
799
+ const jump = getScrollToBottomButton(mount);
800
+ expect(jump.style.display).toBe("");
801
+ jump.click();
802
+ raf.flush();
803
+ expect(getSpacer(mount).style.display).toBe("none");
804
+ expect(metrics.getScrollTop()).toBe(metrics.getBottom());
805
+ const releasedAt = metrics.getScrollTop();
806
+
807
+ // More blocks, tool updates, and completion never reinstate the anchor.
808
+ emitToolMessage(controller, "t-late");
809
+ emitStreamingAssistant(controller, "a2", "One more thing.");
810
+ setBubbleOffsetTop(mount, "a2", 1300);
811
+ metrics.setScrollHeight(1600);
812
+ emitAssistantMessage(controller, "a2", "One more thing, done.");
813
+ raf.flush();
814
+ expect(getSpacer(mount).style.display).toBe("none");
815
+ expect(metrics.getScrollTop()).toBe(releasedAt);
816
+ controller.destroy();
817
+ });
720
818
  });
721
819
 
722
- // Two shapes the browser testbed covers but that are worth pinning down here,
723
- // since they are where the policy's judgement calls actually bite.
724
- describe("anchor-top first-unread — edge shapes", () => {
820
+ // Voice: the recognized user transcript grows in place under one message id
821
+ // (interim → final). It is one send: it anchors once and its updates never
822
+ // re-anchor or chase the reply.
823
+ describe("anchor-top voice transcript updates", () => {
725
824
  beforeEach(() => {
726
825
  installRafMock();
727
826
  });
728
827
 
729
- const anchorTopConfig = () =>
730
- baseConfig({
731
- features: { scrollBehavior: { mode: "anchor-top", anchorTopOffset: 16 } },
732
- });
733
-
734
- const emitTool = (
828
+ const emitVoiceUser = (
735
829
  controller: ReturnType<typeof createAgentExperience>,
736
- id: string
830
+ content: string,
831
+ isFinal: boolean
737
832
  ) => {
738
833
  controller.injectTestMessage({
739
834
  type: "message",
740
835
  message: {
741
- id,
742
- role: "assistant",
743
- content: "",
836
+ id: "voice-u1",
837
+ role: "user",
838
+ content,
744
839
  createdAt: CREATED_AT,
745
- variant: "tool",
746
- toolCall: { id, name: "search", status: "complete" },
840
+ streaming: false,
841
+ viaVoice: true,
842
+ voiceProcessing: !isFinal,
747
843
  },
748
844
  });
749
845
  };
@@ -751,86 +847,91 @@ describe("anchor-top first-unread — edge shapes", () => {
751
847
  const setTop = (mount: HTMLElement, id: string, value: number) => {
752
848
  const bubble = getScrollContainer(mount).querySelector<HTMLElement>(
753
849
  `[data-message-id="${id}"]`
850
+ )!;
851
+ Object.defineProperty(bubble, "offsetTop", { configurable: true, value });
852
+ };
853
+
854
+ const start = (mount: HTMLElement) => {
855
+ const controller = createAgentExperience(
856
+ mount,
857
+ baseConfig({ features: { scrollBehavior: { mode: "anchor-top", anchorTopOffset: 16 } } })
754
858
  );
755
- expect(bubble).not.toBeNull();
756
- Object.defineProperty(bubble!, "offsetTop", { configurable: true, value });
859
+ emitAssistantMessage(controller, "seed", "Earlier reply");
860
+ return controller;
757
861
  };
758
862
 
759
- // KNOWN TRADEOFF: a chatty preamble ("Let me look that up…") is itself the
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", () => {
863
+ it("keeps a growing transcript anchored from interim through final and the reply", () => {
767
864
  const raf = installRafMock();
768
865
  const mount = createMount();
769
- const controller = createAgentExperience(mount, anchorTopConfig());
770
- const metrics = installScrollMetrics(getScrollContainer(mount), {
771
- scrollHeight: 1200,
772
- clientHeight: 400,
773
- });
866
+ const controller = start(mount);
867
+ const sc = getScrollContainer(mount);
868
+ const metrics = installScrollMetrics(sc, { scrollHeight: 1000, clientHeight: 400 });
774
869
 
775
- emitAssistantMessage(controller, "seed", "Earlier reply");
776
- emitUserMessage(controller, "u1", "Look into my order");
777
- setTop(mount, "u1", 300);
870
+ emitVoiceUser(controller, "Book a", false);
871
+ setTop(mount, "voice-u1", 300);
778
872
  raf.flush();
873
+ expect(metrics.getScrollTop()).toBe(284);
779
874
 
780
- // A tall question strands the preamble (delta 400 > 400 - 96): handoff.
781
- emitStreamingAssistant(controller, "pre", "Sure — let me pull that up.");
782
- setTop(mount, "pre", 700);
783
- emitStreamingAssistant(controller, "pre", "Sure — let me pull that up!");
875
+ // Interim text grows the same bubble past the viewport.
876
+ const words = "table for four near the station on Friday evening ".repeat(20);
877
+ emitVoiceUser(controller, `Book a ${words}`, false);
878
+ metrics.setScrollHeight(1600);
784
879
  raf.flush();
785
- expect(metrics.getScrollTop()).toBe(684);
786
-
787
- for (let i = 0; i < 6; i += 1) emitTool(controller, `t${i}`);
788
- emitStreamingAssistant(controller, "answer", "Good news — it is on track.");
789
- setTop(mount, "answer", 1300);
790
- metrics.setScrollHeight(1800);
880
+ emitVoiceUser(controller, `Book a ${words}please.`, true);
791
881
  raf.flush();
882
+ expect(metrics.getScrollTop()).toBe(284);
792
883
 
793
- // Still parked on the preamble: the answer is NOT re-targeted.
794
- expect(metrics.getScrollTop()).toBe(684);
884
+ // The reply lands entirely below the long transcript.
885
+ emitStreamingAssistant(controller, "voice-a1", "");
886
+ emitStreamingAssistant(controller, "voice-a1", "Done — four seats at 7pm.");
887
+ setTop(mount, "voice-a1", 1500);
888
+ metrics.setScrollHeight(1900);
889
+ emitAssistantMessage(controller, "voice-a1", "Done — four seats at 7pm on Friday.");
890
+ raf.flush();
891
+ expect(metrics.getScrollTop()).toBe(284);
795
892
  controller.destroy();
796
893
  });
797
894
 
798
- it("holds the sent message when a turn produces only tool calls", () => {
895
+ it("does not pull a reader back when the transcript updates after they scroll", () => {
799
896
  const raf = installRafMock();
800
897
  const mount = createMount();
801
- const controller = createAgentExperience(mount, anchorTopConfig());
802
- const metrics = installScrollMetrics(getScrollContainer(mount), {
803
- scrollHeight: 1000,
804
- clientHeight: 400,
805
- });
898
+ const controller = start(mount);
899
+ const sc = getScrollContainer(mount);
900
+ const metrics = installScrollMetrics(sc, { scrollHeight: 1000, clientHeight: 400 });
806
901
 
807
- emitAssistantMessage(controller, "seed", "Earlier reply");
808
- emitUserMessage(controller, "u1", "Just refresh the tracking data");
809
- setTop(mount, "u1", 300);
902
+ emitVoiceUser(controller, "What's the", false);
903
+ setTop(mount, "voice-u1", 300);
810
904
  raf.flush();
811
905
  expect(metrics.getScrollTop()).toBe(284);
812
906
 
813
- for (let i = 0; i < 6; i += 1) emitTool(controller, `t${i}`);
814
- metrics.setScrollHeight(1600);
907
+ // The reader scrolls up to re-read the earlier reply mid-utterance.
908
+ metrics.setScrollTop(40);
909
+ sc.dispatchEvent(new WheelEvent("wheel", { deltaY: -120, bubbles: true }));
910
+ sc.dispatchEvent(new Event("scroll"));
911
+
912
+ emitVoiceUser(controller, "What's the weather", false);
913
+ raf.flush();
914
+ emitVoiceUser(controller, "What's the weather tomorrow?", true);
915
+ emitStreamingAssistant(controller, "voice-a1", "Sunny, 21°C.");
916
+ metrics.setScrollHeight(1200);
815
917
  raf.flush();
816
918
 
817
- // Nothing attention-worthy ever arrived: never chase the tool bubbles.
818
- expect(metrics.getScrollTop()).toBe(284);
919
+ expect(metrics.getScrollTop()).toBe(40);
819
920
  controller.destroy();
820
921
  });
821
922
  });
822
923
 
823
924
  // Regression: streaming content under a pinned anchor makes the browser clamp
824
- // scrollTop and then restore it — a matched pair of real scroll events with no
825
- // user involved (observed live as -12 then +12 during a tool run). Treating
826
- // those as reader intent silently disabled every later re-anchor, so a
827
- // response that opened with tool calls stayed stuck on the sent message.
828
- describe("anchor-top first-unread — layout scroll is not reader intent", () => {
925
+ // scrollTop and then restore it: a matched pair of real scroll events with no
926
+ // user involved (observed live as -12 then +12 during a tool run). Those must
927
+ // not read as the reader taking over, or a later layout re-pin (host move)
928
+ // would stand down.
929
+ describe("anchor-top — layout scroll is not reader intent", () => {
829
930
  beforeEach(() => {
830
931
  installRafMock();
831
932
  });
832
933
 
833
- it("still anchors the answer after a clamp/restore scroll pair", () => {
934
+ it("keeps the anchor and its spacer through a clamp/restore scroll pair", () => {
834
935
  const raf = installRafMock();
835
936
  const mount = createMount();
836
937
  const controller = createAgentExperience(
@@ -840,17 +941,22 @@ describe("anchor-top first-unread — layout scroll is not reader intent", () =>
840
941
  })
841
942
  );
842
943
  const sc = getScrollContainer(mount);
843
- const metrics = installScrollMetrics(sc, { scrollHeight: 1000, clientHeight: 400 });
944
+ const metrics = installScrollMetrics(sc, { scrollHeight: 600, clientHeight: 400 });
844
945
 
845
946
  emitAssistantMessage(controller, "seed", "Earlier reply");
846
947
  emitUserMessage(controller, "u1", "Where is my order?");
847
948
  const userBubble = sc.querySelector<HTMLElement>('[data-message-id="u1"]');
848
949
  Object.defineProperty(userBubble!, "offsetTop", { configurable: true, value: 300 });
950
+ raf.step(1);
951
+ const spacer = sc.querySelector<HTMLElement>("[data-persona-anchor-spacer]")!;
952
+ // 284 + 400 - 600 + 24 slack.
953
+ expect(spacer.style.height).toBe("108px");
954
+ metrics.setScrollHeight(708);
849
955
  raf.flush();
850
956
  expect(metrics.getScrollTop()).toBe(284);
851
957
 
852
- // Tool run: the spacer shrinks, scrollTop is clamped down, then the
853
- // growing transcript restores it. Both are layout, not the reader.
958
+ // Tool run: scrollTop is clamped down, then the growing transcript
959
+ // restores it. Both are layout, not the reader.
854
960
  for (let i = 0; i < 6; i += 1) {
855
961
  controller.injectTestMessage({
856
962
  type: "message",
@@ -866,21 +972,17 @@ describe("anchor-top first-unread — layout scroll is not reader intent", () =>
866
972
  }
867
973
  metrics.setScrollTop(272);
868
974
  sc.dispatchEvent(new Event("scroll"));
869
- metrics.setScrollHeight(1500);
870
975
  metrics.setScrollTop(284);
871
976
  sc.dispatchEvent(new Event("scroll"));
872
- raf.flush();
873
977
 
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.
877
978
  emitStreamingAssistant(controller, "a1", "Good news — your order is on track.");
878
979
  const answer = sc.querySelector<HTMLElement>('[data-message-id="a1"]');
879
980
  Object.defineProperty(answer!, "offsetTop", { configurable: true, value: 700 });
880
981
  emitStreamingAssistant(controller, "a1", "Good news — your order is on track!");
881
982
  raf.flush();
882
983
 
883
- expect(metrics.getScrollTop()).toBe(684);
984
+ expect(metrics.getScrollTop()).toBe(284);
985
+ expect(spacer.style.display).toBe("");
884
986
  controller.destroy();
885
987
  });
886
988
  });