@runtypelabs/persona 4.12.0 → 4.14.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 (68) hide show
  1. package/dist/codegen.cjs +1 -1
  2. package/dist/codegen.js +1 -1
  3. package/dist/context-mentions.d.cts +98 -6
  4. package/dist/context-mentions.d.ts +98 -6
  5. package/dist/index.cjs +64 -64
  6. package/dist/index.cjs.map +1 -1
  7. package/dist/index.d.cts +183 -8
  8. package/dist/index.d.ts +183 -8
  9. package/dist/index.global.js +54 -54
  10. package/dist/index.global.js.map +1 -1
  11. package/dist/index.js +67 -67
  12. package/dist/index.js.map +1 -1
  13. package/dist/launcher.global.js +1 -1
  14. package/dist/launcher.global.js.map +1 -1
  15. package/dist/smart-dom-reader.d.cts +98 -6
  16. package/dist/smart-dom-reader.d.ts +98 -6
  17. package/dist/theme-editor-preview.cjs +66 -66
  18. package/dist/theme-editor-preview.d.cts +100 -6
  19. package/dist/theme-editor-preview.d.ts +100 -6
  20. package/dist/theme-editor-preview.js +75 -75
  21. package/dist/theme-editor.cjs +10 -10
  22. package/dist/theme-editor.d.cts +134 -7
  23. package/dist/theme-editor.d.ts +134 -7
  24. package/dist/theme-editor.js +10 -10
  25. package/dist/theme-reference.cjs +1 -1
  26. package/dist/widget.css +1 -1
  27. package/package.json +1 -1
  28. package/src/artifacts-session.test.ts +106 -0
  29. package/src/client.test.ts +2 -2
  30. package/src/client.ts +4 -1
  31. package/src/components/artifact-card.ts +13 -2
  32. package/src/components/artifact-inline.ts +11 -2
  33. package/src/components/composer-builder.test.ts +2 -0
  34. package/src/components/composer-builder.ts +1 -1
  35. package/src/components/composer-parts.test.ts +2 -0
  36. package/src/components/composer-parts.ts +1 -1
  37. package/src/components/message-bubble.test.ts +7 -1
  38. package/src/components/message-bubble.ts +11 -8
  39. package/src/index-core.ts +34 -0
  40. package/src/session.ts +45 -4
  41. package/src/styles/widget.css +10 -1
  42. package/src/theme-editor/index.ts +5 -0
  43. package/src/theme-editor/presets.test.ts +43 -0
  44. package/src/theme-editor/presets.ts +101 -87
  45. package/src/theme-editor/sections.test.ts +39 -7
  46. package/src/theme-editor/sections.ts +60 -8
  47. package/src/theme-editor/state-preferences.test.ts +15 -0
  48. package/src/theme-editor/state.ts +46 -0
  49. package/src/theme-editor/types.ts +15 -1
  50. package/src/theme-editor/webmcp/tools.test.ts +13 -0
  51. package/src/theme-editor/webmcp/tools.ts +5 -0
  52. package/src/types.ts +139 -9
  53. package/src/ui.artifact-pane-gating.test.ts +5 -5
  54. package/src/ui.config-preferences.test.ts +166 -0
  55. package/src/ui.scroll-additive.test.ts +365 -0
  56. package/src/ui.scroll.test.ts +19 -6
  57. package/src/ui.ts +158 -53
  58. package/src/ui.typing-indicator.test.ts +135 -0
  59. package/src/utils/artifact-display.test.ts +180 -16
  60. package/src/utils/artifact-display.ts +155 -14
  61. package/src/utils/auto-follow.test.ts +38 -0
  62. package/src/utils/auto-follow.ts +35 -0
  63. package/src/utils/config-merge.test.ts +60 -0
  64. package/src/utils/config-merge.ts +6 -1
  65. package/src/utils/feature-preferences.test.ts +326 -0
  66. package/src/utils/feature-preferences.ts +498 -0
  67. package/src/utils/theme.test.ts +92 -0
  68. package/src/utils/tokens.ts +7 -7
@@ -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.