@runtypelabs/persona 3.9.2 → 3.10.1

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 (39) hide show
  1. package/dist/index.cjs +45 -42
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +148 -0
  4. package/dist/index.d.ts +148 -0
  5. package/dist/index.global.js +67 -64
  6. package/dist/index.global.js.map +1 -1
  7. package/dist/index.js +45 -42
  8. package/dist/index.js.map +1 -1
  9. package/dist/theme-editor.cjs +959 -214
  10. package/dist/theme-editor.d.cts +157 -3
  11. package/dist/theme-editor.d.ts +157 -3
  12. package/dist/theme-editor.js +955 -214
  13. package/dist/theme-reference.cjs +1 -1
  14. package/dist/theme-reference.d.cts +8 -0
  15. package/dist/theme-reference.d.ts +8 -0
  16. package/dist/theme-reference.js +1 -1
  17. package/dist/widget.css +154 -0
  18. package/package.json +1 -1
  19. package/src/client.test.ts +312 -1
  20. package/src/client.ts +247 -24
  21. package/src/components/messages.ts +1 -1
  22. package/src/components/reasoning-bubble.ts +117 -28
  23. package/src/components/tool-bubble.ts +161 -27
  24. package/src/defaults.ts +12 -0
  25. package/src/styles/widget.css +154 -0
  26. package/src/theme-editor/index.ts +5 -0
  27. package/src/theme-editor/preview-utils.test.ts +58 -0
  28. package/src/theme-editor/preview-utils.ts +220 -4
  29. package/src/theme-editor/sections.test.ts +20 -0
  30. package/src/theme-editor/sections.ts +10 -0
  31. package/src/theme-reference.ts +8 -3
  32. package/src/tool-call-display-defaults.test.ts +23 -0
  33. package/src/types.ts +155 -0
  34. package/src/ui.attachments-drop.test.ts +188 -0
  35. package/src/ui.scroll.test.ts +150 -0
  36. package/src/ui.tool-display.test.ts +204 -0
  37. package/src/ui.ts +275 -7
  38. package/src/utils/message-fingerprint.test.ts +17 -0
  39. package/src/utils/message-fingerprint.ts +13 -1
