@runtypelabs/persona 3.29.1 → 3.31.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/README.md +15 -2547
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-B_Qazlm4.d.cts → types-quh7NmYD.d.cts} +9 -0
  5. package/dist/animations/{types-B_Qazlm4.d.ts → types-quh7NmYD.d.ts} +9 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/codegen.cjs +6 -6
  9. package/dist/codegen.js +4 -4
  10. package/dist/index.cjs +50 -50
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +164 -7
  13. package/dist/index.d.ts +164 -7
  14. package/dist/index.global.js +60 -60
  15. package/dist/index.global.js.map +1 -1
  16. package/dist/index.js +49 -49
  17. package/dist/index.js.map +1 -1
  18. package/dist/launcher.global.js +2 -2
  19. package/dist/launcher.global.js.map +1 -1
  20. package/dist/plugin-kit.cjs +1 -0
  21. package/dist/plugin-kit.d.cts +98 -0
  22. package/dist/plugin-kit.d.ts +98 -0
  23. package/dist/plugin-kit.js +1 -0
  24. package/dist/smart-dom-reader.d.cts +157 -4
  25. package/dist/smart-dom-reader.d.ts +157 -4
  26. package/dist/theme-editor.cjs +40 -40
  27. package/dist/theme-editor.d.cts +161 -6
  28. package/dist/theme-editor.d.ts +161 -6
  29. package/dist/theme-editor.js +40 -40
  30. package/dist/theme-reference.cjs +1 -1
  31. package/dist/theme-reference.d.cts +2 -0
  32. package/dist/theme-reference.d.ts +2 -0
  33. package/dist/theme-reference.js +1 -1
  34. package/dist/widget.css +19 -0
  35. package/package.json +8 -2
  36. package/src/client.ts +6 -0
  37. package/src/components/approval-bubble.test.ts +320 -0
  38. package/src/components/approval-bubble.ts +190 -20
  39. package/src/components/launcher.ts +6 -3
  40. package/src/components/panel.ts +7 -1
  41. package/src/components/tool-bubble.test.ts +39 -0
  42. package/src/components/tool-bubble.ts +4 -0
  43. package/src/defaults.ts +14 -0
  44. package/src/generated/runtype-openapi-contract.ts +4 -0
  45. package/src/index-core.ts +1 -0
  46. package/src/plugin-kit.test.ts +230 -0
  47. package/src/plugin-kit.ts +294 -0
  48. package/src/plugins/types.ts +49 -2
  49. package/src/session.test.ts +161 -0
  50. package/src/session.ts +41 -3
  51. package/src/styles/widget.css +19 -0
  52. package/src/theme-editor/preview-utils.test.ts +10 -0
  53. package/src/theme-editor/preview-utils.ts +29 -1
  54. package/src/theme-editor/sections.test.ts +17 -0
  55. package/src/theme-editor/sections.ts +31 -0
  56. package/src/theme-reference.ts +2 -2
  57. package/src/types/theme.ts +2 -0
  58. package/src/types.ts +109 -2
  59. package/src/ui.approval-plugin.test.ts +204 -0
  60. package/src/ui.scroll.test.ts +383 -0
  61. package/src/ui.ts +539 -56
  62. package/src/utils/auto-follow.test.ts +91 -0
  63. package/src/utils/auto-follow.ts +68 -0
  64. package/src/utils/theme.test.ts +6 -2
  65. package/src/utils/theme.ts +0 -8
  66. package/src/utils/tokens.ts +6 -1
  67. package/src/webmcp-bridge.test.ts +66 -0
  68. package/src/webmcp-bridge.ts +49 -0
@@ -61,6 +61,27 @@ const installRafMock = () => {
61
61
  };
62
62
  };
63
63
 
