@epicbagel/saas-kit 1.1.0 → 1.2.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.
@@ -0,0 +1,416 @@
1
+ "use client";
2
+ import {
3
+ Text
4
+ } from "./chunk-EIY5LLSP.js";
5
+ import {
6
+ Tooltip,
7
+ TooltipContent,
8
+ TooltipTrigger
9
+ } from "./chunk-ZRLUUZHK.js";
10
+ import {
11
+ Button
12
+ } from "./chunk-HQRWRRZJ.js";
13
+ import {
14
+ cn
15
+ } from "./chunk-G6SRSWXB.js";
16
+
17
+ // src/primitives/file-dropzone.tsx
18
+ import { FileUp, Loader2 } from "lucide-react";
19
+ import { createPortal } from "react-dom";
20
+ import {
21
+ forwardRef,
22
+ useCallback,
23
+ useEffect,
24
+ useRef,
25
+ useState
26
+ } from "react";
27
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
28
+ function FileDropzone({
29
+ onFile,
30
+ onFiles,
31
+ multiple = false,
32
+ accept,
33
+ disabled = false,
34
+ busy: externalBusy = false,
35
+ label = "Drop a file, or choose one",
36
+ description,
37
+ busyLabel = "Reading file\u2026",
38
+ icon,
39
+ variant = "dropzone",
40
+ className
41
+ }) {
42
+ const inputRef = useRef(null);
43
+ const [dragging, setDragging] = useState(false);
44
+ const [internalBusy, setInternalBusy] = useState(false);
45
+ const busy = externalBusy || internalBusy;
46
+ useFileDropGuard();
47
+ async function select(files) {
48
+ if (files.length === 0 || disabled || busy) return;
49
+ setInternalBusy(true);
50
+ try {
51
+ if (multiple) {
52
+ await onFiles?.(files);
53
+ } else {
54
+ const first = files[0];
55
+ if (first) await onFile?.(first);
56
+ }
57
+ } finally {
58
+ setInternalBusy(false);
59
+ if (inputRef.current) inputRef.current.value = "";
60
+ }
61
+ }
62
+ function onDrop(event) {
63
+ event.preventDefault();
64
+ setDragging(false);
65
+ void select(Array.from(event.dataTransfer.files ?? []));
66
+ }
67
+ const trigger = /* @__PURE__ */ jsx(
68
+ Button,
69
+ {
70
+ type: "button",
71
+ "data-file-dropzone": "true",
72
+ variant: "outline",
73
+ size: variant === "icon" ? "icon" : "default",
74
+ "aria-label": variant === "icon" ? String(busy ? busyLabel : label) : void 0,
75
+ disabled: disabled || busy,
76
+ onClick: () => inputRef.current?.click(),
77
+ onDragEnter: (event) => {
78
+ event.preventDefault();
79
+ setDragging(true);
80
+ },
81
+ onDragOver: (event) => {
82
+ event.preventDefault();
83
+ setDragging(true);
84
+ },
85
+ onDragLeave: () => setDragging(false),
86
+ onDrop,
87
+ className: cn(
88
+ variant === "dropzone" ? "h-auto w-full flex-col whitespace-normal border-2 border-dashed px-6 py-8 text-center" : variant === "button" ? "w-fit" : "",
89
+ dragging && "border-primary bg-primary/5",
90
+ className
91
+ ),
92
+ children: /* @__PURE__ */ jsx(
93
+ FileDropPrompt,
94
+ {
95
+ variant,
96
+ icon: busy ? /* @__PURE__ */ jsx(Loader2, { className: "h-5 w-5 animate-spin" }) : icon ?? /* @__PURE__ */ jsx(FileUp, { className: "h-5 w-5" }),
97
+ label: busy ? busyLabel : label,
98
+ description
99
+ }
100
+ )
101
+ }
102
+ );
103
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
104
+ /* @__PURE__ */ jsx(
105
+ "input",
106
+ {
107
+ ref: inputRef,
108
+ type: "file",
109
+ accept,
110
+ multiple,
111
+ disabled: disabled || busy,
112
+ onChange: (event) => void select(Array.from(event.target.files ?? [])),
113
+ className: "sr-only",
114
+ tabIndex: -1,
115
+ "aria-hidden": "true"
116
+ }
117
+ ),
118
+ variant === "icon" && description ? /* @__PURE__ */ jsxs(Tooltip, { children: [
119
+ /* @__PURE__ */ jsx(TooltipTrigger, { asChild: true, children: trigger }),
120
+ /* @__PURE__ */ jsx(TooltipContent, { children: description })
121
+ ] }) : trigger
122
+ ] });
123
+ }
124
+ function carriesFiles(data) {
125
+ if (!data) return false;
126
+ return Array.from(data.types ?? []).includes("Files") || (data.files?.length ?? 0) > 0;
127
+ }
128
+ function isNativeFileInput(target) {
129
+ const element = target;
130
+ return element?.tagName === "INPUT" && element.type === "file";
131
+ }
132
+ var inPageDrag = false;
133
+ var inPageDragUsers = 0;
134
+ var markInPageDrag = () => {
135
+ inPageDrag = true;
136
+ };
137
+ var clearInPageDrag = () => {
138
+ inPageDrag = false;
139
+ };
140
+ var clearAfterDrop = () => {
141
+ window.setTimeout(clearInPageDrag, 0);
142
+ };
143
+ var clearOnMove = () => {
144
+ if (inPageDrag) inPageDrag = false;
145
+ };
146
+ function trackInPageDrags() {
147
+ if (inPageDragUsers++ === 0) {
148
+ window.addEventListener("dragstart", markInPageDrag, true);
149
+ window.addEventListener("dragend", clearInPageDrag, true);
150
+ window.addEventListener("drop", clearAfterDrop);
151
+ window.addEventListener("mousemove", clearOnMove, { passive: true });
152
+ }
153
+ return () => {
154
+ if (--inPageDragUsers > 0) return;
155
+ window.removeEventListener("dragstart", markInPageDrag, true);
156
+ window.removeEventListener("dragend", clearInPageDrag, true);
157
+ window.removeEventListener("drop", clearAfterDrop);
158
+ window.removeEventListener("mousemove", clearOnMove);
159
+ inPageDrag = false;
160
+ };
161
+ }
162
+ function isUnclaimedFileDrag(event) {
163
+ return !event.defaultPrevented && !inPageDrag && carriesFiles(event.dataTransfer) && !isNativeFileInput(event.target);
164
+ }
165
+ function installFileDropGuard() {
166
+ if (typeof window === "undefined") return () => void 0;
167
+ const release = trackInPageDrags();
168
+ const onDragOver = (event) => {
169
+ if (!isUnclaimedFileDrag(event)) return;
170
+ event.preventDefault();
171
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "none";
172
+ };
173
+ const onDrop = (event) => {
174
+ if (isUnclaimedFileDrag(event)) event.preventDefault();
175
+ };
176
+ window.addEventListener("dragover", onDragOver);
177
+ window.addEventListener("drop", onDrop);
178
+ let installed = true;
179
+ return () => {
180
+ if (!installed) return;
181
+ installed = false;
182
+ window.removeEventListener("dragover", onDragOver);
183
+ window.removeEventListener("drop", onDrop);
184
+ release();
185
+ };
186
+ }
187
+ function useFileDropGuard(enabled = true) {
188
+ useEffect(() => enabled ? installFileDropGuard() : void 0, [enabled]);
189
+ }
190
+ function FileDropPrompt({
191
+ icon,
192
+ label,
193
+ description,
194
+ variant = "dropzone"
195
+ }) {
196
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
197
+ /* @__PURE__ */ jsx("span", { className: cn(
198
+ "flex items-center justify-center text-muted-foreground",
199
+ variant === "dropzone" && "mb-2 h-10 w-10 rounded-full bg-muted"
200
+ ), children: icon }),
201
+ /* @__PURE__ */ jsx("span", { className: variant === "icon" ? "sr-only" : "font-medium", children: label }),
202
+ description && variant === "dropzone" && /* @__PURE__ */ jsx(Text, { variant: "muted", className: "mt-1 max-w-md text-xs", children: description })
203
+ ] });
204
+ }
205
+ var DRAG_HEARTBEAT_MS = 1e3;
206
+ function visibleBand(element) {
207
+ const rect = element.getBoundingClientRect();
208
+ const viewport = window.innerHeight;
209
+ if (rect.top >= 0 && rect.bottom <= viewport) return null;
210
+ const top = Math.max(rect.top, 0);
211
+ const bottom = Math.min(rect.bottom, viewport);
212
+ return bottom > top ? { top: top - rect.top, height: bottom - top } : null;
213
+ }
214
+ var FileDropArea = forwardRef(function FileDropArea2({
215
+ onFiles,
216
+ disabled = false,
217
+ scope = "area",
218
+ label = "Drop to attach",
219
+ description,
220
+ icon,
221
+ className,
222
+ children,
223
+ onDragEnter,
224
+ onDragOver,
225
+ onDragLeave,
226
+ ...props
227
+ }, ref) {
228
+ const active = Boolean(onFiles) && !disabled;
229
+ const [dragging, setDragging] = useState(false);
230
+ const [band, setBand] = useState(null);
231
+ const root = useRef(null);
232
+ const entered = useRef([]);
233
+ useFileDropGuard(Boolean(onFiles));
234
+ const setRoot = useCallback(
235
+ (node) => {
236
+ root.current = node;
237
+ if (typeof ref === "function") ref(node);
238
+ else if (ref) ref.current = node;
239
+ },
240
+ [ref]
241
+ );
242
+ const reset = useCallback(() => {
243
+ entered.current = [];
244
+ setDragging(false);
245
+ }, []);
246
+ const enter = useCallback((target) => {
247
+ if (target && !entered.current.includes(target)) entered.current.push(target);
248
+ setDragging(true);
249
+ }, []);
250
+ const leave = useCallback((target, container) => {
251
+ entered.current = entered.current.filter(
252
+ (node) => node !== target && node instanceof Node && node.isConnected && container.contains(node)
253
+ );
254
+ if (entered.current.length === 0) setDragging(false);
255
+ }, []);
256
+ const deliver = useCallback(
257
+ (data) => {
258
+ const files = Array.from(data?.files ?? []);
259
+ if (files.length > 0) void onFiles?.(files);
260
+ },
261
+ [onFiles]
262
+ );
263
+ useEffect(() => {
264
+ if (scope !== "page" || !active) return;
265
+ const onEnter = (event) => {
266
+ if (!isUnclaimedFileDrag(event)) return;
267
+ event.preventDefault();
268
+ enter(event.target);
269
+ };
270
+ const onOver = (event) => {
271
+ if (!isUnclaimedFileDrag(event)) return;
272
+ event.preventDefault();
273
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
274
+ if (entered.current.length === 0) enter(event.target);
275
+ };
276
+ const onLeave = (event) => leave(event.target, document);
277
+ const onDrop = (event) => {
278
+ reset();
279
+ if (!isUnclaimedFileDrag(event)) return;
280
+ event.preventDefault();
281
+ deliver(event.dataTransfer);
282
+ };
283
+ document.addEventListener("dragenter", onEnter);
284
+ document.addEventListener("dragover", onOver);
285
+ document.addEventListener("dragleave", onLeave);
286
+ document.addEventListener("drop", onDrop);
287
+ return () => {
288
+ document.removeEventListener("dragenter", onEnter);
289
+ document.removeEventListener("dragover", onOver);
290
+ document.removeEventListener("dragleave", onLeave);
291
+ document.removeEventListener("drop", onDrop);
292
+ };
293
+ }, [active, deliver, enter, leave, reset, scope]);
294
+ useEffect(() => {
295
+ if (!dragging) return;
296
+ let heartbeat = window.setTimeout(reset, DRAG_HEARTBEAT_MS);
297
+ const onDragOverAnywhere = () => {
298
+ window.clearTimeout(heartbeat);
299
+ heartbeat = window.setTimeout(reset, DRAG_HEARTBEAT_MS);
300
+ };
301
+ const onLeaveWindow = (event) => {
302
+ if (event.relatedTarget) return;
303
+ const { clientX: x, clientY: y } = event;
304
+ if (x <= 0 || y <= 0 || x >= window.innerWidth || y >= window.innerHeight) reset();
305
+ };
306
+ window.addEventListener("dragover", onDragOverAnywhere, true);
307
+ window.addEventListener("drop", reset);
308
+ window.addEventListener("dragend", reset);
309
+ window.addEventListener("dragleave", onLeaveWindow);
310
+ window.addEventListener("mousemove", reset, { passive: true });
311
+ return () => {
312
+ window.clearTimeout(heartbeat);
313
+ window.removeEventListener("dragover", onDragOverAnywhere, true);
314
+ window.removeEventListener("drop", reset);
315
+ window.removeEventListener("dragend", reset);
316
+ window.removeEventListener("dragleave", onLeaveWindow);
317
+ window.removeEventListener("mousemove", reset);
318
+ };
319
+ }, [dragging, reset]);
320
+ const showOverlay = dragging && active;
321
+ useEffect(() => {
322
+ if (!showOverlay || scope !== "area") return;
323
+ const measure = () => {
324
+ if (root.current) setBand(visibleBand(root.current));
325
+ };
326
+ measure();
327
+ window.addEventListener("scroll", measure, { capture: true, passive: true });
328
+ window.addEventListener("resize", measure);
329
+ return () => {
330
+ window.removeEventListener("scroll", measure, { capture: true });
331
+ window.removeEventListener("resize", measure);
332
+ setBand(null);
333
+ };
334
+ }, [scope, showOverlay]);
335
+ const handlesHere = active && scope === "area";
336
+ const prompt = /* @__PURE__ */ jsx(FileDropPrompt, { icon: icon ?? /* @__PURE__ */ jsx(FileUp, { className: "h-5 w-5" }), label, description });
337
+ let overlay = null;
338
+ if (showOverlay && scope === "page" && typeof document !== "undefined") {
339
+ overlay = createPortal(
340
+ /* @__PURE__ */ jsx(
341
+ "div",
342
+ {
343
+ "data-slot": "file-drop-overlay",
344
+ "data-scope": "page",
345
+ "aria-hidden": "true",
346
+ className: "pointer-events-none fixed inset-0 z-50 bg-background/80 p-3 supports-backdrop-filter:backdrop-blur-sm motion-safe:animate-in motion-safe:fade-in-0",
347
+ children: /* @__PURE__ */ jsx("div", { className: "flex h-full flex-col items-center justify-center rounded-xl border-2 border-dashed border-primary p-6 text-center text-sm", children: prompt })
348
+ }
349
+ ),
350
+ document.body
351
+ );
352
+ } else if (showOverlay && scope === "area") {
353
+ overlay = /* @__PURE__ */ jsx(
354
+ "div",
355
+ {
356
+ "data-slot": "file-drop-overlay",
357
+ "data-scope": "area",
358
+ "aria-hidden": "true",
359
+ className: "absolute inset-0 z-30 rounded-[inherit] border-2 border-dashed border-primary bg-background/90 text-sm supports-backdrop-filter:backdrop-blur-sm motion-safe:animate-in motion-safe:fade-in-0 [&_*]:pointer-events-none",
360
+ children: /* @__PURE__ */ jsx(
361
+ "div",
362
+ {
363
+ "data-slot": "file-drop-prompt",
364
+ className: "absolute inset-x-0 flex flex-col items-center justify-center p-6 text-center",
365
+ style: band ?? { top: 0, height: "100%" },
366
+ children: prompt
367
+ }
368
+ )
369
+ }
370
+ );
371
+ }
372
+ return /* @__PURE__ */ jsxs(
373
+ "div",
374
+ {
375
+ ref: setRoot,
376
+ "data-file-drop": showOverlay ? "active" : void 0,
377
+ className: cn("relative", className),
378
+ ...props,
379
+ onDragEnter: (event) => {
380
+ onDragEnter?.(event);
381
+ if (!handlesHere || !isUnclaimedFileDrag(event.nativeEvent)) return;
382
+ event.preventDefault();
383
+ enter(event.target);
384
+ },
385
+ onDragOver: (event) => {
386
+ onDragOver?.(event);
387
+ if (!handlesHere || !isUnclaimedFileDrag(event.nativeEvent)) return;
388
+ event.preventDefault();
389
+ event.dataTransfer.dropEffect = "copy";
390
+ if (entered.current.length === 0) enter(event.target);
391
+ },
392
+ onDragLeave: (event) => {
393
+ onDragLeave?.(event);
394
+ if (scope === "area") leave(event.target, event.currentTarget);
395
+ },
396
+ onDrop: (event) => {
397
+ if (scope !== "area") return;
398
+ reset();
399
+ if (!onFiles || !isUnclaimedFileDrag(event.nativeEvent)) return;
400
+ event.preventDefault();
401
+ if (!disabled) deliver(event.dataTransfer);
402
+ },
403
+ children: [
404
+ children,
405
+ overlay
406
+ ]
407
+ }
408
+ );
409
+ });
410
+
411
+ export {
412
+ FileDropzone,
413
+ installFileDropGuard,
414
+ useFileDropGuard,
415
+ FileDropArea
416
+ };
@@ -40,7 +40,8 @@ var _chunkKENBU3NVcjs = require('../chunk-KENBU3NV.cjs');
40
40
  require('../chunk-ELPV44S6.cjs');
