@runtypelabs/persona 3.10.0 → 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.
- package/dist/index.cjs +44 -44
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +29 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.global.js +59 -59
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +44 -44
- package/dist/index.js.map +1 -1
- package/dist/theme-editor.cjs +144 -15
- package/dist/theme-editor.d.cts +29 -0
- package/dist/theme-editor.d.ts +29 -0
- package/dist/theme-editor.js +144 -15
- package/dist/widget.css +30 -0
- package/package.json +1 -1
- package/src/styles/widget.css +30 -0
- package/src/types.ts +29 -0
- package/src/ui.attachments-drop.test.ts +188 -0
- package/src/ui.scroll.test.ts +46 -0
- package/src/ui.ts +172 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runtypelabs/persona",
|
|
3
|
-
"version": "3.10.
|
|
3
|
+
"version": "3.10.1",
|
|
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",
|
package/src/styles/widget.css
CHANGED
|
@@ -93,6 +93,35 @@
|
|
|
93
93
|
gap: 1.5rem;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
[data-persona-root] .persona-widget-container .persona-attachment-drop-overlay {
|
|
97
|
+
display: none;
|
|
98
|
+
position: absolute;
|
|
99
|
+
inset: var(--persona-drop-overlay-inset, 0);
|
|
100
|
+
z-index: 50;
|
|
101
|
+
flex-direction: column;
|
|
102
|
+
align-items: center;
|
|
103
|
+
justify-content: center;
|
|
104
|
+
gap: 0.5rem;
|
|
105
|
+
pointer-events: none;
|
|
106
|
+
background: var(--persona-drop-overlay-bg, rgba(59, 130, 246, 0.08));
|
|
107
|
+
-webkit-backdrop-filter: blur(var(--persona-drop-overlay-blur, 8px));
|
|
108
|
+
backdrop-filter: blur(var(--persona-drop-overlay-blur, 8px));
|
|
109
|
+
border: var(--persona-drop-overlay-border, 2px dashed rgba(59, 130, 246, 0.4));
|
|
110
|
+
border-radius: var(--persona-drop-overlay-radius, inherit);
|
|
111
|
+
transition: opacity 0.15s ease;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
[data-persona-root] .persona-widget-container .persona-attachment-drop-overlay .persona-drop-overlay-label {
|
|
115
|
+
font-size: var(--persona-drop-overlay-label-size, 0.875rem);
|
|
116
|
+
color: var(--persona-drop-overlay-label-color, rgba(59, 130, 246, 0.8));
|
|
117
|
+
font-weight: 500;
|
|
118
|
+
user-select: none;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
[data-persona-root] .persona-widget-container.persona-attachment-drop-active .persona-attachment-drop-overlay {
|
|
122
|
+
display: flex;
|
|
123
|
+
}
|
|
124
|
+
|
|
96
125
|
/* Widget CSS Variables - scoped to widget root to avoid polluting global namespace */
|
|
97
126
|
[data-persona-root] {
|
|
98
127
|
--persona-radius-sm: 0.125rem;
|
|
@@ -935,6 +964,7 @@
|
|
|
935
964
|
}
|
|
936
965
|
|
|
937
966
|
.persona-widget-container {
|
|
967
|
+
position: relative;
|
|
938
968
|
border-radius: var(--persona-panel-radius, var(--persona-radius-xl, 0.75rem));
|
|
939
969
|
}
|
|
940
970
|
|
package/src/types.ts
CHANGED
|
@@ -2081,6 +2081,35 @@ export type AgentWidgetAttachmentsConfig = {
|
|
|
2081
2081
|
* Callback when a file is rejected (wrong type or too large).
|
|
2082
2082
|
*/
|
|
2083
2083
|
onFileRejected?: (file: File, reason: 'type' | 'size' | 'count') => void;
|
|
2084
|
+
/**
|
|
2085
|
+
* Customize the drag-and-drop overlay that appears when files are dragged over the widget.
|
|
2086
|
+
*/
|
|
2087
|
+
dropOverlay?: {
|
|
2088
|
+
/** Background color/value of the overlay. @default 'rgba(59, 130, 246, 0.08)' */
|
|
2089
|
+
background?: string;
|
|
2090
|
+
/** Backdrop blur applied behind the overlay (CSS value). @default '8px' */
|
|
2091
|
+
backdropBlur?: string;
|
|
2092
|
+
/** Border style shown during drag. @default '2px dashed rgba(59, 130, 246, 0.4)' */
|
|
2093
|
+
border?: string;
|
|
2094
|
+
/** Border radius of the overlay. @default 'inherit' */
|
|
2095
|
+
borderRadius?: string;
|
|
2096
|
+
/** Inset/margin pulling the overlay away from the container edges (CSS value). @default '0' */
|
|
2097
|
+
inset?: string;
|
|
2098
|
+
/** Lucide icon name displayed in the center. @default 'upload' */
|
|
2099
|
+
iconName?: string;
|
|
2100
|
+
/** Icon size (CSS value). @default '48px' */
|
|
2101
|
+
iconSize?: string;
|
|
2102
|
+
/** Icon stroke color. @default 'rgba(59, 130, 246, 0.6)' */
|
|
2103
|
+
iconColor?: string;
|
|
2104
|
+
/** Icon stroke width. @default 0.5 */
|
|
2105
|
+
iconStrokeWidth?: number;
|
|
2106
|
+
/** Optional label text shown below the icon. */
|
|
2107
|
+
label?: string;
|
|
2108
|
+
/** Label font size. @default '0.875rem' */
|
|
2109
|
+
labelSize?: string;
|
|
2110
|
+
/** Label color. @default 'rgba(59, 130, 246, 0.8)' */
|
|
2111
|
+
labelColor?: string;
|
|
2112
|
+
};
|
|
2084
2113
|
};
|
|
2085
2114
|
|
|
2086
2115
|
/**
|
|
@@ -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
|
+
});
|
package/src/ui.scroll.test.ts
CHANGED
|
@@ -472,6 +472,52 @@ describe("createAgentExperience streaming scroll", () => {
|
|
|
472
472
|
controller.destroy();
|
|
473
473
|
});
|
|
474
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
|
+
|
|
475
521
|
it("uses icon-only arrow-down defaults for the transcript affordance", () => {
|
|
476
522
|
const raf = installRafMock();
|
|
477
523
|
const mount = createMount();
|
package/src/ui.ts
CHANGED
|
@@ -147,6 +147,19 @@ function getClipboardImageFiles(clipboardData: DataTransfer | null): File[] {
|
|
|
147
147
|
return imageFiles;
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
function dataTransferHasFiles(
|
|
151
|
+
dataTransfer: DataTransfer | null
|
|
152
|
+
): dataTransfer is DataTransfer {
|
|
153
|
+
if (!dataTransfer) return false;
|
|
154
|
+
const types = dataTransfer.types;
|
|
155
|
+
if (!types) return false;
|
|
156
|
+
// Real browsers return DOMStringList which has .contains(); test polyfills use plain arrays.
|
|
157
|
+
if (typeof (types as unknown as { contains?: unknown }).contains === "function") {
|
|
158
|
+
return (types as unknown as DOMStringList).contains("Files");
|
|
159
|
+
}
|
|
160
|
+
return Array.from(types).includes("Files");
|
|
161
|
+
}
|
|
162
|
+
|
|
150
163
|
// ============================================================================
|
|
151
164
|
// PERSIST STATE HELPERS
|
|
152
165
|
// ============================================================================
|
|
@@ -389,11 +402,43 @@ const buildPostprocessor = (
|
|
|
389
402
|
};
|
|
390
403
|
};
|
|
391
404
|
|
|
405
|
+
function buildDropOverlay(
|
|
406
|
+
dropCfg?: NonNullable<AgentWidgetConfig["attachments"]>["dropOverlay"]
|
|
407
|
+
): HTMLElement {
|
|
408
|
+
const overlay = createElement("div", "persona-attachment-drop-overlay");
|
|
409
|
+
if (dropCfg?.background) overlay.style.setProperty("--persona-drop-overlay-bg", dropCfg.background);
|
|
410
|
+
if (dropCfg?.backdropBlur !== undefined) overlay.style.setProperty("--persona-drop-overlay-blur", dropCfg.backdropBlur);
|
|
411
|
+
if (dropCfg?.border) overlay.style.setProperty("--persona-drop-overlay-border", dropCfg.border);
|
|
412
|
+
if (dropCfg?.borderRadius) overlay.style.setProperty("--persona-drop-overlay-radius", dropCfg.borderRadius);
|
|
413
|
+
if (dropCfg?.inset) overlay.style.setProperty("--persona-drop-overlay-inset", dropCfg.inset);
|
|
414
|
+
if (dropCfg?.labelSize) overlay.style.setProperty("--persona-drop-overlay-label-size", dropCfg.labelSize);
|
|
415
|
+
if (dropCfg?.labelColor) overlay.style.setProperty("--persona-drop-overlay-label-color", dropCfg.labelColor);
|
|
416
|
+
|
|
417
|
+
const iconName = dropCfg?.iconName ?? "upload";
|
|
418
|
+
const iconSize = dropCfg?.iconSize ?? "48px";
|
|
419
|
+
const iconColor = dropCfg?.iconColor ?? "rgba(59, 130, 246, 0.6)";
|
|
420
|
+
const iconStrokeWidth = dropCfg?.iconStrokeWidth ?? 0.5;
|
|
421
|
+
const iconSvg = renderLucideIcon(iconName, iconSize, iconColor, iconStrokeWidth);
|
|
422
|
+
if (iconSvg) overlay.appendChild(iconSvg);
|
|
423
|
+
|
|
424
|
+
if (dropCfg?.label) {
|
|
425
|
+
const labelEl = createElement("span", "persona-drop-overlay-label");
|
|
426
|
+
labelEl.textContent = dropCfg.label;
|
|
427
|
+
overlay.appendChild(labelEl);
|
|
428
|
+
}
|
|
429
|
+
return overlay;
|
|
430
|
+
}
|
|
431
|
+
|
|
392
432
|
export const createAgentExperience = (
|
|
393
433
|
mount: HTMLElement,
|
|
394
434
|
initialConfig?: AgentWidgetConfig,
|
|
395
435
|
runtimeOptions?: { debugTools?: boolean }
|
|
396
436
|
): Controller => {
|
|
437
|
+
if (mount == null) {
|
|
438
|
+
throw new Error(
|
|
439
|
+
"createAgentExperience: mount must be a non-null HTMLElement (e.g. pass document.getElementById(\"my-root\") after the node exists)."
|
|
440
|
+
);
|
|
441
|
+
}
|
|
397
442
|
// Preserve original mount id as data attribute for window event instance scoping
|
|
398
443
|
if (mount.id && !mount.getAttribute("data-persona-instance")) {
|
|
399
444
|
mount.setAttribute("data-persona-instance", mount.id);
|
|
@@ -870,8 +915,21 @@ export const createAgentExperience = (
|
|
|
870
915
|
return composerElements.footer;
|
|
871
916
|
},
|
|
872
917
|
onSubmit: (text: string) => {
|
|
873
|
-
if (session
|
|
874
|
-
|
|
918
|
+
if (!session || session.isStreaming()) return;
|
|
919
|
+
const value = text.trim();
|
|
920
|
+
const hasAttachments = attachmentManager?.hasAttachments() ?? false;
|
|
921
|
+
if (!value && !hasAttachments) return;
|
|
922
|
+
let contentParts: ContentPart[] | undefined;
|
|
923
|
+
if (hasAttachments) {
|
|
924
|
+
contentParts = [];
|
|
925
|
+
contentParts.push(...attachmentManager!.getContentParts());
|
|
926
|
+
if (value) {
|
|
927
|
+
contentParts.push(createTextPart(value));
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
session.sendMessage(value, { contentParts });
|
|
931
|
+
if (hasAttachments) {
|
|
932
|
+
attachmentManager!.clearAttachments();
|
|
875
933
|
}
|
|
876
934
|
},
|
|
877
935
|
streaming: false,
|
|
@@ -959,6 +1017,10 @@ export const createAgentExperience = (
|
|
|
959
1017
|
attachmentManager?.handleFileSelect(target.files);
|
|
960
1018
|
target.value = "";
|
|
961
1019
|
});
|
|
1020
|
+
|
|
1021
|
+
const dropCfg = config.attachments.dropOverlay;
|
|
1022
|
+
const overlay = buildDropOverlay(dropCfg);
|
|
1023
|
+
container.appendChild(overlay);
|
|
962
1024
|
}
|
|
963
1025
|
|
|
964
1026
|
// Slot system: allow custom content injection into specific regions
|
|
@@ -1925,6 +1987,7 @@ export const createAgentExperience = (
|
|
|
1925
1987
|
let lastScrollTop = 0;
|
|
1926
1988
|
let scrollRAF: number | null = null;
|
|
1927
1989
|
let isAutoScrolling = false;
|
|
1990
|
+
let hasPendingAutoScroll = false;
|
|
1928
1991
|
|
|
1929
1992
|
const USER_SCROLL_THRESHOLD = 1;
|
|
1930
1993
|
const BOTTOM_THRESHOLD = 8;
|
|
@@ -2041,6 +2104,7 @@ export const createAgentExperience = (
|
|
|
2041
2104
|
cancelAnimationFrame(scrollRAF);
|
|
2042
2105
|
scrollRAF = null;
|
|
2043
2106
|
}
|
|
2107
|
+
hasPendingAutoScroll = false;
|
|
2044
2108
|
cancelSmoothScroll();
|
|
2045
2109
|
};
|
|
2046
2110
|
|
|
@@ -2076,10 +2140,25 @@ export const createAgentExperience = (
|
|
|
2076
2140
|
|
|
2077
2141
|
if (!force && !isStreaming) return;
|
|
2078
2142
|
|
|
2079
|
-
|
|
2143
|
+
// Only cancel the pending schedule rAF — keep the ongoing smooth scroll
|
|
2144
|
+
// animation alive so isAutoScrolling stays true. This prevents scroll
|
|
2145
|
+
// events fired by DOM morphing (between cancel and the next rAF) from
|
|
2146
|
+
// being misinterpreted as user-initiated upward scrolls that would
|
|
2147
|
+
// permanently pause auto-follow during streaming.
|
|
2148
|
+
// smoothScrollToBottom() already calls cancelSmoothScroll() internally
|
|
2149
|
+
// before starting its new animation.
|
|
2150
|
+
if (scrollRAF !== null) {
|
|
2151
|
+
cancelAnimationFrame(scrollRAF);
|
|
2152
|
+
scrollRAF = null;
|
|
2153
|
+
}
|
|
2080
2154
|
|
|
2155
|
+
// Treat the render -> next-rAF window as programmatic scrolling too.
|
|
2156
|
+
// This prevents layout/scroll-anchoring scroll events fired before the
|
|
2157
|
+
// actual smooth scroll starts from being misread as user intent.
|
|
2158
|
+
hasPendingAutoScroll = true;
|
|
2081
2159
|
scrollRAF = requestAnimationFrame(() => {
|
|
2082
2160
|
scrollRAF = null;
|
|
2161
|
+
hasPendingAutoScroll = false;
|
|
2083
2162
|
if (!autoFollow.isFollowing()) return;
|
|
2084
2163
|
smoothScrollToBottom(getScrollableContainer(), force ? 220 : 140);
|
|
2085
2164
|
});
|
|
@@ -3776,7 +3855,7 @@ export const createAgentExperience = (
|
|
|
3776
3855
|
lastScrollTop,
|
|
3777
3856
|
nearBottom: isElementNearBottom(body, BOTTOM_THRESHOLD),
|
|
3778
3857
|
userScrollThreshold: USER_SCROLL_THRESHOLD,
|
|
3779
|
-
isAutoScrolling,
|
|
3858
|
+
isAutoScrolling: isAutoScrolling || hasPendingAutoScroll,
|
|
3780
3859
|
pauseOnUpwardScroll: true,
|
|
3781
3860
|
pauseWhenAwayFromBottom: false,
|
|
3782
3861
|
resumeRequiresDownwardScroll: true
|
|
@@ -3915,6 +3994,78 @@ export const createAgentExperience = (
|
|
|
3915
3994
|
textarea?.addEventListener("keydown", handleInputEnter);
|
|
3916
3995
|
textarea?.addEventListener("paste", handleInputPaste);
|
|
3917
3996
|
|
|
3997
|
+
const ATTACHMENT_DROP_ACTIVE_CLASS = "persona-attachment-drop-active";
|
|
3998
|
+
let attachmentFileDragDepth = 0;
|
|
3999
|
+
|
|
4000
|
+
const clearAttachmentDropVisual = () => {
|
|
4001
|
+
attachmentFileDragDepth = 0;
|
|
4002
|
+
container.classList.remove(ATTACHMENT_DROP_ACTIVE_CLASS);
|
|
4003
|
+
};
|
|
4004
|
+
|
|
4005
|
+
const attachmentDropHandlingActive = (): boolean =>
|
|
4006
|
+
config.attachments?.enabled === true && attachmentManager !== null;
|
|
4007
|
+
|
|
4008
|
+
// Visual highlight tracked on `container` (the chat column).
|
|
4009
|
+
const handleAttachmentDragEnterCapture = (e: DragEvent) => {
|
|
4010
|
+
if (!dataTransferHasFiles(e.dataTransfer) || !attachmentDropHandlingActive()) return;
|
|
4011
|
+
attachmentFileDragDepth++;
|
|
4012
|
+
if (attachmentFileDragDepth === 1) {
|
|
4013
|
+
container.classList.add(ATTACHMENT_DROP_ACTIVE_CLASS);
|
|
4014
|
+
}
|
|
4015
|
+
};
|
|
4016
|
+
|
|
4017
|
+
const handleAttachmentDragLeaveCapture = (e: DragEvent) => {
|
|
4018
|
+
if (!dataTransferHasFiles(e.dataTransfer) || !attachmentDropHandlingActive()) return;
|
|
4019
|
+
attachmentFileDragDepth--;
|
|
4020
|
+
if (attachmentFileDragDepth <= 0) {
|
|
4021
|
+
clearAttachmentDropVisual();
|
|
4022
|
+
}
|
|
4023
|
+
};
|
|
4024
|
+
|
|
4025
|
+
// dragover + drop registered on `mount` so the browser default (open file)
|
|
4026
|
+
// is suppressed across the entire widget surface (artifact pane, gaps, etc.).
|
|
4027
|
+
const handleAttachmentDragOverCapture = (e: DragEvent) => {
|
|
4028
|
+
if (!dataTransferHasFiles(e.dataTransfer) || !attachmentDropHandlingActive()) return;
|
|
4029
|
+
e.preventDefault();
|
|
4030
|
+
e.dataTransfer.dropEffect = "copy";
|
|
4031
|
+
};
|
|
4032
|
+
|
|
4033
|
+
const handleAttachmentDropCapture = (e: DragEvent) => {
|
|
4034
|
+
if (!dataTransferHasFiles(e.dataTransfer) || !attachmentDropHandlingActive()) return;
|
|
4035
|
+
e.preventDefault();
|
|
4036
|
+
e.stopPropagation();
|
|
4037
|
+
clearAttachmentDropVisual();
|
|
4038
|
+
const files = Array.from(e.dataTransfer.files ?? []);
|
|
4039
|
+
if (files.length === 0) return;
|
|
4040
|
+
void attachmentManager!.handleFiles(files);
|
|
4041
|
+
};
|
|
4042
|
+
|
|
4043
|
+
const attachmentDropCapture = true;
|
|
4044
|
+
container.addEventListener("dragenter", handleAttachmentDragEnterCapture, attachmentDropCapture);
|
|
4045
|
+
container.addEventListener("dragleave", handleAttachmentDragLeaveCapture, attachmentDropCapture);
|
|
4046
|
+
mount.addEventListener("dragover", handleAttachmentDragOverCapture, attachmentDropCapture);
|
|
4047
|
+
mount.addEventListener("drop", handleAttachmentDropCapture, attachmentDropCapture);
|
|
4048
|
+
|
|
4049
|
+
// Prevent the browser from navigating to/opening a dropped file anywhere on
|
|
4050
|
+
// the page while this widget instance has attachments enabled. These guards
|
|
4051
|
+
// intentionally skip the `dataTransferHasFiles` check because real OS drags
|
|
4052
|
+
// may expose `dataTransfer.types` as a DOMStringList or restrict access
|
|
4053
|
+
// during certain drag phases. The cost is minimal: we suppress the native
|
|
4054
|
+
// "open file" default for ALL drag-overs while the widget is alive and
|
|
4055
|
+
// attachments are on — text drags into the textarea still work because
|
|
4056
|
+
// element-level handlers are unaffected (we don't stopPropagation here).
|
|
4057
|
+
const ownerDoc = mount.ownerDocument;
|
|
4058
|
+
const handleDocDragOver = (e: DragEvent) => {
|
|
4059
|
+
if (!attachmentDropHandlingActive()) return;
|
|
4060
|
+
e.preventDefault();
|
|
4061
|
+
};
|
|
4062
|
+
const handleDocDrop = (e: DragEvent) => {
|
|
4063
|
+
if (!attachmentDropHandlingActive()) return;
|
|
4064
|
+
e.preventDefault();
|
|
4065
|
+
};
|
|
4066
|
+
ownerDoc.addEventListener("dragover", handleDocDragOver);
|
|
4067
|
+
ownerDoc.addEventListener("drop", handleDocDrop);
|
|
4068
|
+
|
|
3918
4069
|
destroyCallbacks.push(() => {
|
|
3919
4070
|
if (composerForm) {
|
|
3920
4071
|
composerForm.removeEventListener("submit", handleSubmit);
|
|
@@ -3923,6 +4074,16 @@ export const createAgentExperience = (
|
|
|
3923
4074
|
textarea?.removeEventListener("paste", handleInputPaste);
|
|
3924
4075
|
});
|
|
3925
4076
|
|
|
4077
|
+
destroyCallbacks.push(() => {
|
|
4078
|
+
container.removeEventListener("dragenter", handleAttachmentDragEnterCapture, attachmentDropCapture);
|
|
4079
|
+
container.removeEventListener("dragleave", handleAttachmentDragLeaveCapture, attachmentDropCapture);
|
|
4080
|
+
mount.removeEventListener("dragover", handleAttachmentDragOverCapture, attachmentDropCapture);
|
|
4081
|
+
mount.removeEventListener("drop", handleAttachmentDropCapture, attachmentDropCapture);
|
|
4082
|
+
ownerDoc.removeEventListener("dragover", handleDocDragOver);
|
|
4083
|
+
ownerDoc.removeEventListener("drop", handleDocDrop);
|
|
4084
|
+
clearAttachmentDropVisual();
|
|
4085
|
+
});
|
|
4086
|
+
|
|
3926
4087
|
destroyCallbacks.push(() => {
|
|
3927
4088
|
session.cancel();
|
|
3928
4089
|
});
|
|
@@ -4958,6 +5119,11 @@ export const createAgentExperience = (
|
|
|
4958
5119
|
}
|
|
4959
5120
|
});
|
|
4960
5121
|
}
|
|
5122
|
+
|
|
5123
|
+
// Create drop overlay if missing
|
|
5124
|
+
if (!container.querySelector(".persona-attachment-drop-overlay")) {
|
|
5125
|
+
container.appendChild(buildDropOverlay(attachmentsConfig.dropOverlay));
|
|
5126
|
+
}
|
|
4961
5127
|
} else {
|
|
4962
5128
|
// Show existing attachment button and update config
|
|
4963
5129
|
attachmentButtonWrapper.style.display = "";
|
|
@@ -4987,6 +5153,8 @@ export const createAgentExperience = (
|
|
|
4987
5153
|
if (attachmentManager) {
|
|
4988
5154
|
attachmentManager.clearAttachments();
|
|
4989
5155
|
}
|
|
5156
|
+
// Remove drop overlay
|
|
5157
|
+
container.querySelector(".persona-attachment-drop-overlay")?.remove();
|
|
4990
5158
|
}
|
|
4991
5159
|
|
|
4992
5160
|
// Update send button styling
|