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
11 changes: 10 additions & 1 deletion apps/desktop/src/preload.ts
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -39,6 +39,15 @@ contextBridge.exposeInMainWorld("desktopBridge", {
return result as ReturnType<DesktopBridge["getAppBranding"]>;
},
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;
Expand Down
88 changes: 86 additions & 2 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand DownExpand Up@@ -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 },
Expand DownExpand Up@@ -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,
);
Comment on lines +3340 to +3344

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.

videoMimeType returns string | null (apps/web/src/types.ts), never undefined, so videoMimeType(...) !== undefined is always true. Every dropped file therefore lands in attachable, byPath is always empty, and insertDroppedFilePaths never runs — the feature this PR adds is dead at the call site (and the no-overlap comparison should also fail typecheck).

Switching to !== null re-exposes the classification gap flagged earlier: Finder omits the MIME type for IMG_1234.HEIC, so file.type.startsWith("image/") routes it (and a typeless photo.jpg) to the path branch, where a browser tab or a remote-environment thread discards it with an "Unable to add to chat" toast instead of attaching it, unlike onComposerPaste. Reusing the already-imported classifyComposerAttachmentFile keeps drop and paste on the same rules and fixes both:

Suggested change
constattachable=files.filter(
(file)=>
file.type.startsWith("image/")||
videoMimeType({name: file.name,mimeType: file.type})!==undefined,
);
constattachable=files.filter(
(file)=>
classifyComposerAttachmentFile(file)!=="file"||
videoMimeType({name: file.name,mimeType: file.type})!==null,
);

Posted via Macroscope — UI Consistency

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.

Path insert never runs

High Severity

The attachable filter treats every dropped file as attachable because videoMimeType returns null for non-videos, and null !== undefined is always true. byPath stays empty, so insertDroppedFilePaths never runs and non-image drops keep going through addComposerAttachments as before.

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit f52c963. Configure here.

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: () => {
Expand Down
114 changes: 114 additions & 0 deletions apps/web/src/components/chat/droppedFilePaths.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
75 changes: 75 additions & 0 deletions apps/web/src/components/chat/droppedFilePaths.ts
Original file line numberDiff line numberDiff line change
@@ -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") {

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.

🟠 Highchat/droppedFilePaths.ts:35

In WSL-only mode, this predicate returns true for matching desktop-managed IDs and allows a Windows path such as C:\Users\... into a Linux agent prompt, where the agent cannot open it. The check must distinguish a WSL primary (or translate the path) before permitting dropped-path insertion.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/droppedFilePaths.ts around line 35:
In WSL-only mode, this predicate returns `true` for matching `desktop-managed` IDs and allows a Windows path such as `C:\Users\...` into a Linux agent prompt, where the agent cannot open it. The check must distinguish a WSL primary (or translate the path) before permitting dropped-path insertion.

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>): string {
return paths
.filter((path) => path.trim().length > 0)
.map(quoteDroppedFilePath)
.join(" ");
}
7 changes: 7 additions & 0 deletions packages/contracts/src/ipc.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
11 changes: 10 additions & 1 deletion apps/desktop/src/preload.ts
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -39,6 +39,15 @@ contextBridge.exposeInMainWorld("desktopBridge", {
return result as ReturnType<DesktopBridge["getAppBranding"]>;
},
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;
Expand Down
88 changes: 86 additions & 2 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand DownExpand Up@@ -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 },
Expand DownExpand Up@@ -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,
);
Comment on lines +3340 to +3344

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.

videoMimeType returns string | null (apps/web/src/types.ts), never undefined, so videoMimeType(...) !== undefined is always true. Every dropped file therefore lands in attachable, byPath is always empty, and insertDroppedFilePaths never runs — the feature this PR adds is dead at the call site (and the no-overlap comparison should also fail typecheck).

Switching to !== null re-exposes the classification gap flagged earlier: Finder omits the MIME type for IMG_1234.HEIC, so file.type.startsWith("image/") routes it (and a typeless photo.jpg) to the path branch, where a browser tab or a remote-environment thread discards it with an "Unable to add to chat" toast instead of attaching it, unlike onComposerPaste. Reusing the already-imported classifyComposerAttachmentFile keeps drop and paste on the same rules and fixes both:

