Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down
71 changes: 61 additions & 10 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
dataTransferHasComposerMention,
makeComposerMentionDragHandlers,
} from "./composerMentionDrag";
import { buildDroppedFileMentions, partitionDroppedComposerFiles } from "./composerFileDrop";
import {
type ComposerImageAttachment,
type DraftId,
Expand Down Expand Up @@ -2419,16 +2420,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
}
};

const onComposerDrop = (event: React.DragEvent<HTMLDivElement>) => {
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 },
Expand All @@ -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<HTMLDivElement>) => {
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.
Expand Down
67 changes: 67 additions & 0 deletions apps/web/src/components/chat/composerFileDrop.test.ts
Original file line number Diff line number Diff line change
@@ -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("");
});
});
59 changes: 59 additions & 0 deletions apps/web/src/components/chat/composerFileDrop.ts
Original file line number Diff line number Diff line change
@@ -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(/\/+$/, "");
Comment on lines +39 to +42

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium chat/composerFileDrop.ts:39

toComposerMentionPath strips all trailing slashes from cwd, so when cwd is "/" the normalized cwd becomes an empty string and the relativization branch is skipped entirely. Dropping /src/app.ts with the workspace rooted at / returns /src/app.ts instead of the expected src/app.ts. Consider preserving POSIX root "/" as the prefix when normalizing instead of stripping it down to an empty string.

 export function toComposerMentionPath(absolutePath: string, cwd: string | null): string {
   const normalizedPath = absolutePath.replace(/\\/g, "/");
   if (cwd !== null) {
-    const normalizedCwd = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
+    const normalizedCwd =
+      cwd.replace(/\\/g, "/") === "/"
+        ? "/"
+        : cwd.replace(/\\/g, "/").replace(/\/+$/, "");
     if (normalizedCwd.length > 0) {
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/composerFileDrop.ts around lines 39-42:

`toComposerMentionPath` strips all trailing slashes from `cwd`, so when `cwd` is `"/"` the normalized cwd becomes an empty string and the relativization branch is skipped entirely. Dropping `/src/app.ts` with the workspace rooted at `/` returns `/src/app.ts` instead of the expected `src/app.ts`. Consider preserving POSIX root `"/"` as the prefix when normalizing instead of stripping it down to an empty string.

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(" ");
}
9 changes: 9 additions & 0 deletions packages/contracts/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1026,6 +1026,15 @@ export interface DesktopBridge {
position?: { x: number; y: number },
) => Promise<T | null>;
openExternal: (url: string) => Promise<boolean>;
/**
* 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;
Expand Down
Loading