Support folder attachments in chat composer
Flatten dropped or selected folders into ordinary file attachments while preserving basename display and existing multi-file upload behavior. Make the composer attachment menu actions open their hidden file inputs directly, and label them as Attach files and Attach folder.
Alessandro committed
Jun 3, 2026 at 03:18 UTC
84ea6c27a122479a748d57fe46cba67223a5d8b4
3 files changed
+113
-24
webui/components/chat/attachments/attachmentsStore.js
+78
-5
@@ -112,12 +112,19 @@ const model = {
112
// Handle drop
113
document.addEventListener(
114
"drop",
115
- (e) => {
116
- console.log("Drop detected with files:", e.dataTransfer.files.length);
115
+ async (e) => {
116
+ const dataTransfer = e.dataTransfer;
117
+ console.log("Drop detected with files:", dataTransfer?.files?.length || 0);
118
dragCounter = 0;
119
this.hideDragDropOverlay();
120
120
- const files = e.dataTransfer.files;
121
+ let files = [];
122
+ try {
123
+ files = await this.getDroppedFiles(dataTransfer);
124
+ } catch (error) {
125
+ console.error("Failed to read dropped files:", error);
126
+ files = Array.from(dataTransfer?.files || []);
127
+ }
128
this.handleFiles(files);
129
},
130
false
@@ -209,10 +216,76 @@ const model = {
216
event.target.value = ""; // clear uploader selection to fix issue where same file is ignored the second time
217
},
218
219
+ async getDroppedFiles(dataTransfer) {
220
+ const items = Array.from(dataTransfer?.items || []);
221
+ const entries = items
222
+ .map((item) =>
223
+ typeof item.webkitGetAsEntry === "function"
224
+ ? item.webkitGetAsEntry()
225
+ : null
226
+ )
227
+ .filter(Boolean);
228
+
229
+ if (!entries.length) {
230
+ return Array.from(dataTransfer?.files || []);
231
+ }
232
+
233
+ const nested = await Promise.all(
234
+ entries.map((entry) => this.readEntryFiles(entry))
235
+ );
236
+ const files = nested.flat();
237
+ return files.length ? files : Array.from(dataTransfer?.files || []);
238
+ },
239
+
240
+ async readEntryFiles(entry) {
241
+ if (entry.isFile) {
242
+ return await new Promise((resolve, reject) => {
243
+ entry.file(
244
+ (file) => resolve([file]),
245
+ (error) => reject(error)
246
+ );
247
+ });
248
+ }
249
+
250
+ if (!entry.isDirectory) return [];
251
+
252
+ const reader = entry.createReader();
253
+ const childEntries = await this.readAllDirectoryEntries(reader);
254
+ const nested = await Promise.all(
255
+ childEntries.map((child) => this.readEntryFiles(child))
256
+ );
257
+ return nested.flat();
258
+ },
259
+
260
+ async readAllDirectoryEntries(reader) {
261
+ const entries = [];
262
+
263
+ return await new Promise((resolve, reject) => {
264
+ const readBatch = () => {
265
+ reader.readEntries(
266
+ (batch) => {
267
+ if (!batch.length) {
268
+ resolve(entries);
269
+ return;
270
+ }
271
+ entries.push(...batch);
272
+ readBatch();
273
+ },
274
+ (error) => reject(error)
275
+ );
276
+ };
277
+
278
+ readBatch();
279
+ });
280
+ },
281
+
282
// File handling logic (moved from index.js)
283
handleFiles(files) {
214
- console.log("handleFiles called with", files.length, "files");
215
- Array.from(files).forEach((file) => {
284
+ const fileList = Array.from(files || []);
285
+ console.log("handleFiles called with", fileList.length, "files");
286
+ fileList.forEach((file) => {
287
+ if (!file?.name) return;
288
+
289
console.log("Processing file:", file.name, file.type);
290
const ext = file.name.split(".").pop().toLowerCase();
291
const isImage = ["jpg", "jpeg", "png", "bmp", "gif", "webp", "svg"].includes(
webui/components/chat/attachments/dragDropOverlay.html
+1
-1
@@ -17,7 +17,7 @@
17
x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-300"
18
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" class="dragdrop-overlay">
19
<img src="public/dragndrop.svg" alt="Drop files" class="dragdrop-icon">
20
- <div class="dragdrop-text">Drop files to attach them to your message</div>
20
+ <div class="dragdrop-text">Drop files or folders to attach them to your message</div>
21
<div class="dragdrop-subtext"></div>
22
</div>
23
</template>
webui/components/chat/input/bottom-actions.html
+34
-18
@@ -11,20 +11,42 @@
11
<body>
12
<template x-if="$store.chatInput">
13
<div class="chat-bottom-actions-menu">
14
- <label class="chat-bottom-menu-item" @click.stop>
15
- <input
16
- type="file"
17
- id="chat-file-input"
18
- class="chat-bottom-menu-file-input"
19
- accept="*"
20
- multiple
21
- @change="$store.chatAttachments.handleFileUpload($event); $store.chatInput.closeChatMoreMenu()"
22
- >
14
+ <input
15
+ type="file"
16
+ id="chat-file-input"
17
+ class="chat-bottom-menu-file-input"
18
+ accept="*"
19
+ multiple
20
+ @change="$store.chatAttachments.handleFileUpload($event); $store.chatInput.closeChatMoreMenu()"
21
+ >
22
+ <button
23
+ type="button"
24
+ class="chat-bottom-menu-item"
25
+ @click.stop="document.getElementById('chat-file-input')?.click()"
26
+ >
27
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
28
<path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z" />
29
</svg>
26
- <span>Add attachments</span>
27
- </label>
30
+ <span>Attach files</span>
31
+ </button>
32
+
33
+ <input
34
+ type="file"
35
+ id="chat-folder-input"
36
+ class="chat-bottom-menu-file-input"
37
+ webkitdirectory
38
+ directory
39
+ multiple
40
+ @change="$store.chatAttachments.handleFileUpload($event); $store.chatInput.closeChatMoreMenu()"
41
+ >
42
+ <button
43
+ type="button"
44
+ class="chat-bottom-menu-item"
45
+ @click.stop="document.getElementById('chat-folder-input')?.click()"
46
+ >
47
+ <span class="material-symbols-outlined" aria-hidden="true">drive_folder_upload</span>
48
+ <span>Attach folder</span>
49
+ </button>
50
51
<button
52
type="button"
@@ -94,10 +116,6 @@
116
button.chat-bottom-menu-item {
117
border: none;
118
}
97
- label.chat-bottom-menu-item {
98
- position: relative;
99
- font-weight: inherit;
100
- }
119
.chat-bottom-menu-file-input {
120
position: absolute;
121
width: 1px;
@@ -110,13 +128,11 @@
128
border: 0;
129
opacity: 0;
130
}
113
- button.chat-bottom-menu-item:hover:not(:disabled),
114
- label.chat-bottom-menu-item:hover {
131
+ button.chat-bottom-menu-item:hover:not(:disabled) {
132
opacity: 1;
133
background-color: var(--color-background-hover);
134
}
135
button.chat-bottom-menu-item:active:not(:disabled) { opacity: 0.72; }
119
- label.chat-bottom-menu-item:active { opacity: 0.72; }
136
button.chat-bottom-menu-item:disabled {
137
opacity: 0.38;
138
cursor: not-allowed;