Suggested change
constattachable=files.filter(
(file)=>
file.type.startsWith("image/")||
videoMimeType({name: file.name,mimeType: file.type})!==undefined,
);
constattachable=files.filter(
(file)=>
classifyComposerAttachmentFile(file)!=="file"||
videoMimeType({name: file.name,mimeType: file.type})!==null,
);

Posted via Macroscope — UI Consistency

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.

Path insert never runs

High Severity

The attachable filter treats every dropped file as attachable because videoMimeType returns null for non-videos, and null !== undefined is always true. byPath stays empty, so insertDroppedFilePaths never runs and non-image drops keep going through addComposerAttachments as before.

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit f52c963. Configure here.

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: () => {
Expand Down
114 changes: 114 additions & 0 deletions apps/web/src/components/chat/droppedFilePaths.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
75 changes: 75 additions & 0 deletions apps/web/src/components/chat/droppedFilePaths.ts
Original file line numberDiff line numberDiff line change
@@ -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") {

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.

🟠 Highchat/droppedFilePaths.ts:35

In WSL-only mode, this predicate returns true for matching desktop-managed IDs and allows a Windows path such as C:\Users\... into a Linux agent prompt, where the agent cannot open it. The check must distinguish a WSL primary (or translate the path) before permitting dropped-path insertion.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/droppedFilePaths.ts around line 35:
In WSL-only mode, this predicate returns `true` for matching `desktop-managed` IDs and allows a Windows path such as `C:\Users\...` into a Linux agent prompt, where the agent cannot open it. The check must distinguish a WSL primary (or translate the path) before permitting dropped-path insertion.

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>): string {
return paths
.filter((path) => path.trim().length > 0)
.map(quoteDroppedFilePath)
.join(" ");
}
7 changes: 7 additions & 0 deletions packages/contracts/src/ipc.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
11 changes: 10 additions & 1 deletion apps/desktop/src/preload.ts
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -39,6 +39,15 @@ contextBridge.exposeInMainWorld("desktopBridge", {
return result as ReturnType<DesktopBridge["getAppBranding"]>;
},
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;
Expand Down
88 changes: 86 additions & 2 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand DownExpand Up@@ -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 },
Expand DownExpand Up@@ -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,
);
Comment on lines +3340 to +3344

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.

videoMimeType returns string | null (apps/web/src/types.ts), never undefined, so videoMimeType(...) !== undefined is always true. Every dropped file therefore lands in attachable, byPath is always empty, and insertDroppedFilePaths never runs — the feature this PR adds is dead at the call site (and the no-overlap comparison should also fail typecheck).

Switching to !== null re-exposes the classification gap flagged earlier: Finder omits the MIME type for IMG_1234.HEIC, so file.type.startsWith("image/") routes it (and a typeless photo.jpg) to the path branch, where a browser tab or a remote-environment thread discards it with an "Unable to add to chat" toast instead of attaching it, unlike onComposerPaste. Reusing the already-imported classifyComposerAttachmentFile keeps drop and paste on the same rules and fixes both:

Suggested change
constattachable=files.filter(
(file)=>
file.type.startsWith("image/")||
videoMimeType({name: file.name,mimeType: file.type})!==undefined,
);
constattachable=files.filter(
(file)=>
classifyComposerAttachmentFile(file)!=="file"||
videoMimeType({name: file.name,mimeType: file.type})!==null,
);

Posted via Macroscope — UI Consistency

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.

Path insert never runs

High Severity

