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;