@@ -0,0 +1,188 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { createAgentExperience } from "./ui";
6
+ import { AttachmentManager } from "./utils/attachment-manager";
7
+
8
+ const createMount = () => {
9
+ const mount = document.createElement("div");
10
+ document.body.appendChild(mount);
11
+ return mount;
12
+ };
13
+
14
+ /** jsdom does not expose `DataTransfer`; real browsers set `dropEffect` on dragover. */
15
+ function createFileDataTransfer(files: File[]): DataTransfer {
16
+ const list: File[] = [...files];
17
+ const fileList = list as unknown as FileList;
18
+ return {
19
+ dropEffect: "none",
20
+ effectAllowed: "all",
21
+ files: fileList,
22
+ items: {
23
+ add: () => {},
24
+ clear: () => {},
25
+ remove: () => {}
26
+ } as unknown as DataTransferItemList,
27
+ types: files.length > 0 ? ["Files"] : [],
28
+ clearData: () => {},
29
+ getData: () => "",
30
+ setData: () => {},
31
+ setDragImage: () => {}
32
+ } as unknown as DataTransfer;
33
+ }
34
+
35
+ function createDragEvent(type: string, dataTransfer: DataTransfer): DragEvent {
36
+ const ev = new Event(type, { bubbles: true, cancelable: true }) as unknown as DragEvent;
37
+ Object.defineProperty(ev, "dataTransfer", { value: dataTransfer, enumerable: true });
38
+ return ev;
39
+ }
40
+
41
+ describe("createAgentExperience attachment file drop", () => {
42
+ beforeEach(() => {
43
+ vi.stubGlobal("requestAnimationFrame", (cb: (time: number) => void) => {
44
+ cb(0);
45
+ return 1;
46
+ });
47
+ vi.stubGlobal("cancelAnimationFrame", () => {});
48
+ window.scrollTo = vi.fn();
49
+ });
50
+
51
+ afterEach(() => {
52
+ document.body.innerHTML = "";
53
+ vi.restoreAllMocks();
54
+ });
55
+
56
+ it("calls AttachmentManager.handleFiles when files are dropped on the mount", () => {
57
+ const handleFilesSpy = vi.spyOn(AttachmentManager.prototype, "handleFiles");
58
+
59
+ const mount = createMount();
60
+ const controller = createAgentExperience(mount, {
61
+ apiUrl: "https://api.example.com/chat",
62
+ launcher: { enabled: false },
63
+ attachments: { enabled: true, maxFiles: 4 },
64
+ });
65
+
66
+ const file = new File(["x"], "test.png", { type: "image/png" });
67
+ const dt = createFileDataTransfer([file]);
68
+
69
+ // dragover/drop are on mount so the browser default is suppressed everywhere
70
+ const dragOver = createDragEvent("dragover", dt);
71
+ mount.dispatchEvent(dragOver);
72
+ expect(dragOver.defaultPrevented).toBe(true);
73
+ expect(dt.dropEffect).toBe("copy");
74
+
75
+ const drop = createDragEvent("drop", dt);
76
+ mount.dispatchEvent(drop);
77
+ expect(drop.defaultPrevented).toBe(true);
78
+
79
+ expect(handleFilesSpy).toHaveBeenCalledTimes(1);
80
+ const passed = handleFilesSpy.mock.calls[0]?.[0] as File[];
81
+ expect(passed).toHaveLength(1);
82
+ expect(passed[0]?.name).toBe("test.png");
83
+
84
+ handleFilesSpy.mockRestore();
85
+ controller.destroy();
86
+ });
87
+
88
+ it("shows drop-active highlight on container during dragenter", () => {
89
+ const mount = createMount();
90
+ const controller = createAgentExperience(mount, {
91
+ apiUrl: "https://api.example.com/chat",
92
+ launcher: { enabled: false },
93
+ attachments: { enabled: true, maxFiles: 4 },
94
+ });
95
+
96
+ const container = mount.querySelector(".persona-widget-container")!;
97
+ const file = new File(["x"], "test.png", { type: "image/png" });
98
+ const dt = createFileDataTransfer([file]);
99
+
100
+ container.dispatchEvent(createDragEvent("dragenter", dt));
101
+ expect(container.classList.contains("persona-attachment-drop-active")).toBe(true);
102
+
103
+ container.dispatchEvent(createDragEvent("dragleave", dt));
104
+ expect(container.classList.contains("persona-attachment-drop-active")).toBe(false);
105
+
106
+ controller.destroy();
107
+ });
108
+
109
+ it("renders drop overlay with icon inside container", () => {
110
+ const mount = createMount();
111
+ const controller = createAgentExperience(mount, {
112
+ apiUrl: "https://api.example.com/chat",
113
+ launcher: { enabled: false },
114
+ attachments: { enabled: true, maxFiles: 4 },
115
+ });
116
+
117
+ const overlay = mount.querySelector(".persona-attachment-drop-overlay");
118
+ expect(overlay).not.toBeNull();
119
+ expect(overlay!.querySelector("svg")).not.toBeNull();
120
+
121
+ controller.destroy();
122
+ });
123
+
124
+ it("applies custom dropOverlay config as CSS variables", () => {
125
+ const mount = createMount();
126
+ const controller = createAgentExperience(mount, {
127
+ apiUrl: "https://api.example.com/chat",
128
+ launcher: { enabled: false },
129
+ attachments: {
130
+ enabled: true,
131
+ maxFiles: 4,
132
+ dropOverlay: {
133
+ background: "rgba(255, 0, 0, 0.1)",
134
+ backdropBlur: "12px",
135
+ border: "2px solid red",
136
+ inset: "8px",
137
+ iconName: "image-plus",
138
+ label: "Drop here",
139
+ },
140
+ },
141
+ });
142
+
143
+ const overlay = mount.querySelector<HTMLElement>(".persona-attachment-drop-overlay")!;
144
+ expect(overlay).not.toBeNull();
145
+ expect(overlay.style.getPropertyValue("--persona-drop-overlay-bg")).toBe("rgba(255, 0, 0, 0.1)");
146
+ expect(overlay.style.getPropertyValue("--persona-drop-overlay-blur")).toBe("12px");
147
+ expect(overlay.style.getPropertyValue("--persona-drop-overlay-border")).toBe("2px solid red");
148
+ expect(overlay.style.getPropertyValue("--persona-drop-overlay-inset")).toBe("8px");
149
+
150
+ const label = overlay.querySelector(".persona-drop-overlay-label");
151
+ expect(label).not.toBeNull();
152
+ expect(label!.textContent).toBe("Drop here");
153
+
154
+ controller.destroy();
155
+ });
156
+
157
+ it("does not render drop overlay when attachments are disabled", () => {
158
+ const mount = createMount();
159
+ const controller = createAgentExperience(mount, {
160
+ apiUrl: "https://api.example.com/chat",
161
+ launcher: { enabled: false },
162
+ attachments: { enabled: false },
163
+ });
164
+
165
+ const overlay = mount.querySelector(".persona-attachment-drop-overlay");
166
+ expect(overlay).toBeNull();
167
+
168
+ controller.destroy();
169
+ });
170
+
171
+ it("does not prevent dragover when attachments are disabled", () => {
172
+ const mount = createMount();
173
+ const controller = createAgentExperience(mount, {
174
+ apiUrl: "https://api.example.com/chat",
175
+ launcher: { enabled: false },
176
+ attachments: { enabled: false },
177
+ });
178
+
179
+ const file = new File(["x"], "test.png", { type: "image/png" });
180
+ const dt = createFileDataTransfer([file]);
181
+
182
+ const dragOver = createDragEvent("dragover", dt);
183
+ mount.dispatchEvent(dragOver);
184
+ expect(dragOver.defaultPrevented).toBe(false);
185
+
186
+ controller.destroy();
187
+ });
188
+ });
@@ -135,6 +135,36 @@ const emitReasoningMessage = (
135
135
  });