The attachable filter treats every dropped file as attachable because videoMimeType returns null for non-videos, and null !== undefined is always true. byPath stays empty, so insertDroppedFilePaths never runs and non-image drops keep going through addComposerAttachments as before.

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit f52c963. Configure here.

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: () => {
Expand Down
114 changes: 114 additions & 0 deletions apps/web/src/components/chat/droppedFilePaths.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
75 changes: 75 additions & 0 deletions apps/web/src/components/chat/droppedFilePaths.ts
Original file line numberDiff line numberDiff line change
@@ -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") {

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.

🟠 Highchat/droppedFilePaths.ts:35

In WSL-only mode, this predicate returns true for matching desktop-managed IDs and allows a Windows path such as C:\Users\... into a Linux agent prompt, where the agent cannot open it. The check must distinguish a WSL primary (or translate the path) before permitting dropped-path insertion.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/droppedFilePaths.ts around line 35:
In WSL-only mode, this predicate returns `true` for matching `desktop-managed` IDs and allows a Windows path such as `C:\Users\...` into a Linux agent prompt, where the agent cannot open it. The check must distinguish a WSL primary (or translate the path) before permitting dropped-path insertion.

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>): string {
return paths
.filter((path) => path.trim().length > 0)
.map(quoteDroppedFilePath)
.join(" ");
}
7 changes: 7 additions & 0 deletions packages/contracts/src/ipc.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
11 changes: 10 additions & 1 deletion apps/desktop/src/preload.ts
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -39,6 +39,15 @@ contextBridge.exposeInMainWorld("desktopBridge", {
return result as ReturnType<DesktopBridge["getAppBranding"]>;
},
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;
Expand Down
88 changes: 86 additions & 2 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand DownExpand Up@@ -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 },
Expand DownExpand Up@@ -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,
);
Comment on lines +3340 to +3344

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.

videoMimeType returns string | null (apps/web/src/types.ts), never undefined, so videoMimeType(...) !== undefined is always true. Every dropped file therefore lands in attachable, byPath is always empty, and insertDroppedFilePaths never runs — the feature this PR adds is dead at the call site (and the no-overlap comparison should also fail typecheck).

Switching to !== null re-exposes the classification gap flagged earlier: Finder omits the MIME type for IMG_1234.HEIC, so file.type.startsWith("image/") routes it (and a typeless photo.jpg) to the path branch, where a browser tab or a remote-environment thread discards it with an "Unable to add to chat" toast instead of attaching it, unlike onComposerPaste. Reusing the already-imported classifyComposerAttachmentFile keeps drop and paste on the same rules and fixes both:

Suggested change
constattachable=files.filter(
(file)=>
file.type.startsWith("image/")||
videoMimeType({name: file.name,mimeType: file.type})!==undefined,
);
constattachable=files.filter(
(file)=>
classifyComposerAttachmentFile(file)!=="file"||
videoMimeType({name: file.name,mimeType: file.type})!==null,
);

Posted via Macroscope — UI Consistency

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.

Path insert never runs

High Severity

The attachable filter treats every dropped file as attachable because videoMimeType returns null for non-videos, and null !== undefined is always true. byPath stays empty, so insertDroppedFilePaths never runs and non-image drops keep going through addComposerAttachments as before.

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit f52c963. Configure here.

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: () => {
Expand Down
114 changes: 114 additions & 0 deletions apps/web/src/components/chat/droppedFilePaths.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
75 changes: 75 additions & 0 deletions apps/web/src/components/chat/droppedFilePaths.ts
Original file line numberDiff line numberDiff line change
@@ -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") {

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.

🟠 Highchat/droppedFilePaths.ts:35

In WSL-only mode, this predicate returns true for matching desktop-managed IDs and allows a Windows path such as C:\Users\... into a Linux agent prompt, where the agent cannot open it. The check must distinguish a WSL primary (or translate the path) before permitting dropped-path insertion.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/droppedFilePaths.ts around line 35:
In WSL-only mode, this predicate returns `true` for matching `desktop-managed` IDs and allows a Windows path such as `C:\Users\...` into a Linux agent prompt, where the agent cannot open it. The check must distinguish a WSL primary (or translate the path) before permitting dropped-path insertion.

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>): string {
return paths
.filter((path) => path.trim().length > 0)
.map(quoteDroppedFilePath)
.join(" ");
}
7 changes: 7 additions & 0 deletions packages/contracts/src/ipc.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
11 changes: 10 additions & 1 deletion apps/desktop/src/preload.ts
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -39,6 +39,15 @@ contextBridge.exposeInMainWorld("desktopBridge", {
return result as ReturnType<DesktopBridge["getAppBranding"]>;
},
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;
Expand Down
88 changes: 86 additions & 2 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand DownExpand Up@@ -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 },
Expand DownExpand Up@@ -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,
);
Comment on lines +3340 to +3344

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.

