@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtypelabs/persona",
3
- "version": "3.10.0",
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",
@@ -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
+ });
@@ -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 && !session.isStreaming()) {
874
- session.sendMessage(text);
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
- cancelAutoScroll();
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