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