videoMimeType returns string | null (apps/web/src/types.ts), never undefined, so videoMimeType(...) !== undefined is always true. Every dropped file therefore lands in attachable, byPath is always empty, and insertDroppedFilePaths never runs — the feature this PR adds is dead at the call site (and the no-overlap comparison should also fail typecheck).

Switching to !== null re-exposes the classification gap flagged earlier: Finder omits the MIME type for IMG_1234.HEIC, so file.type.startsWith("image/") routes it (and a typeless photo.jpg) to the path branch, where a browser tab or a remote-environment thread discards it with an "Unable to add to chat" toast instead of attaching it, unlike onComposerPaste. Reusing the already-imported classifyComposerAttachmentFile keeps drop and paste on the same rules and fixes both:

Suggested change
constattachable=files.filter(
(file)=>
file.type.startsWith("image/")||
videoMimeType({name: file.name,mimeType: file.type})!==undefined,
);
constattachable=files.filter(
(file)=>
classifyComposerAttachmentFile(file)!=="file"||
videoMimeType({name: file.name,mimeType: file.type})!==null,
);

Posted via Macroscope — UI Consistency

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.

Path insert never runs

High Severity

The attachable filter treats every dropped file as attachable because videoMimeType returns null for non-videos, and null !== undefined is always true. byPath stays empty, so insertDroppedFilePaths never runs and non-image drops keep going through addComposerAttachments as before.

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit f52c963. Configure here.

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: () => {
Expand Down
114 changes: 114 additions & 0 deletions apps/web/src/components/chat/droppedFilePaths.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
75 changes: 75 additions & 0 deletions apps/web/src/components/chat/droppedFilePaths.ts
Original file line numberDiff line numberDiff line change
@@ -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") {

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.

🟠 Highchat/droppedFilePaths.ts:35

In WSL-only mode, this predicate returns true for matching desktop-managed IDs and allows a Windows path such as C:\Users\... into a Linux agent prompt, where the agent cannot open it. The check must distinguish a WSL primary (or translate the path) before permitting dropped-path insertion.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/droppedFilePaths.ts around line 35:
In WSL-only mode, this predicate returns `true` for matching `desktop-managed` IDs and allows a Windows path such as `C:\Users\...` into a Linux agent prompt, where the agent cannot open it. The check must distinguish a WSL primary (or translate the path) before permitting dropped-path insertion.

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>): string {
return paths
.filter((path) => path.trim().length > 0)
.map(quoteDroppedFilePath)
.join(" ");
}
7 changes: 7 additions & 0 deletions packages/contracts/src/ipc.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
11 changes: 10 additions & 1 deletion apps/desktop/src/preload.ts
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -39,6 +39,15 @@ contextBridge.exposeInMainWorld("desktopBridge", {
return result as ReturnType<DesktopBridge["getAppBranding"]>;
},
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;
Expand Down
88 changes: 86 additions & 2 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand DownExpand Up@@ -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 },
Expand DownExpand Up@@ -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,
);
Comment on lines +3340 to +3344

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.

videoMimeType returns string | null (apps/web/src/types.ts), never undefined, so videoMimeType(...) !== undefined is always true. Every dropped file therefore lands in attachable, byPath is always empty, and insertDroppedFilePaths never runs — the feature this PR adds is dead at the call site (and the no-overlap comparison should also fail typecheck).

Switching to !== null re-exposes the classification gap flagged earlier: Finder omits the MIME type for IMG_1234.HEIC, so file.type.startsWith("image/") routes it (and a typeless photo.jpg) to the path branch, where a browser tab or a remote-environment thread discards it with an "Unable to add to chat" toast instead of attaching it, unlike onComposerPaste. Reusing the already-imported classifyComposerAttachmentFile keeps drop and paste on the same rules and fixes both:

