@runtypelabs/persona 4.11.1 → 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.11.1",
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",
@@ -23,6 +23,49 @@ describe("codegen subpath", () => {
23
23
  expect(code).not.toContain("@latest");
24
24
  });
25
25
 
26
+ describe("cdnBase option", () => {
27
+ const cdnBase = "https://cdn.runtype.com/persona/latest";
28
+
29
+ it("emits the custom base in every script format", () => {
30
+ expect(fromSubpath(config, "script-installer", { cdnBase })).toContain(
31
+ `src="${cdnBase}/install.global.js"`
32
+ );
33
+ const manual = fromSubpath(config, "script-manual", { cdnBase });
34
+ expect(manual).toContain(`href="${cdnBase}/widget.css"`);
35
+ expect(manual).toContain(`src="${cdnBase}/index.global.js"`);
36
+ expect(fromSubpath(config, "script-advanced", { cdnBase })).toContain(
37
+ `var CDN_BASE = '${cdnBase}';`
38
+ );
39
+ });
40
+
41
+ it("tolerates a trailing slash", () => {
42
+ const code = fromSubpath(config, "script-installer", { cdnBase: `${cdnBase}/` });
43
+ expect(code).toContain(`src="${cdnBase}/install.global.js"`);
44
+ });
45
+
46
+ it("percent-encodes characters that could break the emitted JS/HTML contexts", () => {
47
+ const hostile = "https://cdn.example.com/x'\"\\`/persona";
48
+ const encoded = "https://cdn.example.com/x%27%22%5C%60/persona";
49
+ // script-advanced emits into a single-quoted JS literal
50
+ expect(fromSubpath(config, "script-advanced", { cdnBase: hostile })).toContain(
51
+ `var CDN_BASE = '${encoded}';`
52
+ );
53
+ // script-installer / script-manual emit into double-quoted HTML attributes
54
+ expect(fromSubpath(config, "script-installer", { cdnBase: hostile })).toContain(
55
+ `src="${encoded}/install.global.js"`
56
+ );
57
+ expect(fromSubpath(config, "script-manual", { cdnBase: hostile })).toContain(
58
+ `href="${encoded}/widget.css"`
59
+ );
60
+ });
61
+
62
+ it("leaves no jsDelivr URL behind when a custom base is set", () => {
63
+ for (const format of ["script-installer", "script-manual", "script-advanced"] as const) {
64
+ expect(fromSubpath(config, format, { cdnBase })).not.toContain("jsdelivr");
65
+ }
66
+ });
67
+ });
68
+
26
69
  describe("mount target option", () => {
27
70
  it("defaults to body when no target is given", () => {
28
71
  expect(fromSubpath(config, "react-component")).toContain("target: 'body'");
@@ -343,6 +343,89 @@ describe("install.ts: deferral gate", () => {
343
343
  });
344
344
  });
345
345
 
346
+ describe("install.ts: CDN base derivation", () => {
347
+ // The installer captures document.currentScript.src synchronously at module
348
+ // evaluation; fake it with an own-property override (jsdom's getter lives on
349
+ // the prototype, so a configurable instance property shadows it cleanly).
350
+ function setCurrentScript(src: string) {
351
+ const script = document.createElement("script");
352
+ script.src = src;
353
+ Object.defineProperty(document, "currentScript", { value: script, configurable: true });
354
+ }
355
+
356
+ afterEach(() => {
357
+ delete (document as any).currentScript;
358
+ });
359
+
360
+ /**
361
+ * Run the deferred path with NO bundles provided so loadCSS/loadLauncher
362
+ * inject real (never-resolving in jsdom) elements whose URLs we can read.
363
+ */
364
+ async function installedUrls(installerOptions: any) {
365
+ await install({ config: { apiUrl: "/api", launcher: { enabled: true } }, ...installerOptions });
366
+ await flush();
367
+ const css = document.head.querySelector("link[rel=\"stylesheet\"][data-persona]") as HTMLLinkElement | null;
368
+ const scripts = Array.from(document.head.querySelectorAll("script")).map((s) => s.src);
369
+ return { cssHref: css?.href ?? null, scripts };
370
+ }
371
+
372
+ it("derives asset URLs from the installer's own script directory (first-party CDN)", async () => {
373
+ setCurrentScript("https://cdn.runtype.com/persona/latest/install.global.js");
374
+ const { cssHref, scripts } = await installedUrls({});
375
+ expect(cssHref).toBe("https://cdn.runtype.com/persona/latest/widget.css");
376
+ expect(scripts).toContain("https://cdn.runtype.com/persona/latest/launcher.global.js");
377
+ });
378
+
379
+ it("keeps npm-CDN siblings on the installer's own pinned range (no @latest skew)", async () => {
380
+ setCurrentScript("https://cdn.jsdelivr.net/npm/@runtypelabs/persona@4/dist/install.global.js");
381
+ const { cssHref, scripts } = await installedUrls({});
382
+ expect(cssHref).toBe("https://cdn.jsdelivr.net/npm/@runtypelabs/persona@4/dist/widget.css");
383
+ expect(scripts).toContain("https://cdn.jsdelivr.net/npm/@runtypelabs/persona@4/dist/launcher.global.js");
384
+ });
385
+
386
+ it("the full bundle URL follows the installer directory on the eager path", async () => {
387
+ setCurrentScript("https://cdn.runtype.com/persona/latest/install.global.js");
388
+ markCssLoaded();
389
+ await install({ config: { apiUrl: "/api", launcher: { enabled: false } } });
390
+ await flush();
391
+ const scripts = Array.from(document.head.querySelectorAll("script")).map((s) => s.src);
392
+ expect(scripts).toContain("https://cdn.runtype.com/persona/latest/index.global.js");
393
+ });
394
+
395
+ it("falls back to jsDelivr@latest when currentScript is unavailable", async () => {
396
+ // No setCurrentScript: module/inline contexts have no script src.
397
+ const { cssHref } = await installedUrls({});
398
+ expect(cssHref).toBe("https://cdn.jsdelivr.net/npm/@runtypelabs/persona@latest/dist/widget.css");
399
+ });
400
+
401
+ it("falls back to the npm CDN for a non-http(s) installer source", async () => {
402
+ setCurrentScript("data:text/javascript,void%200");
403
+ const { cssHref } = await installedUrls({});
404
+ expect(cssHref).toBe("https://cdn.jsdelivr.net/npm/@runtypelabs/persona@latest/dist/widget.css");
405
+ });
406
+
407
+ it("an explicit `version` opts back into npm-CDN URLs even when the src is derivable", async () => {
408
+ setCurrentScript("https://cdn.runtype.com/persona/latest/install.global.js");
409
+ const { cssHref } = await installedUrls({ version: "1.2.3" });
410
+ expect(cssHref).toBe("https://cdn.jsdelivr.net/npm/@runtypelabs/persona@1.2.3/dist/widget.css");
411
+ });
412
+
413
+ it("an explicit `cdn` opts back into npm-CDN URLs even when the src is derivable", async () => {
414
+ setCurrentScript("https://cdn.runtype.com/persona/latest/install.global.js");
415
+ const { cssHref } = await installedUrls({ cdn: "unpkg" });
416
+ expect(cssHref).toBe("https://unpkg.com/@runtypelabs/persona@latest/dist/widget.css");
417
+ });
418
+
419
+ it("explicit cssUrl + jsUrl win over the derived installer base", async () => {
420
+ setCurrentScript("https://cdn.runtype.com/persona/latest/install.global.js");
421
+ const { cssHref } = await installedUrls({
422
+ cssUrl: "https://assets.example.com/persona/widget.css",
423
+ jsUrl: "https://assets.example.com/persona/index.global.js",
424
+ });
425
+ expect(cssHref).toBe("https://assets.example.com/persona/widget.css");
426
+ });
427
+ });
428
+
346
429
  describe("install.ts: onError", () => {
347
430
  it("fires onError with phase 'init' and dispatches persona:error when initialization throws", async () => {
348
431
  markCssLoaded();
package/src/install.ts CHANGED
@@ -7,6 +7,9 @@
7
7
  export {};
8
8
 
9
9
  interface SiteAgentInstallConfig {
10
+ // Setting `version` or `cdn` explicitly opts into npm-CDN asset URLs
11
+ // (jsDelivr/unpkg). When neither is set, assets load from the directory the
12
+ // installer script itself was served from — see getCdnBase().
10
13
  version?: string;
11
14
  cdn?: "unpkg" | "jsdelivr";
12
15
  cssUrl?: string;
@@ -137,6 +140,10 @@ declare global {
137
140
  // Get config from script attributes (must be called synchronously during script execution)
138
141
  const scriptConfig = getConfigFromScript();
139
142
 
143
+ // The installer's own URL, captured now because document.currentScript is
144
+ // null once synchronous execution ends (and always inside modules).
145
+ const installerSrc = (document.currentScript as HTMLScriptElement | null)?.src || "";
146
+
140
147
  // Merge script attributes with window config (script attributes take precedence)
141
148
  const windowConfig: SiteAgentInstallConfig = window.siteAgentConfig || {};
142
149
  const config: SiteAgentInstallConfig = { ...windowConfig, ...scriptConfig };
@@ -194,7 +201,28 @@ declare global {
194
201
  const cdn = config.cdn || "jsdelivr";
195
202
  const autoInit = config.autoInit !== false; // Default to true
196
203
 
197
- // Determine CDN base URL
204
+ // Directory the installer itself was served from. Every published layout
205
+ // (npm CDN dist/, first-party CDN, self-hosted copies) keeps the sibling
206
+ // assets next to install.global.js, so this base is valid wherever the
207
+ // installer loaded from — and always satisfies the same CSP/origin rules
208
+ // that let the installer through. Non-http(s) sources (inline, blob:) and
209
+ // module contexts (no currentScript) yield null.
210
+ const getInstallerBase = (): string | null => {
211
+ if (!installerSrc) return null;
212
+ try {
213
+ const url = new URL(installerSrc, document.baseURI);
214
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
215
+ return url.origin + url.pathname.slice(0, url.pathname.lastIndexOf("/"));
216
+ } catch {
217
+ return null;
218
+ }
219
+ };
220
+
221
+ // Determine CDN base URL. Precedence:
222
+ // 1. explicit cssUrl + jsUrl
223
+ // 2. explicit `cdn` / `version` → npm CDN construction
224
+ // 3. the installer's own directory (default)
225
+ // 4. npm CDN construction (currentScript unavailable)
198
226
  const getCdnBase = () => {
199
227
  // For a custom URL override, derive the sibling launcher URL when the
200
228
  // override mirrors the dist layout (…/index.global.js → …/launcher.global.js)
@@ -209,8 +237,26 @@ declare global {
209
237
  };
210
238
  }
211
239
 
240
+ // Default: load assets from wherever the installer was served, so a page
241
+ // whose CSP allows only its own CDN (e.g. cdn.runtype.com) never has
242
+ // sibling requests fan out to a third-party host. An explicit `cdn` or
243
+ // `version` keeps the documented npm-CDN behavior.
244
+ if (!config.cdn && !config.version) {
245
+ const selfBase = getInstallerBase();
246
+ if (selfBase) {
247
+ return {
248
+ cssUrl: `${selfBase}/widget.css`,
249
+ jsUrl: `${selfBase}/index.global.js`,
250
+ launcherUrl: `${selfBase}/launcher.global.js` as string | null,
251
+ };
252
+ }
253
+ }
254
+
212
255
  const packageName = "@runtypelabs/persona";
213
- const basePath = `/npm/${packageName}@${version}/dist`;
256
+ // jsDelivr namespaces npm packages under /npm/; unpkg serves them at the root.
257
+ const basePath = cdn === "unpkg"
258
+ ? `/${packageName}@${version}/dist`
259
+ : `/npm/${packageName}@${version}/dist`;
214
260
  const host = cdn === "unpkg" ? "https://unpkg.com" : "https://cdn.jsdelivr.net";
215
261
 
216
262
  return {
@@ -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.