136
136
  };
137
137
 
138
+ const emitToolMessage = (
139
+ controller: ReturnType<typeof createAgentExperience>,
140
+ {
141
+ id = STREAM_MESSAGE_ID,
142
+ status = "running",
143
+ chunks,
144
+ }: {
145
+ id?: string;
146
+ status?: "pending" | "running" | "complete";
147
+ chunks: string[];
148
+ }
149
+ ) => {
150
+ controller.injectTestMessage({
151
+ type: "message",
152
+ message: {
153
+ id,
154
+ role: "assistant",
155
+ content: "",
156
+ createdAt: STREAM_CREATED_AT,
157
+ streaming: status !== "complete",
158
+ variant: "tool",
159
+ toolCall: {
160
+ id,
161
+ status,
162
+ chunks,
163
+ }
164
+ }
165
+ });
166
+ };
167
+
138
168
  const createCustomComposer = () => {
139
169
  const footer = document.createElement("div");
140
170
  footer.className = "persona-widget-footer";
@@ -368,6 +398,126 @@ describe("createAgentExperience streaming scroll", () => {
368
398
  controller.destroy();
369
399
  });
370
400
 
401
+ it("keeps following collapsed tool preview updates while active", () => {
402
+ const raf = installRafMock();
403
+ const mount = createMount();
404
+ const controller = createAgentExperience(mount, {
405
+ apiUrl: "https://api.example.com/chat",
406
+ launcher: { enabled: false },
407
+ features: {
408
+ toolCallDisplay: {
409
+ activePreview: true,
410
+ },
411
+ },
412
+ } as any);
413
+
414
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
415
+ expect(scrollContainer).not.toBeNull();
416
+
417
+ const metrics = installScrollMetrics(scrollContainer!, {
418
+ scrollHeight: 980,
419
+ clientHeight: 400
420
+ });
421
+
422
+ emitStreamingStatus(controller);
423
+ emitToolMessage(controller, { chunks: ["Loaded tools"] });
424
+ raf.flush();
425
+
426
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
427
+
428
+ metrics.setScrollHeight(1045);
429
+ emitToolMessage(controller, {
430
+ chunks: ["Loaded tools", "\nFetched platform documentation"]
431
+ });
432
+ raf.flush();
433
+
434
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
435
+
436
+ controller.destroy();
437
+ });
438
+
439
+ it("keeps following grouped tool sequences as new tool rows arrive", () => {
440
+ const raf = installRafMock();
441
+ const mount = createMount();
442
+ const controller = createAgentExperience(mount, {
443
+ apiUrl: "https://api.example.com/chat",
444
+ launcher: { enabled: false },
445
+ features: {
446
+ toolCallDisplay: {
447
+ grouped: true,
448
+ },
449
+ },
450
+ } as any);
451
+
452
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
453
+ expect(scrollContainer).not.toBeNull();
454
+
455
+ const metrics = installScrollMetrics(scrollContainer!, {
456
+ scrollHeight: 960,
457
+ clientHeight: 400
458
+ });
459
+
460
+ emitStreamingStatus(controller);
461
+ emitToolMessage(controller, { id: "tool-1", chunks: ["Loaded tools"] });
462
+ raf.flush();
463
+
464
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
465
+
466
+ metrics.setScrollHeight(1030);
467
+ emitToolMessage(controller, { id: "tool-2", chunks: ["Fetched platform documentation"] });
468
+ raf.flush();
469
+
470
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
471
+
472
+ controller.destroy();
473
+ });
474
+
475
+ it("ignores layout-driven scroll events before a scheduled auto-scroll starts", () => {
476
+ const raf = installRafMock();
477
+ const mount = createMount();
478
+ const controller = createAgentExperience(mount, {
479
+ apiUrl: "https://api.example.com/chat",
480
+ launcher: { enabled: false },
481
+ features: {
482
+ toolCallDisplay: {
483
+ activePreview: true,
484
+ },
485
+ },
486
+ } as any);
487
+
488
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
489
+ expect(scrollContainer).not.toBeNull();
490
+
491
+ const metrics = installScrollMetrics(scrollContainer!, {
492
+ scrollHeight: 960,
493
+ clientHeight: 400,
494
+ });
495
+
496
+ emitStreamingStatus(controller);
497
+ emitToolMessage(controller, { id: "tool-1", chunks: ["Loaded tools"] });
498
+ emitToolMessage(controller, { id: "tool-2", chunks: ["Fetched docs"] });
499
+ raf.flush();
500
+
501
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
502
+
503
+ metrics.setScrollHeight(1035);
504
+ emitToolMessage(controller, {
505
+ id: "tool-3",
506
+ chunks: ["Compared layouts and noted launcher sizing"],
507
+ });
508
+
509
+ // Simulate the browser emitting a scroll event caused by layout/scroll
510
+ // anchoring before the scheduled auto-scroll rAF has started.
511
+ metrics.setScrollTop(metrics.getScrollTop() - 2);
512
+ scrollContainer!.dispatchEvent(new Event("scroll"));
513
+
514
+ raf.flush();
515
+
516
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
517
+
518
+ controller.destroy();
519
+ });
520
+
371
521
  it("uses icon-only arrow-down defaults for the transcript affordance", () => {
372
522
  const raf = installRafMock();
373
523
  const mount = createMount();
@@ -0,0 +1,204 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { createAgentExperience } from "./ui";
6
+ import type { AgentWidgetController } from "./ui";
7
+
8
+ const createMount = () => {
9
+ const mount = document.createElement("div");
10
+ document.body.appendChild(mount);
11
+ return mount;
12
+ };
13
+
14
+ const injectToolMessage = (
15
+ controller: AgentWidgetController,
16
+ {
17
+ id,
18
+ name,
19
+ status = "running",
20
+ chunks = [],
21
+ }: {
22
+ id: string;
23
+ name?: string;
24
+ status?: "pending" | "running" | "complete";
25
+ chunks?: string[];
26
+ }
27
+ ) => {
28
+ controller.injectTestMessage({
29
+ type: "message",
30
+ message: {
31
+ id,
32
+ role: "assistant",
33
+ content: "",
34
+ createdAt: new Date().toISOString(),
35
+ streaming: status !== "complete",
36
+ variant: "tool",
37
+ toolCall: {
38
+ id,
39
+ name,
40
+ status,
41
+ chunks,
42
+ },
43
+ },
44
+ });
45
+ };
46
+
47
+ const injectReasoningMessage = (
48
+ controller: AgentWidgetController,
49
+ {
50
+ id,
51
+ status = "streaming",
52
+ chunks = [],
53
+ }: {
54
+ id: string;
55
+ status?: "pending" | "streaming" | "complete";
56
+ chunks?: string[];
57
+ }
58
+ ) => {
59
+ controller.injectTestMessage({
60
+ type: "message",
61
+ message: {
62
+ id,
63
+ role: "assistant",
64
+ content: "",
65
+ createdAt: new Date().toISOString(),
66
+ streaming: status !== "complete",
67
+ variant: "reasoning",
68
+ reasoning: {
69
+ id,
70
+ status,
71
+ chunks,
72
+ },
73
+ },
74
+ });
75
+ };
76
+
77
+ describe("createAgentExperience tool call display modes", () => {
78
+ beforeEach(() => {
79
+ vi.stubGlobal("requestAnimationFrame", (cb: (time: number) => void) => {
80
+ cb(0);
81
+ return 1;
82
+ });
83
+ vi.stubGlobal("cancelAnimationFrame", () => {});
84
+ window.scrollTo = vi.fn();
85
+ });
86
+
87
+ afterEach(() => {
88
+ document.body.innerHTML = "";
89
+ vi.restoreAllMocks();
90
+ });
91
+
92
+ it("keeps collapsed tool rows on the generic summary by default", () => {
93
+ const mount = createMount();
94
+ const controller = createAgentExperience(mount, {
95
+ apiUrl: "https://api.example.com/chat",
96
+ launcher: { enabled: false },
97
+ });
98
+
99
+ injectToolMessage(controller, {
100
+ id: "tool-1",
101
+ name: "Get platform documentation",
102
+ chunks: ["Loaded tools, used Runtype integration"],
103
+ });
104
+
105
+ const header = mount.querySelector(".persona-tool-bubble button[data-expand-header='true']");
106
+ expect(header?.textContent).toContain("Using tool...");
107
+ expect(header?.textContent).not.toContain("Get platform documentation");
108
+
109
+ controller.destroy();
110
+ });
111
+
112
+ it("shows the tool name in collapsed rows when configured", () => {
113
+ const mount = createMount();
114
+ const controller = createAgentExperience(mount, {
115
+ apiUrl: "https://api.example.com/chat",
116
+ launcher: { enabled: false },
117
+ features: {
118
+ toolCallDisplay: {
119
+ collapsedMode: "tool-name",
120
+ },
121
+ },
122
+ } as any);
123
+
124
+ injectToolMessage(controller, {
125
+ id: "tool-1",
126
+ name: "Get platform documentation",
127
+ chunks: ["Loaded tools, used Runtype integration"],
128
+ });
129
+
130
+ const header = mount.querySelector(".persona-tool-bubble button[data-expand-header='true']");
131
+ expect(header?.textContent).toContain("Get platform documentation");
132
+
133
+ controller.destroy();
134
+ });
135
+
136
+ it("renders a collapsed preview for active tool rows when enabled", () => {
137
+ const mount = createMount();
138
+ const controller = createAgentExperience(mount, {
139
+ apiUrl: "https://api.example.com/chat",
140
+ launcher: { enabled: false },
141
+ features: {
142
+ toolCallDisplay: {
143
+ activePreview: true,
144
+ },
145
+ },
146
+ } as any);
147
+
148
+ injectToolMessage(controller, {
149
+ id: "tool-1",
150
+ name: "Get platform documentation",
151
+ chunks: ["Loaded tools, used Runtype integration"],
152
+ });
153
+
154
+ const preview = mount.querySelector("[data-persona-collapsed-preview='tool']");
155
+ expect(preview?.textContent).toContain("Loaded tools, used Runtype integration");
156
+
157
+ controller.destroy();
158
+ });
159
+
160
+ it("renders a collapsed preview for active reasoning rows when enabled", () => {
161
+ const mount = createMount();
162
+ const controller = createAgentExperience(mount, {
163
+ apiUrl: "https://api.example.com/chat",
164
+ launcher: { enabled: false },
165
+ features: {
166
+ reasoningDisplay: {
167
+ activePreview: true,
168
+ },
169
+ },
170
+ } as any);
171
+
172
+ injectReasoningMessage(controller, {
173
+ id: "reason-1",
174
+ chunks: ["Now let me get the Persona embed documentation and builtin tools catalog."],
175
+ });
176
+
177
+ const preview = mount.querySelector("[data-persona-collapsed-preview='reasoning']");
178
+ expect(preview?.textContent).toContain("Now let me get the Persona embed documentation");
179
+
180
+ controller.destroy();
181
+ });
182
+
183
+ it("groups consecutive tool calls when enabled", () => {
184
+ const mount = createMount();
185
+ const controller = createAgentExperience(mount, {
186
+ apiUrl: "https://api.example.com/chat",
187
+ launcher: { enabled: false },
188
+ features: {
189
+ toolCallDisplay: {
190
+ grouped: true,
191
+ },
192
+ },
193
+ } as any);
194
+
195
+ injectToolMessage(controller, { id: "tool-1", name: "Load tools", chunks: ["Loaded tools"] });
196
+ injectToolMessage(controller, { id: "tool-2", name: "Get docs", chunks: ["Fetched docs"] });
197
+
198
+ const group = mount.querySelector("[data-persona-tool-group='true']");
199
+ expect(group).not.toBeNull();
200
+ expect(group?.textContent).toContain("Called 2 tools");
201
+
202
+ controller.destroy();
203
+ });
204
+ });