64
+ const installResizeObserverMock = () => {
65
+ const triggers: Array<() => void> = [];
66
+
67
+ class ResizeObserverMock {
68
+ constructor(callback: (entries: unknown[], observer: unknown) => void) {
69
+ triggers.push(() => callback([], this));
70
+ }
71
+ observe() {}
72
+ unobserve() {}
73
+ disconnect() {}
74
+ }
75
+
76
+ vi.stubGlobal("ResizeObserver", ResizeObserverMock);
77
+
78
+ return {
79
+ trigger() {
80
+ triggers.forEach((fire) => fire());
81
+ }
82
+ };
83
+ };
84
+
64
85
  const installScrollMetrics = (
65
86
  element: HTMLElement,
66
87
  initial: { scrollHeight: number; clientHeight: number }
@@ -123,6 +144,22 @@ const emitStreamingMessage = (
123
144
  });
124
145
  };
125
146
 
147
+ const emitUserMessage = (
148
+ controller: ReturnType<typeof createAgentExperience>,
149
+ id: string,
150
+ content = "Hello"
151
+ ) => {
152
+ controller.injectTestMessage({
153
+ type: "message",
154
+ message: {
155
+ id,
156
+ role: "user",
157
+ content,
158
+ createdAt: STREAM_CREATED_AT
159
+ }
160
+ });
161
+ };
162
+
126
163
  const emitReasoningMessage = (
127
164
  controller: ReturnType<typeof createAgentExperience>,
128
165
  chunks: string[]
@@ -200,7 +237,12 @@ describe("createAgentExperience streaming scroll", () => {
200
237
 
201
238
  afterEach(() => {
202
239
  document.body.innerHTML = "";
240
+ // Widgets persist chat history to localStorage by default; without
241
+ // clearing it, a later widget restores an earlier test's messages at
242
+ // construction and "new message" assertions see stale ids.
243
+ localStorage.clear();
203
244
  vi.restoreAllMocks();
245
+ vi.unstubAllGlobals();
204
246
  });
205
247
 
206
248
  it("stops auto-follow after a small upward scroll during streaming", () => {
@@ -744,4 +786,345 @@ describe("createAgentExperience streaming scroll", () => {
744
786
  controller.destroy();
745
787
  });
746
788
 
789
+ it("re-pins to the bottom when content grows without a render event", () => {
790
+ const raf = installRafMock();
791
+ const resize = installResizeObserverMock();
792
+ const mount = createMount();
793
+ const controller = createAgentExperience(mount, {
794
+ apiUrl: "https://api.example.com/chat",
795
+ launcher: { enabled: false }
796
+ });
797
+
798
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
799
+ const metrics = installScrollMetrics(scrollContainer!, {
800
+ scrollHeight: 1000,
801
+ clientHeight: 400
802
+ });
803
+
804
+ emitStreamingStatus(controller);
805
+ emitStreamingMessage(controller, "First chunk");
806
+ raf.flush();
807
+
808
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
809
+
810
+ // Content grows with no render event (e.g. an image finishing loading
811
+ // mid-stream) — only the ResizeObserver sees it.
812
+ metrics.setScrollHeight(1200);
813
+ resize.trigger();
814
+ raf.flush();
815
+
816
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
817
+
818
+ controller.destroy();
819
+ });
820
+
821
+ it("does not yank a paused reader when content grows without a render event", () => {
822
+ const raf = installRafMock();
823
+ const resize = installResizeObserverMock();
824
+ const mount = createMount();
825
+ const controller = createAgentExperience(mount, {
826
+ apiUrl: "https://api.example.com/chat",
827
+ launcher: { enabled: false }
828
+ });
829
+
830
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
831
+ const metrics = installScrollMetrics(scrollContainer!, {
832
+ scrollHeight: 1000,
833
+ clientHeight: 400
834
+ });
835
+
836
+ emitStreamingStatus(controller);
837
+ emitStreamingMessage(controller, "First chunk");
838
+ raf.flush();
839
+
840
+ scrollContainer!.dispatchEvent(new WheelEvent("wheel", { deltaY: -24 }));
841
+ metrics.setScrollTop(420);
842
+
843
+ metrics.setScrollHeight(1200);
844
+ resize.trigger();
845
+ raf.flush();
846
+
847
+ expect(metrics.getScrollTop()).toBe(420);
848
+
849
+ controller.destroy();
850
+ });
851
+
852
+ it("pauses auto-follow while the user selects transcript text during streaming", () => {
853
+ const raf = installRafMock();
854
+ const mount = createMount();
855
+ const controller = createAgentExperience(mount, {
856
+ apiUrl: "https://api.example.com/chat",
857
+ launcher: { enabled: false }
858
+ });
859
+
860
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
861
+ const metrics = installScrollMetrics(scrollContainer!, {
862
+ scrollHeight: 1000,
863
+ clientHeight: 400
864
+ });
865
+
866
+ emitStreamingStatus(controller);
867
+ emitStreamingMessage(controller, "First chunk");
868
+ raf.flush();
869
+
870
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
871
+
872
+ // A selection forms inside the transcript (mouse drag or keyboard —
873
+ // both surface as selectionchange).
874
+ let currentSelection: Partial<Selection> | null = {
875
+ isCollapsed: false,
876
+ anchorNode: scrollContainer,
877
+ focusNode: scrollContainer
878
+ };
879
+ vi.spyOn(document, "getSelection").mockImplementation(
880
+ () => currentSelection as Selection | null
881
+ );
882
+ document.dispatchEvent(new Event("selectionchange"));
883
+
884
+ metrics.setScrollHeight(1100);
885
+ emitStreamingMessage(controller, "Second chunk");
886
+ raf.flush();
887
+
888
+ // Auto-follow paused: the selection isn't dragged out from under the user.
889
+ expect(metrics.getScrollTop()).toBe(600);
890
+
891
+ // Drag-selecting toward the bottom edge auto-scrolls down — that must
892
+ // not read as a resume gesture while the selection is still active.
893
+ metrics.setScrollTop(metrics.getBottomScrollTop());
894
+ scrollContainer!.dispatchEvent(new Event("scroll"));
895
+ const heldPosition = metrics.getScrollTop();
896
+ metrics.setScrollHeight(1200);
897
+ emitStreamingMessage(controller, "Third chunk");
898
+ raf.flush();
899
+ expect(metrics.getScrollTop()).toBe(heldPosition);
900
+
901
+ // Once the selection clears, scrolling down near the bottom resumes.
902
+ currentSelection = null;
903
+ metrics.setScrollTop(metrics.getBottomScrollTop());
904
+ scrollContainer!.dispatchEvent(new Event("scroll"));
905
+ metrics.setScrollHeight(1300);
906
+ emitStreamingMessage(controller, "Fourth chunk");
907
+ raf.flush();
908
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
909
+
910
+ controller.destroy();
911
+ });
912
+
913
+ it("re-sticks to the bottom when the user sends a message after scrolling up", () => {
914
+ const raf = installRafMock();
915
+ const mount = createMount();
916
+ const controller = createAgentExperience(mount, {
917
+ apiUrl: "https://api.example.com/chat",
918
+ launcher: { enabled: false }
919
+ });
920
+
921
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
922
+ const metrics = installScrollMetrics(scrollContainer!, {
923
+ scrollHeight: 1000,
924
+ clientHeight: 400
925
+ });
926
+
927
+ emitStreamingStatus(controller);
928
+ emitStreamingMessage(controller, "First chunk");
929
+ raf.flush();
930
+
931
+ scrollContainer!.dispatchEvent(new WheelEvent("wheel", { deltaY: -24 }));
932
+ metrics.setScrollTop(300);
933
+
934
+ emitUserMessage(controller, "user-resend", "Follow-up question");
935
+ raf.flush();
936
+
937
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
938
+
939
+ controller.destroy();
940
+ });
941
+
942
+ it("shows a count badge for messages that arrive while paused", () => {
943
+ const raf = installRafMock();
944
+ const mount = createMount();
945
+ const controller = createAgentExperience(mount, {
946
+ apiUrl: "https://api.example.com/chat",
947
+ launcher: { enabled: false }
948
+ });
949
+
950
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
951
+ const metrics = installScrollMetrics(scrollContainer!, {
952
+ scrollHeight: 1000,
953
+ clientHeight: 400
954
+ });
955
+
956
+ emitStreamingStatus(controller);
957
+ emitStreamingMessage(controller, "First chunk");
958
+ raf.flush();
959
+
960
+ scrollContainer!.dispatchEvent(new WheelEvent("wheel", { deltaY: -24 }));
961
+ metrics.setScrollTop(300);
962
+
963
+ controller.injectTestMessage({
964
+ type: "message",
965
+ message: {
966
+ id: "ast-while-paused",
967
+ role: "assistant",
968
+ content: "Another answer",
969
+ createdAt: STREAM_CREATED_AT
970
+ }
971
+ });
972
+ raf.flush();
973
+
974
+ const badge = mount.querySelector<HTMLElement>(
975
+ "[data-persona-scroll-to-bottom-count]"
976
+ );
977
+ expect(badge?.textContent).toBe("1");
978
+ expect(badge?.style.display).not.toBe("none");
979
+
980
+ // Jumping back to the latest clears the count.
981
+ getScrollToBottomButton(mount)!.click();
982
+ raf.flush();
983
+ expect(badge?.textContent).toBe("");
984
+ expect(badge?.style.display).toBe("none");
985
+
986
+ controller.destroy();
987
+ });
988
+
989
+ it("anchor-top mode pins the sent user message near the viewport top and never follows the stream", () => {
990
+ const raf = installRafMock();
991
+ const resize = installResizeObserverMock();
992
+ const mount = createMount();
993
+ const controller = createAgentExperience(mount, {
994
+ apiUrl: "https://api.example.com/chat",
995
+ launcher: { enabled: false },
996
+ features: {
997
+ scrollBehavior: { mode: "anchor-top", anchorTopOffset: 16 }
998
+ }
999
+ } as any);
1000
+
1001
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
1002
+ const metrics = installScrollMetrics(scrollContainer!, {
1003
+ scrollHeight: 1000,
1004
+ clientHeight: 400
1005
+ });
1006
+
1007
+ emitStreamingStatus(controller);
1008
+ emitUserMessage(controller, "user-anchor", "Long question");
1009
+
1010
+ // Give the rendered bubble layout geometry before the anchor rAF runs.
1011
+ const bubble = scrollContainer!.querySelector<HTMLElement>(
1012
+ '[data-message-id="user-anchor"]'
1013
+ );
1014
+ expect(bubble).not.toBeNull();
1015
+ Object.defineProperty(bubble!, "offsetTop", { value: 700 });
1016
+
1017
+ // Run just the anchor frame: it sizes the spacer and starts the scroll.
1018
+ raf.step(1);
1019
+
1020
+ // target = 700 - 16 = 684; spacer = 684 + 400 - 1000 = 84.
1021
+ const spacer = scrollContainer!.querySelector<HTMLElement>(
1022
+ "[data-persona-anchor-spacer]"
1023
+ );
1024
+ expect(spacer?.style.height).toBe("84px");
1025
+
1026
+ // The spacer's height is invisible to the mocked scroll metrics — apply
1027
+ // it manually so the anchor target is reachable, as in a real browser.
1028
+ metrics.setScrollHeight(1084);
1029
+ raf.flush();
1030
+ expect(metrics.getScrollTop()).toBe(684);
1031
+
1032
+ // Streaming below the anchor never moves the viewport.
1033
+ metrics.setScrollHeight(1150);
1034
+ emitStreamingMessage(controller, "Streaming response");
1035
+ raf.flush();
1036
+ expect(metrics.getScrollTop()).toBe(684);
1037
+
1038
+ // As real content grows, the spacer gives room back (shrink-only):
1039
+ // content grew from 1000 to 1150 - 84 = 1066, so spacer 84 - 66 = 18.
1040
+ resize.trigger();
1041
+ expect(spacer?.style.height).toBe("18px");
1042
+
1043
+ // Jumping to the latest abandons the anchor: the spacer is dropped so
1044
+ // "bottom" is the real end of content.
1045
+ getScrollToBottomButton(mount)!.click();
1046
+ raf.flush();
1047
+ expect(spacer?.style.height).toBe("0px");
1048
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
1049
+
1050
+ controller.destroy();
1051
+ });
1052
+
1053
+ it("jump-to-latest cancels an in-flight anchor scroll animation", () => {
1054
+ const raf = installRafMock();
1055
+ const mount = createMount();
1056
+ const controller = createAgentExperience(mount, {
1057
+ apiUrl: "https://api.example.com/chat",
1058
+ launcher: { enabled: false },
1059
+ features: {
1060
+ scrollBehavior: { mode: "anchor-top", anchorTopOffset: 16 }
1061
+ }
1062
+ } as any);
1063
+
1064
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
1065
+ const metrics = installScrollMetrics(scrollContainer!, {
1066
+ scrollHeight: 1000,
1067
+ clientHeight: 400
1068
+ });
1069
+
1070
+ emitStreamingStatus(controller);
1071
+ emitUserMessage(controller, "user-anchor-cancel", "Question");
1072
+
1073
+ // Anchor target (484) is reachable without a spacer, and differs from
1074
+ // the bottom (600) so a stale animation is distinguishable.
1075
+ const bubble = scrollContainer!.querySelector<HTMLElement>(
1076
+ '[data-message-id="user-anchor-cancel"]'
1077
+ );
1078
+ Object.defineProperty(bubble!, "offsetTop", { value: 500 });
1079
+
1080
+ raf.step(1); // anchor frame: starts the scroll animation
1081
+ raf.step(1); // first animation frame — animation now in flight
1082
+
1083
+ // Jump to the latest mid-animation: the stale anchor animation must not
1084
+ // keep easing scrollTop back toward the old target.
1085
+ getScrollToBottomButton(mount)!.click();
1086
+ raf.flush();
1087
+
1088
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
1089
+
1090
+ controller.destroy();
1091
+ });
1092
+
1093
+ it("scroll mode none never auto-scrolls during streaming", () => {
1094
+ const raf = installRafMock();
1095
+ const mount = createMount();
1096
+ const controller = createAgentExperience(mount, {
1097
+ apiUrl: "https://api.example.com/chat",
1098
+ launcher: { enabled: false },
1099
+ features: {
1100
+ scrollBehavior: { mode: "none" }
1101
+ }
1102
+ } as any);
1103
+
1104
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
1105
+ const metrics = installScrollMetrics(scrollContainer!, {
1106
+ scrollHeight: 1000,
1107
+ clientHeight: 400
1108
+ });
1109
+
1110
+ emitStreamingStatus(controller);
1111
+ emitStreamingMessage(controller, "First chunk");
1112
+ metrics.setScrollHeight(1200);
1113
+ emitStreamingMessage(controller, "Second chunk");
1114
+ raf.flush();
1115
+
1116
+ expect(metrics.getScrollTop()).toBe(0);
1117
+
1118
+ // The affordance is still available to get back to the latest content,
1119
+ // and messages that arrived while away from the bottom are counted.
1120
+ scrollContainer!.dispatchEvent(new Event("scroll"));
1121
+ expect(getScrollToBottomButton(mount)?.style.display).not.toBe("none");
1122
+ const badge = mount.querySelector<HTMLElement>(
1123
+ "[data-persona-scroll-to-bottom-count]"
1124
+ );
1125
+ expect(badge?.textContent).toBe("1");
1126
+
1127
+ controller.destroy();
1128
+ });
1129
+
747
1130
  });