Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
11 changes: 10 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 @@ -35,6 +35,15 @@ contextBridge.exposeInMainWorld("desktopBridge", {
}
return result as ReturnType<DesktopBridge["getAppBranding"]>;
},
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;
Expand Down
50 changes: 49 additions & 1 deletion apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ import {
submitComposerDraft,
} from "./composerSubmission";
import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation";
import { formatDroppedFilePaths } from "./droppedFilePaths";

type ComposerCommandMenuPosition = {
bottom: number;
Expand Down Expand Up @@ -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;
}

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.

Mixed drops overwrite image errors

Medium Severity

When a drop mixes images and non-images, insertDroppedFilePaths always calls setThreadError on path failure and can replace a message just set by addComposerImages. In the browser, a successful image attach still shows the desktop-only path error, which reads like the whole drop failed and can lead to retrying with duplicate attachments.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 602ce81. Configure here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

All three are fair — fixed in 82f2be0. Path failures now go to a toast instead of setThreadError, so they can't overwrite the banner the image attach owns or make a successful image attach read as a failed drop. The 'composer is busy' refusal is suppressed when the same drop also had images, since that refusal comes from the state that already rejected them. And insertDroppedFilePaths now returns whether it inserted, so focusComposer() is skipped only when text actually landed — the stale-state rationale only applies to a successful insert. Re-verified the drop end to end after the refactor.

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 },
Expand Down Expand Up @@ -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;
}

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.

Mixed drops fire duplicate toasts

Low Severity

A mixed image and non-image drop while plan questions are pending triggers two separate error toasts: addComposerImages rejects with its plan-questions message, then insertDroppedFilePaths fails insertComposerTextAtEnd and toasts that the composer is busy.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 602ce81. Configure here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

All three are fair — fixed in 82f2be0. Path failures now go to a toast instead of setThreadError, so they can't overwrite the banner the image attach owns or make a successful image attach read as a failed drop. The 'composer is busy' refusal is suppressed when the same drop also had images, since that refusal comes from the state that already rejected them. And insertDroppedFilePaths now returns whether it inserted, so focusComposer() is skipped only when text actually landed — the stale-state rationale only applies to a successful insert. Re-verified the drop end to end after the refactor.

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.

Failed path insert skips focus

Low Severity

The non-image branch always returns after insertDroppedFilePaths, which skips focusComposer even when no text was inserted. The Lexical stale-state rationale only applies after a successful applyPromptReplacement; on path failure with images still attaching, focus never runs.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 602ce81. Configure here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

All three are fair — fixed in 82f2be0. Path failures now go to a toast instead of setThreadError, so they can't overwrite the banner the image attach owns or make a successful image attach read as a failed drop. The 'composer is busy' refusal is suppressed when the same drop also had images, since that refusal comes from the state that already rejected them. And insertDroppedFilePaths now returns whether it inserted, so focusComposer() is skipped only when text actually landed — the stale-state rationale only applies to a successful insert. Re-verified the drop end to end after the refactor.

focusComposer();
},
insertTextAtEnd: insertComposerTextAtEnd,
Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/components/chat/droppedFilePaths.test.ts
Original file line number Diff line number Diff line change
@@ -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("");
});
});
31 changes: 31 additions & 0 deletions apps/web/src/components/chat/droppedFilePaths.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
}

/**
* 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>): string {
return paths
.map((path) => path.trim())
.filter((path) => path.length > 0)
.map(quoteDroppedFilePath)
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
.join(" ");
}
7 changes: 7 additions & 0 deletions packages/contracts/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading