From 6d047692fa7f7d79688f057e180e0beddbc10e98 Mon Sep 17 00:00:00 2001 From: zeroxjf Date: Wed, 5 Aug 2026 01:23:51 -0400 Subject: [PATCH 1/2] feat(composer): insert file mentions when non-image files are dropped Dropping a file onto the composer previously routed every file through the image-attachment path, so anything that wasn't an image was rejected with "Unsupported file type ... attach image files only". There was no way to drop a file from Finder/Explorer to reference it by path. Now an OS drop is partitioned: images still attach inline, and other files are turned into composer file mentions using the dropped File's on-disk path, resolved via Electron `webUtils.getPathForFile` exposed on the desktop bridge. Paths inside the workspace cwd are made workspace-relative so they match typed and file-tree mentions; paths outside stay absolute. Files whose path can't be resolved (browser builds, in-memory Files) fall back to the existing image path, so non-desktop behavior is unchanged. - contracts: add optional DesktopBridge.getPathForFile - desktop/preload: implement it via webUtils - web: partitionDroppedComposerFiles / toComposerMentionPath / buildDroppedFileMentions helper + unit tests, wired into ChatComposer onComposerDrop --- apps/desktop/src/preload.ts | 5 +- apps/web/src/components/chat/ChatComposer.tsx | 62 ++++++++++++++--- .../components/chat/composerFileDrop.test.ts | 67 +++++++++++++++++++ .../src/components/chat/composerFileDrop.ts | 59 ++++++++++++++++ packages/contracts/src/ipc.ts | 9 +++ 5 files changed, 191 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/components/chat/composerFileDrop.test.ts create mode 100644 apps/web/src/components/chat/composerFileDrop.ts diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 9f01baeed90..9404942b785 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"; @@ -105,6 +105,9 @@ contextBridge.exposeInMainWorld("desktopBridge", { ...(position === undefined ? {} : { position }), }), openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), + // Synchronous and in-process: webUtils reads the path off the drag payload, + // no IPC hop. Returns "" for files without a backing path. + getPathForFile: (file) => webUtils.getPathForFile(file as File), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c26f52cbc0c..371d6a6e768 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -47,6 +47,7 @@ import { dataTransferHasComposerMention, makeComposerMentionDragHandlers, } from "./composerMentionDrag"; +import { buildDroppedFileMentions, partitionDroppedComposerFiles } from "./composerFileDrop"; import { type ComposerImageAttachment, type DraftId, @@ -2419,16 +2420,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } }; - const onComposerDrop = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) return; - event.preventDefault(); - dragDepthRef.current = 0; - setIsDragOverComposer(false); - const files = Array.from(event.dataTransfer.files); - void addComposerImages(files); - focusComposer(); - }; - const insertComposerTextAtEnd = ( text: string, options?: { ensureLeadingBoundary?: boolean }, @@ -2452,6 +2443,57 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); }; + // OS file drops: images attach inline (as before), while other files become + // file mentions when the desktop shell can resolve the dropped File's on-disk + // path (Electron webUtils). Files whose path can't be resolved — browser + // builds, or in-memory Files — fall back to the image path so the existing + // "unsupported file type" error still surfaces instead of silently vanishing. + const onComposerDrop = (event: React.DragEvent) => { + if (!event.dataTransfer.types.includes("Files")) return; + event.preventDefault(); + dragDepthRef.current = 0; + setIsDragOverComposer(false); + const { imageFiles, pathFiles } = partitionDroppedComposerFiles( + Array.from(event.dataTransfer.files), + ); + const resolvePath = window.desktopBridge?.getPathForFile; + const mentionPaths: string[] = []; + const unresolvedFiles: File[] = []; + for (const file of pathFiles) { + let absolutePath = ""; + if (resolvePath) { + try { + absolutePath = resolvePath(file); + } catch { + absolutePath = ""; + } + } + if (absolutePath.length > 0) { + mentionPaths.push(absolutePath); + } else { + unresolvedFiles.push(file); + } + } + if (mentionPaths.length > 0) { + const inserted = insertComposerTextAtEnd(buildDroppedFileMentions(mentionPaths, gitCwd), { + ensureLeadingBoundary: true, + }); + if (!inserted) { + toastManager.add({ + type: "error", + title: "Unable to add to chat", + description: "The composer is busy; try again once it is ready.", + }); + } + } + const imagesToAttach = + unresolvedFiles.length > 0 ? [...imageFiles, ...unresolvedFiles] : imageFiles; + if (imagesToAttach.length > 0) { + void addComposerImages(imagesToAttach); + } + focusComposer(); + }; + // File-tree drags land as mentions. Handled in the capture phase so the // editor never sees the drop; the load-bearing rules (native stop, "move" // effect, no eager focus) live in makeComposerMentionDragHandlers. diff --git a/apps/web/src/components/chat/composerFileDrop.test.ts b/apps/web/src/components/chat/composerFileDrop.test.ts new file mode 100644 index 00000000000..1b6f0eb6db1 --- /dev/null +++ b/apps/web/src/components/chat/composerFileDrop.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + buildDroppedFileMentions, + partitionDroppedComposerFiles, + toComposerMentionPath, +} from "./composerFileDrop.ts"; + +const fakeFile = (type: string, name: string): File => ({ type, name }) as unknown as File; + +describe("partitionDroppedComposerFiles", () => { + it("splits image files from everything else, preserving order", () => { + const png = fakeFile("image/png", "shot.png"); + const doc = fakeFile("text/markdown", "notes.md"); + const jpeg = fakeFile("image/jpeg", "photo.jpg"); + const bin = fakeFile("", "Makefile"); + + const { imageFiles, pathFiles } = partitionDroppedComposerFiles([png, doc, jpeg, bin]); + + expect(imageFiles).toEqual([png, jpeg]); + expect(pathFiles).toEqual([doc, bin]); + }); + + it("returns empty partitions for no files", () => { + expect(partitionDroppedComposerFiles([])).toEqual({ imageFiles: [], pathFiles: [] }); + }); +}); + +describe("toComposerMentionPath", () => { + it("relativises a path inside the workspace cwd", () => { + expect(toComposerMentionPath("/home/me/proj/src/app.ts", "/home/me/proj")).toBe("src/app.ts"); + }); + + it("tolerates a trailing slash on the cwd", () => { + expect(toComposerMentionPath("/home/me/proj/src/app.ts", "/home/me/proj/")).toBe("src/app.ts"); + }); + + it("keeps the absolute path when it is outside the cwd", () => { + expect(toComposerMentionPath("/etc/hosts", "/home/me/proj")).toBe("/etc/hosts"); + }); + + it("keeps the absolute path when there is no cwd", () => { + expect(toComposerMentionPath("/home/me/proj/src/app.ts", null)).toBe("/home/me/proj/src/app.ts"); + }); + + it("does not relativise the cwd itself to an empty string", () => { + expect(toComposerMentionPath("/home/me/proj", "/home/me/proj")).toBe("/home/me/proj"); + }); + + it("normalises Windows separators and relativises", () => { + expect(toComposerMentionPath("C:\\Users\\me\\proj\\src\\app.ts", "C:\\Users\\me\\proj")).toBe( + "src/app.ts", + ); + }); +}); + +describe("buildDroppedFileMentions", () => { + it("serializes each path as a space-separated file link", () => { + expect( + buildDroppedFileMentions(["/home/me/proj/src/app.ts", "/etc/hosts"], "/home/me/proj"), + ).toBe("[app.ts](src/app.ts) [hosts](/etc/hosts)"); + }); + + it("returns an empty string for no paths", () => { + expect(buildDroppedFileMentions([], "/home/me/proj")).toBe(""); + }); +}); diff --git a/apps/web/src/components/chat/composerFileDrop.ts b/apps/web/src/components/chat/composerFileDrop.ts new file mode 100644 index 00000000000..29c7e3bb7f0 --- /dev/null +++ b/apps/web/src/components/chat/composerFileDrop.ts @@ -0,0 +1,59 @@ +import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; + +/** + * Split files from an OS drag-and-drop into the two ways the composer can use + * them: images become inline attachments, everything else becomes a file + * mention that points the agent at the dropped path. + */ +export interface DroppedComposerFilePartition { + readonly imageFiles: File[]; + readonly pathFiles: File[]; +} + +export function partitionDroppedComposerFiles( + files: readonly File[], +): DroppedComposerFilePartition { + const imageFiles: File[] = []; + const pathFiles: File[] = []; + for (const file of files) { + if (file.type.startsWith("image/")) { + imageFiles.push(file); + } else { + pathFiles.push(file); + } + } + return { imageFiles, pathFiles }; +} + +/** + * Convert an absolute filesystem path into the value inserted as a composer + * mention. When the path lives inside the workspace cwd it is made + * workspace-relative so it matches typed and file-tree mentions; otherwise the + * absolute path is kept so the reference is still unambiguous. + * + * Path separators are normalised to `/` so Windows drops relativise too. The + * prefix comparison is case-sensitive, so a drop that differs only in casing + * from the cwd (possible on case-insensitive volumes) keeps its absolute path + * rather than guessing a relative one. + */ +export function toComposerMentionPath(absolutePath: string, cwd: string | null): string { + const normalizedPath = absolutePath.replace(/\\/g, "/"); + if (cwd !== null) { + const normalizedCwd = cwd.replace(/\\/g, "/").replace(/\/+$/, ""); + if (normalizedCwd.length > 0) { + const prefix = `${normalizedCwd}/`; + if (normalizedPath.startsWith(prefix) && normalizedPath.length > prefix.length) { + return normalizedPath.slice(prefix.length); + } + } + } + return normalizedPath; +} + +/** + * Build the composer text for a set of dropped file paths: one serialized file + * link per path, space-separated so each stays a valid mention token. + */ +export function buildDroppedFileMentions(paths: readonly string[], cwd: string | null): string { + return paths.map((path) => serializeComposerFileLink(toComposerMentionPath(path, cwd))).join(" "); +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 43167bbf0c3..e98be12371c 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1026,6 +1026,15 @@ export interface DesktopBridge { position?: { x: number; y: number }, ) => Promise; openExternal: (url: string) => Promise; + /** + * Resolve the absolute filesystem path backing a dragged or picked file + * (Electron `webUtils.getPathForFile`). Returns an empty string when the file + * has no on-disk path (e.g. synthesized in-memory). The argument is a DOM + * `File`; it is typed as `unknown` because the contracts package is built + * without the DOM lib. Optional so browser builds, which cannot resolve OS + * paths, may omit it. + */ + getPathForFile?: (file: unknown) => string; onMenuAction: (listener: (action: string) => void) => () => void; getWindowFullscreenState: () => boolean; onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; From 254a259580c82ec365f7faa48e0717de7f113b10 Mon Sep 17 00:00:00 2001 From: zeroxjf Date: Wed, 5 Aug 2026 10:14:29 -0400 Subject: [PATCH 2/2] fix(composer): trailing space + no sync focus after dropped-file mentions Review fixes for the drop-to-mention handler: - The mention token grammar (FILE_LINK_TOKEN_REGEX) requires trailing whitespace after a token, and every other insert site appends one; without it the last dropped mention stayed plain text and was missed at send time. - Focusing synchronously after the prompt insert made focusAt push the editor's stale pre-mention snapshot back through onChange, overwriting the inserted mention -- the exact failure documented on ComposerMentionDropHost. The insert path already focuses on the next frame, so only focus here when no mention was inserted. --- apps/web/src/components/chat/ChatComposer.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 371d6a6e768..f8d4cc364b4 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2474,11 +2474,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) unresolvedFiles.push(file); } } + let insertedMentions = false; if (mentionPaths.length > 0) { - const inserted = insertComposerTextAtEnd(buildDroppedFileMentions(mentionPaths, gitCwd), { - ensureLeadingBoundary: true, - }); - if (!inserted) { + // Trailing space: the mention token grammar requires trailing + // whitespace, same as every other mention insert site. + insertedMentions = insertComposerTextAtEnd( + `${buildDroppedFileMentions(mentionPaths, gitCwd)} `, + { ensureLeadingBoundary: true }, + ); + if (!insertedMentions) { toastManager.add({ type: "error", title: "Unable to add to chat", @@ -2491,7 +2495,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (imagesToAttach.length > 0) { void addComposerImages(imagesToAttach); } - focusComposer(); + // Focusing synchronously after a prompt insert would push the editor's + // stale snapshot back over the inserted mention (see the note on + // ComposerMentionDropHost); the insert path already focuses next frame. + if (!insertedMentions) { + focusComposer(); + } }; // File-tree drags land as mentions. Handled in the capture phase so the