From 602ce8183dafc53ad38136da2be986ce26d20300 Mon Sep 17 00:00:00 2001 From: Asaf Benatia Date: Fri, 21 Aug 2026 06:29:21 +0300 Subject: [PATCH 1/4] feat(composer): drop non-image files as filesystem paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropping an audio file, PDF or video onto the composer was rejected with 'Please attach image files only'. Images still become attachments; everything else now inserts its filesystem path as text, so the agent opens the file from disk itself and a large recording never crosses the wire — the same thing dropping a file into a terminal does. The renderer cannot read a dropped file's path (Electron removed File.path in v32), so preload exposes webUtils.getPathForFile through the desktop bridge. In a browser tab, where no path exists, the drop says so instead of failing silently. Note the deliberate absence of focusComposer() on the path branch: focusing synchronously after the insert makes the not-yet-reconciled Lexical editor sync its stale empty state back over the text, which is the same footgun makeComposerMentionDragHandlers documents for the file-tree mention drop. --- apps/desktop/src/preload.ts | 11 +++- apps/web/src/components/chat/ChatComposer.tsx | 50 ++++++++++++++++++- .../components/chat/droppedFilePaths.test.ts | 29 +++++++++++ .../src/components/chat/droppedFilePaths.ts | 31 ++++++++++++ packages/contracts/src/ipc.ts | 7 +++ 5 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/chat/droppedFilePaths.test.ts create mode 100644 apps/web/src/components/chat/droppedFilePaths.ts diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index ee03141f2d82..c24771cdde0d 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -5,7 +5,7 @@ import type { DesktopPreviewTabState, } from "@t3tools/contracts"; import { exposeClerkBridge } from "@clerk/electron/preload"; -import { contextBridge, ipcRenderer } from "electron"; +import { contextBridge, ipcRenderer, webUtils } from "electron"; import * as IpcChannels from "./ipc/channels.ts"; @@ -35,6 +35,15 @@ contextBridge.exposeInMainWorld("desktopBridge", { } return result as ReturnType; }, + getPathForFile: (file: File) => { + // Throws for a File that never came from the OS (e.g. built by the page). + try { + const path = webUtils.getPathForFile(file); + return path.length > 0 ? path : null; + } catch { + return null; + } + }, getSystemLocale: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); return typeof result === "string" ? result : null; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index a0518bdabef2..4ff96f73db88 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -122,6 +122,7 @@ import { submitComposerDraft, } from "./composerSubmission"; import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; +import { formatDroppedFilePaths } from "./droppedFilePaths"; type ComposerCommandMenuPosition = { bottom: number; @@ -2563,6 +2564,37 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) void addComposerImages(imageFiles); }; + /** + * Insert dropped non-image files as filesystem paths. Only the desktop app + * can resolve a path from a dropped File, so in a browser tab this reports + * why nothing was inserted instead of silently swallowing the drop. + */ + const insertDroppedFilePaths = (files: File[]) => { + const resolvePath = window.desktopBridge?.getPathForFile; + if (!resolvePath) { + setThreadError( + activeThreadId, + "Attaching files by path needs the desktop app. Paste an image, or type the path.", + ); + return; + } + const paths = files + .map((file) => resolvePath(file)) + .filter((path): path is string => path !== null); + const text = formatDroppedFilePaths(paths); + if (text.length === 0) { + setThreadError(activeThreadId, "Could not read the location of the dropped file(s)."); + return; + } + if (!insertComposerTextAtEnd(text, { ensureLeadingBoundary: true })) { + toastManager.add({ + type: "error", + title: "Unable to add to chat", + description: "The composer is busy; try again once it is ready.", + }); + } + }; + const insertComposerTextAtEnd = ( text: string, options?: { ensureLeadingBoundary?: boolean }, @@ -2686,7 +2718,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerEditorRef.current?.focusAt(cursor); }, addDroppedFiles: (files: File[]) => { - void addComposerImages(files); + // Images become attachments; everything else (audio, PDF, video, …) is + // handed over as a path so the agent opens it from disk itself. + const images = files.filter((file) => file.type.startsWith("image/")); + const nonImages = files.filter((file) => !file.type.startsWith("image/")); + if (images.length > 0) { + void addComposerImages(images); + } + if (nonImages.length > 0) { + // Deliberately no focusComposer() on this path. `applyPromptReplacement` + // focuses on the next frame, once Lexical has reconciled; focusing + // synchronously here makes the not-yet-reconciled editor sync its stale + // empty state back over the text we just inserted, so the drop looks + // like it silently did nothing. Same footgun the file-tree mention drop + // documents in makeComposerMentionDragHandlers. + insertDroppedFilePaths(nonImages); + return; + } focusComposer(); }, insertTextAtEnd: insertComposerTextAtEnd, diff --git a/apps/web/src/components/chat/droppedFilePaths.test.ts b/apps/web/src/components/chat/droppedFilePaths.test.ts new file mode 100644 index 000000000000..7030a4c93a97 --- /dev/null +++ b/apps/web/src/components/chat/droppedFilePaths.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { formatDroppedFilePaths, quoteDroppedFilePath } from "./droppedFilePaths"; + +describe("quoteDroppedFilePath", () => { + it("leaves a path without whitespace alone", () => { + expect(quoteDroppedFilePath("/Users/me/notes.pdf")).toBe("/Users/me/notes.pdf"); + }); + + it("quotes a path containing spaces", () => { + expect(quoteDroppedFilePath("/Users/me/Voice Memos/note 1.m4a")).toBe( + '"/Users/me/Voice Memos/note 1.m4a"', + ); + }); +}); + +describe("formatDroppedFilePaths", () => { + it("joins several paths with a space", () => { + expect(formatDroppedFilePaths(["/a/one.opus", "/b/two.pdf"])).toBe("/a/one.opus /b/two.pdf"); + }); + + it("skips empty and whitespace-only entries", () => { + expect(formatDroppedFilePaths(["", " ", "/a/one.opus"])).toBe("/a/one.opus"); + }); + + it("returns an empty string when nothing resolved", () => { + expect(formatDroppedFilePaths([])).toBe(""); + }); +}); diff --git a/apps/web/src/components/chat/droppedFilePaths.ts b/apps/web/src/components/chat/droppedFilePaths.ts new file mode 100644 index 000000000000..a3fd0724c594 --- /dev/null +++ b/apps/web/src/components/chat/droppedFilePaths.ts @@ -0,0 +1,31 @@ +/** + * Files dropped from the OS that aren't images are handed to the agent by + * *path*, not by content: it can open the file itself, so a 40MB recording or + * a PDF never has to cross the wire as an attachment. This mirrors dropping a + * file into a terminal, where the shell receives the path. + * + * Paths are only available in the desktop app (Electron's `webUtils`); in a + * browser tab the File object carries no filesystem path at all. + */ + +/** + * Quote a path for the prompt when whitespace would make where it ends + * ambiguous. This is prompt text, not a shell command — the goal is a clear + * boundary for the reader, not shell-injection safety. + */ +export function quoteDroppedFilePath(path: string): string { + return /\s/.test(path) ? `"${path}"` : path; +} + +/** + * The text inserted into the composer for a set of dropped paths. Empty and + * whitespace-only paths are dropped (a bridge that can't resolve a file + * returns null, which the caller filters, but be defensive about "" too). + */ +export function formatDroppedFilePaths(paths: ReadonlyArray): string { + return paths + .map((path) => path.trim()) + .filter((path) => path.length > 0) + .map(quoteDroppedFilePath) + .join(" "); +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 93f074f7be56..1d3780ab0d04 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1070,6 +1070,13 @@ export interface DesktopBridge { * regardless of OS settings. */ getSystemLocale?: () => string | null; + /** + * The filesystem path of a dropped/pasted `File`, which the renderer cannot + * read for itself (Electron removed `File.path` in v32). Returns null when + * the object has no path — e.g. a file synthesised in-page rather than + * dragged in from the OS. + */ + getPathForFile?: (file: File) => string | null; // One bootstrap per pool instance currently registered with bootstrap // info (omits instances whose backend hasn't produced a config yet). // The primary backend is identified by id === PRIMARY_LOCAL_ENVIRONMENT_ID. From d16df52f56e29249d8e3bbc1f3a0445f9259716b Mon Sep 17 00:00:00 2001 From: Asaf Benatia Date: Fri, 21 Aug 2026 06:42:18 +0300 Subject: [PATCH 2/4] fix(composer): never alter a dropped path while formatting it Two ways the formatter could hand the agent a path that does not exist: - trimming every entry destroyed a leading or trailing space, which is legal in a POSIX filename ('/tmp/report ' became '/tmp/report'). Emptiness is now tested on a trimmed copy while the original path is what gets inserted. - a double quote is also legal in a filename, so wrapping '/tmp/a " b.pdf' in quotes made the inner quote read as the closing delimiter. Inner quotes are escaped before wrapping. --- .../components/chat/droppedFilePaths.test.ts | 12 ++++++++++++ .../web/src/components/chat/droppedFilePaths.ts | 17 ++++++++++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/droppedFilePaths.test.ts b/apps/web/src/components/chat/droppedFilePaths.test.ts index 7030a4c93a97..b07c9b23fb6d 100644 --- a/apps/web/src/components/chat/droppedFilePaths.test.ts +++ b/apps/web/src/components/chat/droppedFilePaths.test.ts @@ -7,6 +7,14 @@ describe("quoteDroppedFilePath", () => { expect(quoteDroppedFilePath("/Users/me/notes.pdf")).toBe("/Users/me/notes.pdf"); }); + it("escapes a double quote inside a quoted path", () => { + expect(quoteDroppedFilePath('/tmp/a " b.pdf')).toBe('"/tmp/a \\" b.pdf"'); + }); + + it("leaves a double quote alone when there is no whitespace to quote for", () => { + expect(quoteDroppedFilePath('/tmp/a"b.pdf')).toBe('/tmp/a"b.pdf'); + }); + it("quotes a path containing spaces", () => { expect(quoteDroppedFilePath("/Users/me/Voice Memos/note 1.m4a")).toBe( '"/Users/me/Voice Memos/note 1.m4a"', @@ -19,6 +27,10 @@ describe("formatDroppedFilePaths", () => { expect(formatDroppedFilePaths(["/a/one.opus", "/b/two.pdf"])).toBe("/a/one.opus /b/two.pdf"); }); + it("preserves a trailing space in a filename instead of trimming it", () => { + expect(formatDroppedFilePaths(["/tmp/report "])).toBe('"/tmp/report "'); + }); + it("skips empty and whitespace-only entries", () => { expect(formatDroppedFilePaths(["", " ", "/a/one.opus"])).toBe("/a/one.opus"); }); diff --git a/apps/web/src/components/chat/droppedFilePaths.ts b/apps/web/src/components/chat/droppedFilePaths.ts index a3fd0724c594..c8a70fa6ed7d 100644 --- a/apps/web/src/components/chat/droppedFilePaths.ts +++ b/apps/web/src/components/chat/droppedFilePaths.ts @@ -14,18 +14,25 @@ * boundary for the reader, not shell-injection safety. */ export function quoteDroppedFilePath(path: string): string { - return /\s/.test(path) ? `"${path}"` : path; + if (!/\s/.test(path)) { + return path; + } + // A double quote is legal in a POSIX filename, so escape any before wrapping — + // otherwise the first inner quote reads as the closing delimiter. + return `"${path.replace(/"/g, '\\"')}"`; } /** - * The text inserted into the composer for a set of dropped paths. Empty and - * whitespace-only paths are dropped (a bridge that can't resolve a file + * The text inserted into the composer for a set of dropped paths. Entries that + * are empty or all whitespace are skipped (a bridge that can't resolve a file * returns null, which the caller filters, but be defensive about "" too). + * A path that survives is never altered: leading and trailing spaces are legal + * in a filename, so trimming one would point the agent at a file that does not + * exist. Quoting keeps such a path readable instead. */ export function formatDroppedFilePaths(paths: ReadonlyArray): string { return paths - .map((path) => path.trim()) - .filter((path) => path.length > 0) + .filter((path) => path.trim().length > 0) .map(quoteDroppedFilePath) .join(" "); } From 82f2be05eb900a524110c1f713219c6f61be0bb3 Mon Sep 17 00:00:00 2001 From: Asaf Benatia Date: Fri, 21 Aug 2026 06:54:58 +0300 Subject: [PATCH 3/4] fix(composer): keep a path failure from speaking for the whole drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mixed drop (images + other files) reported path failures through setThreadError, the single banner the image attach path also writes: a successful image attach alongside an unsupported path read as though the whole drop had failed. Path failures now go to a toast, which coexists with the banner, and the 'composer is busy' refusal is suppressed when images were in the same drop, since that refusal comes from the state that already rejected them — one drop never says it twice. insertDroppedFilePaths now reports whether it inserted, so focusComposer() is skipped only when text actually landed. The stale-state rationale applies to a successful insert; on failure there is nothing to lose and focus behaves as it always did. --- apps/web/src/components/chat/ChatComposer.tsx | 55 +++++++++++-------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 4ff96f73db88..71d3e20ecf4e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2565,34 +2565,41 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }; /** - * Insert dropped non-image files as filesystem paths. Only the desktop app - * can resolve a path from a dropped File, so in a browser tab this reports - * why nothing was inserted instead of silently swallowing the drop. + * Insert dropped non-image files as filesystem paths, reporting to a toast + * rather than the thread error banner: a mixed drop's images own that banner, + * and a path failure there would read as though the whole drop failed. Only + * the desktop app can resolve a path from a dropped File, so in a browser tab + * this says so instead of silently swallowing the drop. Returns whether text + * was actually inserted. */ - const insertDroppedFilePaths = (files: File[]) => { + const insertDroppedFilePaths = (files: File[], hadImages: boolean): boolean => { + const reportFailure = (description: string) => { + toastManager.add({ type: "error", title: "Unable to add to chat", description }); + }; const resolvePath = window.desktopBridge?.getPathForFile; if (!resolvePath) { - setThreadError( - activeThreadId, + reportFailure( "Attaching files by path needs the desktop app. Paste an image, or type the path.", ); - return; + return false; } const paths = files .map((file) => resolvePath(file)) .filter((path): path is string => path !== null); const text = formatDroppedFilePaths(paths); if (text.length === 0) { - setThreadError(activeThreadId, "Could not read the location of the dropped file(s)."); - return; + reportFailure("Could not read the location of the dropped file(s)."); + return false; } if (!insertComposerTextAtEnd(text, { ensureLeadingBoundary: true })) { - toastManager.add({ - type: "error", - title: "Unable to add to chat", - description: "The composer is busy; try again once it is ready.", - }); + // This refusal comes from the same composer state that already rejected + // the images, so one drop never says it twice. + if (!hadImages) { + reportFailure("The composer is busy; try again once it is ready."); + } + return false; } + return true; }; const insertComposerTextAtEnd = ( @@ -2725,17 +2732,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (images.length > 0) { void addComposerImages(images); } - if (nonImages.length > 0) { - // Deliberately no focusComposer() on this path. `applyPromptReplacement` - // focuses on the next frame, once Lexical has reconciled; focusing - // synchronously here makes the not-yet-reconciled editor sync its stale - // empty state back over the text we just inserted, so the drop looks - // like it silently did nothing. Same footgun the file-tree mention drop - // documents in makeComposerMentionDragHandlers. - insertDroppedFilePaths(nonImages); - return; + const insertedPath = + nonImages.length > 0 && insertDroppedFilePaths(nonImages, images.length > 0); + // Focus unless a path just landed. `applyPromptReplacement` focuses on the + // next frame, once Lexical has reconciled; focusing synchronously right + // after the insert makes the not-yet-reconciled editor sync its stale empty + // state back over the text, so the drop looks like it silently did nothing + // — the footgun makeComposerMentionDragHandlers documents for the mention + // drop. Nothing inserted means nothing to lose, so focus as before. + if (!insertedPath) { + focusComposer(); } - focusComposer(); }, insertTextAtEnd: insertComposerTextAtEnd, openModelPicker: () => { From 4186b1a88fdbce1c958e07c9d10fedf2fe66cc5e Mon Sep 17 00:00:00 2001 From: Asaf Benatia Date: Fri, 21 Aug 2026 10:54:58 +0300 Subject: [PATCH 4/4] fix(composer): only stay silent when the images really said it first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suppressing the busy toast for any drop that contained an image assumed the image path had already reported the same refusal. It only does for pending plan questions — the one guard addComposerImages shares with insertComposerTextAtEnd. While connecting, awaiting approval, or with no project selected, a mixed drop would attach the images and drop the paths without a word, which is the silent failure this branch exists to avoid. The suppression now requires that shared condition to actually hold. --- apps/web/src/components/chat/ChatComposer.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 71d3e20ecf4e..6b94a2b57788 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2592,9 +2592,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return false; } if (!insertComposerTextAtEnd(text, { ensureLeadingBoundary: true })) { - // This refusal comes from the same composer state that already rejected - // the images, so one drop never says it twice. - if (!hadImages) { + // Pending plan questions is the ONLY refusal `addComposerImages` shares + // with the insert, so that is the only case a mixed drop has already been + // told about. Staying silent for the others — connecting, approval, + // project selection — would attach the images and drop the paths without + // a word, which is the failure this whole branch exists to avoid. + const alreadyReported = hadImages && pendingUserInputs.length > 0; + if (!alreadyReported) { reportFailure("The composer is busy; try again once it is ready."); } return false;