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..f8d4cc364b4 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,66 @@ 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); + } + } + let insertedMentions = false; + if (mentionPaths.length > 0) { + // 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", + 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); + } + // 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 // 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;