41
41
 
42
42
 
43
- var _chunkI5UQSBBZcjs = require('../chunk-I5UQSBBZ.cjs');
43
+
44
+ var _chunkKADJ56AHcjs = require('../chunk-KADJ56AH.cjs');
44
45
 
45
46
 
46
47
 
@@ -149,7 +150,7 @@ var ChatThread = _react.forwardRef.call(void 0,
149
150
  _chunkXABXUP6Lcjs.MessageScrollerContent,
150
151
  {
151
152
  className: _chunkKWLSBTB2cjs.cn.call(void 0,
152
- workspace ? "mx-auto w-full max-w-3xl gap-7 px-4 py-6 md:px-6 md:py-8" : "gap-2.5 px-px py-px"
153
+ workspace ? "mx-auto w-full max-w-3xl gap-10 px-4 py-6 md:px-6 md:py-8" : "gap-2.5 px-px py-px"
153
154
  ),
154
155
  children: [
155
156
  messages.map((m, index) => {
@@ -163,7 +164,7 @@ var ChatThread = _react.forwardRef.call(void 0,
163
164
  {
164
165
  messageId: m.id,
165
166
  scrollAnchor: m.id === anchorId,
166
- className: "flex flex-col gap-3",
167
+ className: _chunkKWLSBTB2cjs.cn.call(void 0, "flex flex-col", workspace ? "gap-5" : "gap-3"),
167
168
  children: [
168
169
  startsTimelineSection ? /* @__PURE__ */ _jsxruntime.jsx.call(void 0, TimelineMarker, { label: relativeTime }) : null,
169
170
  /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunk2AU7ZRGXcjs.Message, { align: isUser ? "end" : "start", children: /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunk2AU7ZRGXcjs.MessageContent, { children: [
@@ -608,6 +609,9 @@ var Chat = _react.forwardRef.call(void 0, function Chat2({
608
609
  emptyFileDropzone = false,
609
610
  emptyFileDropzoneLabel = "Drop files here, or choose them",
610
611
  emptyFileDropzoneDescription,
612
+ fileDrop = "conversation",
613
+ fileDropLabel,
614
+ fileDropDescription,
611
615
  voice = true,
612
616
  voiceTitle,
613
617
  activeVoiceTitle,
@@ -660,21 +664,34 @@ var Chat = _react.forwardRef.call(void 0, function Chat2({
660
664
  }),
661
665
  []
662
666
  );
667
+ const dropFiles = _react.useCallback.call(void 0,
668
+ (files) => {
669
+ if (!conversationStarted && mode === "voice") setUserMode("text");
670
+ return _optionalChain([onFilesSelected, 'optionalCall', _16 => _16(files)]);
671
+ },
672
+ [conversationStarted, mode, onFilesSelected]
673
+ );
663
674
  const application = layout === "application";
664
675
  const workspace = layout === "workspace";
665
676
  const showEmptyFileDropzone = emptyFileDropzone && Boolean(onFilesSelected) && attachments.length === 0;
677
+ const showEmptyPending = !conversationStarted && pending && Boolean(emptyPendingContent);
666
678
  return /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
667
- "div",
679
+ _chunkKADJ56AHcjs.FileDropArea,
668
680
  {
669
681
  "data-slot": "chat",
670
682
  "data-layout": layout,
683
+ onFiles: onFilesSelected ? dropFiles : void 0,
684
+ disabled: fileDrop === false || showEmptyPending,
685
+ scope: fileDrop === "page" ? "page" : "area",
686
+ label: fileDropLabel,
687
+ description: fileDropDescription,
671
688
  className: _chunkKWLSBTB2cjs.cn.call(void 0,
672
689
  "w-full",
673
690
  application && "flex min-h-[calc(100svh-4rem)] flex-1 flex-col md:block md:min-h-0",
674
691
  workspace && "flex h-full min-h-0 flex-1 flex-col overflow-hidden",
675
692
  className
676
693
  ),
677
- children: !conversationStarted && pending && emptyPendingContent ? /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
694
+ children: showEmptyPending ? /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
678
695
  "div",
679
696
  {
680
697
  "data-slot": "chat-empty-pending",
@@ -727,7 +744,7 @@ var Chat = _react.forwardRef.call(void 0, function Chat2({
727
744
  ),
728
745
  children: [
729
746
  showEmptyFileDropzone && onFilesSelected && /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
730
- _chunkI5UQSBBZcjs.FileDropzone,
747
+ _chunkKADJ56AHcjs.FileDropzone,
731
748
  {
732
749
  multiple: true,
733
750
  onFiles: onFilesSelected,
@@ -251,6 +251,21 @@ interface ChatProps {
251
251
  emptyFileDropzoneLabel?: ReactNode;
252
252
  /** Supporting file type or workflow copy inside the drop target. */
253
253
  emptyFileDropzoneDescription?: ReactNode;
254
+ /**
255
+ * Where a dragged file can be dropped. With `onFilesSelected`, a file
256
+ * dragged over the conversation raises a "Drop to attach" overlay, and a
257
+ * drop hands every file to `onFilesSelected`, exactly as the composer's
258
+ * file menu does: same validation, same staged files, same errors.
259
+ * `"conversation"` (the default) takes drops on the whole Chat, `"page"`
260
+ * anywhere in the document (its overlay then covers the viewport), and
261
+ * `false` none. Either way a file dropped where nothing takes it never
262
+ * opens in the browser.
263
+ */
264
+ fileDrop?: "conversation" | "page" | false;
265
+ /** The drop overlay's label. Defaults to "Drop to attach". */
266
+ fileDropLabel?: ReactNode;
267
+ /** A muted line under the overlay's label, such as the accepted types and limits. */
268
+ fileDropDescription?: ReactNode;
254
269
  /** Enable voice dictation. Defaults to true. */
255
270
  voice?: boolean;
256
271
  /** Title shown inside the voice overlay. */
@@ -318,6 +333,10 @@ interface ChatHandle {
318
333
  * **Active state** (messages exist) — always the compact text input;
319
334
  * the mic stays as an accessory icon.
320
335
  *
336
+ * **Files** — with `onFilesSelected`, the composer's menu picks files and a
337
+ * file dragged anywhere on the conversation can be dropped (`fileDrop`);
338
+ * both hand the files to `onFilesSelected`, which validates and stages them.
339
+ *
321
340
  * Stateless w.r.t. the conversation — pass `messages` and handle
322
341
  * `onSend` from the parent. The input text is managed internally.
323
342
  */
@@ -251,6 +251,21 @@ interface ChatProps {
251
251
  emptyFileDropzoneLabel?: ReactNode;
252
252
  /** Supporting file type or workflow copy inside the drop target. */
253
253
  emptyFileDropzoneDescription?: ReactNode;
254
+ /**
255
+ * Where a dragged file can be dropped. With `onFilesSelected`, a file
256
+ * dragged over the conversation raises a "Drop to attach" overlay, and a
257
+ * drop hands every file to `onFilesSelected`, exactly as the composer's
258
+ * file menu does: same validation, same staged files, same errors.
259
+ * `"conversation"` (the default) takes drops on the whole Chat, `"page"`
260
+ * anywhere in the document (its overlay then covers the viewport), and
261
+ * `false` none. Either way a file dropped where nothing takes it never
262
+ * opens in the browser.
263
+ */
264
+ fileDrop?: "conversation" | "page" | false;
265
+ /** The drop overlay's label. Defaults to "Drop to attach". */
266
+ fileDropLabel?: ReactNode;
267
+ /** A muted line under the overlay's label, such as the accepted types and limits. */
268
+ fileDropDescription?: ReactNode;
254
269
  /** Enable voice dictation. Defaults to true. */
255
270
  voice?: boolean;
256
271
  /** Title shown inside the voice overlay. */
@@ -318,6 +333,10 @@ interface ChatHandle {
318
333
  * **Active state** (messages exist) — always the compact text input;
319
334
  * the mic stays as an accessory icon.
320
335
  *
336
+ * **Files** — with `onFilesSelected`, the composer's menu picks files and a
337
+ * file dragged anywhere on the conversation can be dropped (`fileDrop`);
338
+ * both hand the files to `onFilesSelected`, which validates and stages them.
339
+ *
321
340
  * Stateless w.r.t. the conversation — pass `messages` and handle
322
341
  * `onSend` from the parent. The input text is managed internally.
323
342
  */
@@ -39,8 +39,9 @@ import {
39
39
  } from "../chunk-VY5U4VGV.js";
40
40
  import "../chunk-INNGGLMZ.js";
41
41
  import {
42
+ FileDropArea,
42
43
  FileDropzone
43
- } from "../chunk-6V4XMQ6D.js";
44
+ } from "../chunk-M7FYGAGO.js";
44
45
  import {
45
46
  DropdownMenu,
46
47
  DropdownMenuContent,
@@ -149,7 +150,7 @@ var ChatThread = forwardRef(
149
150
  MessageScrollerContent,
150
151
  {
151
152
  className: cn(
152
- workspace ? "mx-auto w-full max-w-3xl gap-7 px-4 py-6 md:px-6 md:py-8" : "gap-2.5 px-px py-px"
153
+ workspace ? "mx-auto w-full max-w-3xl gap-10 px-4 py-6 md:px-6 md:py-8" : "gap-2.5 px-px py-px"
153
154
  ),
154
155
  children: [
155
156
  messages.map((m, index) => {
@@ -163,7 +164,7 @@ var ChatThread = forwardRef(
163
164
  {
164
165
  messageId: m.id,
165
166
  scrollAnchor: m.id === anchorId,
166
- className: "flex flex-col gap-3",
167
+ className: cn("flex flex-col", workspace ? "gap-5" : "gap-3"),
167
168
  children: [
168
169
  startsTimelineSection ? /* @__PURE__ */ jsx(TimelineMarker, { label: relativeTime }) : null,
169
170
  /* @__PURE__ */ jsx(Message, { align: isUser ? "end" : "start", children: /* @__PURE__ */ jsxs(MessageContent, { children: [
@@ -608,6 +609,9 @@ var Chat = forwardRef(function Chat2({
608
609
  emptyFileDropzone = false,
609
610
  emptyFileDropzoneLabel = "Drop files here, or choose them",
610
611
  emptyFileDropzoneDescription,
612
+ fileDrop = "conversation",
613
+ fileDropLabel,
614
+ fileDropDescription,
611
615
  voice = true,
612
616
  voiceTitle,
613
617
  activeVoiceTitle,
@@ -660,21 +664,34 @@ var Chat = forwardRef(function Chat2({
660
664
  }),
661
665
  []
662
666
  );
667
+ const dropFiles = useCallback(
668
+ (files) => {
669
+ if (!conversationStarted && mode === "voice") setUserMode("text");
670
+ return onFilesSelected?.(files);
671
+ },
672
+ [conversationStarted, mode, onFilesSelected]
673
+ );
663
674
  const application = layout === "application";
664
675
  const workspace = layout === "workspace";
665
676
  const showEmptyFileDropzone = emptyFileDropzone && Boolean(onFilesSelected) && attachments.length === 0;
677
+ const showEmptyPending = !conversationStarted && pending && Boolean(emptyPendingContent);
666
678
  return /* @__PURE__ */ jsx(
667
- "div",
679
+ FileDropArea,
668
680
  {
669
681
  "data-slot": "chat",
670
682
  "data-layout": layout,
683
+ onFiles: onFilesSelected ? dropFiles : void 0,
684
+ disabled: fileDrop === false || showEmptyPending,
685
+ scope: fileDrop === "page" ? "page" : "area",
686
+ label: fileDropLabel,
687
+ description: fileDropDescription,
671
688
  className: cn(
672
689
  "w-full",
673
690
  application && "flex min-h-[calc(100svh-4rem)] flex-1 flex-col md:block md:min-h-0",
674
691
  workspace && "flex h-full min-h-0 flex-1 flex-col overflow-hidden",
675
692
  className
676
693
  ),
677
- children: !conversationStarted && pending && emptyPendingContent ? /* @__PURE__ */ jsx(
694
+ children: showEmptyPending ? /* @__PURE__ */ jsx(
678
695
  "div",
679
696
  {
680
697
  "data-slot": "chat-empty-pending",
@@ -2,11 +2,17 @@
2
2
  "use client";
3
3
 
4
4
 
5
- var _chunkI5UQSBBZcjs = require('../chunk-I5UQSBBZ.cjs');
5
+
6
+
7
+
8
+ var _chunkKADJ56AHcjs = require('../chunk-KADJ56AH.cjs');
6
9
  require('../chunk-373W6VAD.cjs');
7
10
  require('../chunk-U2NES7VR.cjs');
8
11
  require('../chunk-L4DH72UF.cjs');
9
12
  require('../chunk-KWLSBTB2.cjs');
10
13
 
11
14
 
12
- exports.FileDropzone = _chunkI5UQSBBZcjs.FileDropzone;
15
+
16
+
17
+
18
+ exports.FileDropArea = _chunkKADJ56AHcjs.FileDropArea; exports.FileDropzone = _chunkKADJ56AHcjs.FileDropzone; exports.installFileDropGuard = _chunkKADJ56AHcjs.installFileDropGuard; exports.useFileDropGuard = _chunkKADJ56AHcjs.useFileDropGuard;