Suggested change
constattachable=files.filter(
(file)=>
file.type.startsWith("image/")||
videoMimeType({name: file.name,mimeType: file.type})!==undefined,
);
constattachable=files.filter(
(file)=>
classifyComposerAttachmentFile(file)!=="file"||
videoMimeType({name: file.name,mimeType: file.type})!==null,
);

Posted via Macroscope — UI Consistency

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.

Path insert never runs

High Severity

The attachable filter treats every dropped file as attachable because videoMimeType returns null for non-videos, and null !== undefined is always true. byPath stays empty, so insertDroppedFilePaths never runs and non-image drops keep going through addComposerAttachments as before.

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit f52c963. Configure here.

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: () => {
Expand Down
114 changes: 114 additions & 0 deletions apps/web/src/components/chat/droppedFilePaths.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
75 changes: 75 additions & 0 deletions apps/web/src/components/chat/droppedFilePaths.ts
Original file line numberDiff line numberDiff line change
@@ -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") {

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.

🟠 Highchat/droppedFilePaths.ts:35

In WSL-only mode, this predicate returns true for matching desktop-managed IDs and allows a Windows path such as C:\Users\... into a Linux agent prompt, where the agent cannot open it. The check must distinguish a WSL primary (or translate the path) before permitting dropped-path insertion.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/droppedFilePaths.ts around line 35:
In WSL-only mode, this predicate returns `true` for matching `desktop-managed` IDs and allows a Windows path such as `C:\Users\...` into a Linux agent prompt, where the agent cannot open it. The check must distinguish a WSL primary (or translate the path) before permitting dropped-path insertion.

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>): string {
return paths
.filter((path) => path.trim().length > 0)
.map(quoteDroppedFilePath)
.join(" ");
}
7 changes: 7 additions & 0 deletions packages/contracts/src/ipc.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
11 changes: 10 additions & 1 deletion apps/desktop/src/preload.ts
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -39,6 +39,15 @@ contextBridge.exposeInMainWorld("desktopBridge", {
return result as ReturnType<DesktopBridge["getAppBranding"]>;
},
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;
Expand Down
88 changes: 86 additions & 2 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand DownExpand Up@@ -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 },
Expand DownExpand Up@@ -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,
);
Comment on lines +3340 to +3344

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.

videoMimeType returns string | null (apps/web/src/types.ts), never undefined, so videoMimeType(...) !== undefined is always true. Every dropped file therefore lands in attachable, byPath is always empty, and insertDroppedFilePaths never runs — the feature this PR adds is dead at the call site (and the no-overlap comparison should also fail typecheck).

Switching to !== null re-exposes the classification gap flagged earlier: Finder omits the MIME type for IMG_1234.HEIC, so file.type.startsWith("image/") routes it (and a typeless photo.jpg) to the path branch, where a browser tab or a remote-environment thread discards it with an "Unable to add to chat" toast instead of attaching it, unlike onComposerPaste. Reusing the already-imported classifyComposerAttachmentFile keeps drop and paste on the same rules and fixes both:

Suggested change
constattachable=files.filter(
(file)=>
file.type.startsWith("image/")||
videoMimeType({name: file.name,mimeType: file.type})!==undefined,
);
constattachable=files.filter(
(file)=>
classifyComposerAttachmentFile(file)!=="file"||
videoMimeType({name: file.name,mimeType: file.type})!==null,
);

Posted via Macroscope — UI Consistency

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.

Path insert never runs

High Severity

