diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index d1313ff2e767..f6cd60dded19 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"; @@ -39,6 +39,15 @@ contextBridge.exposeInMainWorld("desktopBridge", { return result as ReturnType; }, getClientPlatform: () => clientPlatform, + 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 8937e52048e7..e9a171b2c88a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -169,6 +169,9 @@ import { submitComposerDraft, } from "./composerSubmission"; import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; +import { getPrimaryKnownEnvironment } from "~/environments/primary"; + +import { droppedPathsResolveInEnvironment, formatDroppedFilePaths } from "./droppedFilePaths"; function ComposerVideoThumbnail({ file }: { file: File }) { const setVideo = useCallback( @@ -3148,6 +3151,65 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) void addComposerAttachments(files); }; + /** + * 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[], 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) { + reportFailure( + "Attaching files by path needs the desktop app. Paste an image, or type the path.", + ); + return false; + } + // The bridge reads a path on THIS machine. Inserting it into a thread whose + // agent runs elsewhere hands over a path that host cannot open — so gate on + // the thread's environment being the one this desktop app supervises, + // rather than on the bridge merely existing. + const primaryEnvironment = getPrimaryKnownEnvironment(); + if ( + !droppedPathsResolveInEnvironment({ + primarySource: primaryEnvironment?.source, + primaryEnvironmentId: primaryEnvironment?.environmentId, + threadEnvironmentId: environmentId, + }) + ) { + reportFailure( + "This thread runs on another machine, which cannot open a path from this one. Attach the file, or type a path that exists there.", + ); + return false; + } + const paths = files + .map((file) => resolvePath(file)) + .filter((path): path is string => path !== null); + const text = formatDroppedFilePaths(paths); + if (text.length === 0) { + reportFailure("Could not read the location of the dropped file(s)."); + return false; + } + if (!insertComposerTextAtEnd(text, { ensureLeadingBoundary: true })) { + // 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; + } + return true; + }; + const insertComposerTextAtEnd = ( text: string, options?: { ensureLeadingBoundary?: boolean }, @@ -3271,8 +3333,30 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerEditorRef.current?.focusAt(cursor); }, addDroppedFiles: (files: File[]) => { - void addComposerAttachments(files); - focusComposer(); + // The composer attaches what it can render inline — images and video. + // Everything else (audio, PDF, archives, …) is handed over as a path so + // the agent opens it from disk itself, instead of being rejected as "not + // a supported image type" or pushing a 40MB recording across the wire. + const attachable = files.filter( + (file) => + file.type.startsWith("image/") || + videoMimeType({ name: file.name, mimeType: file.type }) !== undefined, + ); + const byPath = files.filter((file) => !attachable.includes(file)); + if (attachable.length > 0) { + void addComposerAttachments(attachable); + } + const insertedPath = + byPath.length > 0 && insertDroppedFilePaths(byPath, attachable.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(); + } }, insertTextAtEnd: insertComposerTextAtEnd, openModelPicker: () => { 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..ecc7a84b8fdd --- /dev/null +++ b/apps/web/src/components/chat/droppedFilePaths.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + droppedPathsResolveInEnvironment, + 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("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"', + ); + }); +}); + +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("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"); + }); + + it("returns an empty string when nothing resolved", () => { + expect(formatDroppedFilePaths([])).toBe(""); + }); +}); + +describe("droppedPathsResolveInEnvironment", () => { + const LOCAL = "env-local"; + const OTHER = "env-other"; + + it("accepts a thread running on the desktop-managed environment", () => { + expect( + droppedPathsResolveInEnvironment({ + primarySource: "desktop-managed", + primaryEnvironmentId: LOCAL, + threadEnvironmentId: LOCAL, + }), + ).toBe(true); + }); + + it("refuses a thread on a different environment even when the bridge is present", () => { + // The desktop app supervises env-local, but this thread runs on another + // machine: its agent cannot open a path read from this filesystem. + expect( + droppedPathsResolveInEnvironment({ + primarySource: "desktop-managed", + primaryEnvironmentId: LOCAL, + threadEnvironmentId: OTHER, + }), + ).toBe(false); + }); + + it.each(["configured", "manual", "window-origin"])( + "refuses a %s connection, which always names a server elsewhere", + (source) => { + expect( + droppedPathsResolveInEnvironment({ + primarySource: source, + primaryEnvironmentId: LOCAL, + threadEnvironmentId: LOCAL, + }), + ).toBe(false); + }, + ); + + it("refuses while either id is still unknown", () => { + // Bootstrapping is not evidence the thread runs here; guessing wrong + // inserts a path the agent silently cannot read. + expect( + droppedPathsResolveInEnvironment({ + primarySource: "desktop-managed", + primaryEnvironmentId: undefined, + threadEnvironmentId: LOCAL, + }), + ).toBe(false); + expect( + droppedPathsResolveInEnvironment({ + primarySource: "desktop-managed", + primaryEnvironmentId: LOCAL, + threadEnvironmentId: undefined, + }), + ).toBe(false); + }); + + it("refuses when there is no primary environment at all", () => { + expect( + droppedPathsResolveInEnvironment({ + primarySource: undefined, + primaryEnvironmentId: undefined, + threadEnvironmentId: LOCAL, + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/chat/droppedFilePaths.ts b/apps/web/src/components/chat/droppedFilePaths.ts new file mode 100644 index 000000000000..054eff4b3753 --- /dev/null +++ b/apps/web/src/components/chat/droppedFilePaths.ts @@ -0,0 +1,75 @@ +/** + * 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. + * + * A path is also only *meaningful* where the agent can open it. The desktop + * bridge resolves a path on the machine the client runs on, so the path is + * valid only when the thread's environment is that same machine — see + * `droppedPathsResolveInEnvironment`. + */ + +/** + * Whether a filesystem path read from the local desktop bridge means anything + * to the environment a thread runs in. + * + * The bridge resolves the path on *this* machine. `desktop-managed` is the one + * environment the desktop app supervises here, so only a thread bound to that + * environment shares the filesystem the path names. Every other connection — + * a LAN or Tailscale host, a relay, a tunnel, another desktop acting as server + * — runs the agent on a different machine, where the path either does not + * exist or, worse, names a different file. + * + * Passing the ids explicitly keeps this pure: the caller reads the primary + * environment, this decides. + */ +export function droppedPathsResolveInEnvironment(input: { + readonly primarySource: string | undefined; + readonly primaryEnvironmentId: string | undefined; + readonly threadEnvironmentId: string | undefined; +}): boolean { + if (input.primarySource !== "desktop-managed") { + return false; + } + // Both ids must be known. A missing id is not a match: bootstrapping is not + // evidence that the thread runs here, and guessing wrong inserts a path the + // agent silently cannot read. + return ( + input.primaryEnvironmentId !== undefined && + input.threadEnvironmentId !== undefined && + input.primaryEnvironmentId === input.threadEnvironmentId + ); +} + +/** + * 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 { + 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. 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 + .filter((path) => path.trim().length > 0) + .map(quoteDroppedFilePath) + .join(" "); +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b78479278330..112adf297682 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1091,6 +1091,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.