The attachable filter treats every dropped file as attachable because videoMimeType returns null for non-videos, and null !== undefined is always true. byPath stays empty, so insertDroppedFilePaths never runs and non-image drops keep going through addComposerAttachments as before.

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit f52c963. Configure here.

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: () => {
Expand Down
114 changes: 114 additions & 0 deletions apps/web/src/components/chat/droppedFilePaths.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
75 changes: 75 additions & 0 deletions apps/web/src/components/chat/droppedFilePaths.ts
Original file line numberDiff line numberDiff line change
@@ -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") {

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.

🟠 Highchat/droppedFilePaths.ts:35

In WSL-only mode, this predicate returns true for matching desktop-managed IDs and allows a Windows path such as C:\Users\... into a Linux agent prompt, where the agent cannot open it. The check must distinguish a WSL primary (or translate the path) before permitting dropped-path insertion.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/droppedFilePaths.ts around line 35:
In WSL-only mode, this predicate returns `true` for matching `desktop-managed` IDs and allows a Windows path such as `C:\Users\...` into a Linux agent prompt, where the agent cannot open it. The check must distinguish a WSL primary (or translate the path) before permitting dropped-path insertion.

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>): string {
return paths
.filter((path) => path.trim().length > 0)
.map(quoteDroppedFilePath)
.join(" ");
}
7 changes: 7 additions & 0 deletions packages/contracts/src/ipc.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
11 changes: 10 additions & 1 deletion apps/desktop/src/preload.ts
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -39,6 +39,15 @@ contextBridge.exposeInMainWorld("desktopBridge", {
return result as ReturnType<DesktopBridge["getAppBranding"]>;
},
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;
Expand Down
88 changes: 86 additions & 2 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand DownExpand Up@@ -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 },
Expand DownExpand Up@@ -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,
);
Comment on lines +3340 to +3344

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.

videoMimeType returns string | null (apps/web/src/types.ts), never undefined, so videoMimeType(...) !== undefined is always true. Every dropped file therefore lands in attachable, byPath is always empty, and insertDroppedFilePaths never runs — the feature this PR adds is dead at the call site (and the no-overlap comparison should also fail typecheck).

Switching to !== null re-exposes the classification gap flagged earlier: Finder omits the MIME type for IMG_1234.HEIC, so file.type.startsWith("image/") routes it (and a typeless photo.jpg) to the path branch, where a browser tab or a remote-environment thread discards it with an "Unable to add to chat" toast instead of attaching it, unlike onComposerPaste. Reusing the already-imported classifyComposerAttachmentFile keeps drop and paste on the same rules and fixes both:

Suggested change
constattachable=files.filter(
(file)=>
file.type.startsWith("image/")||
videoMimeType({name: file.name,mimeType: file.type})!==undefined,
);
constattachable=files.filter(
(file)=>
classifyComposerAttachmentFile(file)!=="file"||
videoMimeType({name: file.name,mimeType: file.type})!==null,
);

Posted via Macroscope — UI Consistency

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.

Path insert never runs

High Severity

The attachable filter treats every dropped file as attachable because videoMimeType returns null for non-videos, and null !== undefined is always true. byPath stays empty, so insertDroppedFilePaths never runs and non-image drops keep going through addComposerAttachments as before.

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit f52c963. Configure here.

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: () => {
Expand Down
114 changes: 114 additions & 0 deletions apps/web/src/components/chat/droppedFilePaths.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
75 changes: 75 additions & 0 deletions apps/web/src/components/chat/droppedFilePaths.ts
Original file line numberDiff line numberDiff line change
@@ -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") {

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.

🟠 Highchat/droppedFilePaths.ts:35

In WSL-only mode, this predicate returns true for matching desktop-managed IDs and allows a Windows path such as C:\Users\... into a Linux agent prompt, where the agent cannot open it. The check must distinguish a WSL primary (or translate the path) before permitting dropped-path insertion.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/droppedFilePaths.ts around line 35:
In WSL-only mode, this predicate returns `true` for matching `desktop-managed` IDs and allows a Windows path such as `C:\Users\...` into a Linux agent prompt, where the agent cannot open it. The check must distinguish a WSL primary (or translate the path) before permitting dropped-path insertion.

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>): string {
return paths
.filter((path) => path.trim().length > 0)
.map(quoteDroppedFilePath)
.join(" ");
}
7 changes: 7 additions & 0 deletions packages/contracts/src/ipc.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
Loading