authorgravatar for 76637177+SunkenInTime@users.noreply.github.comDara Adedeji <76637177+SunkenInTime@users.noreply.github.com> 2026-09-16 13:28:28-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-16 13:28:28-07:00
logd1a644897119300688cbd8bf26e0b64aeb92c382
tree64ec7e49d0db70f8aa4a01dda30d8b2eaec2f1a1
parenteff44be433dc9683da7764a20334598e47b127b2
signature Signed by PGP key B5690EEEBB952194

fix(web): dropped folders become path chips on the local environment and are refused on remote ones (#12001)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

12 files changed, 228 insertions(+), 4 deletions(-)

apps/desktop/scripts/verify-preload-bundle.mjs+4
......@@ -9,6 +9,7 @@ import { parse } from "acorn";
99const expectedDesktopBridgeApis = [
1010 "getClientPlatform",
1111 "getLocalEnvironmentBootstraps",
12 "getPathForFile",
1213 "pickFolder",
1314];
1415const clerkPasskeysGlobal = "__clerk_internal_electron_passkeys";
......@@ -68,6 +69,9 @@ const createSandboxModules = (exposedGlobals) => {
6869 exposeInMainWorld: (name, api) => exposedGlobals.set(name, api),
6970 },
7071 ipcRenderer,
72 webUtils: {
73 getPathForFile: () => "",
74 },
7175 };
7276
7377 return new Map([
apps/desktop/scripts/verify-preload-bundle.test.mjs+1
......@@ -9,6 +9,7 @@ const validPreload = `
99 electron.contextBridge.exposeInMainWorld("desktopBridge", {
1010 getClientPlatform: () => process.platform,
1111 getLocalEnvironmentBootstraps: () => [],
12 getPathForFile: () => "",
1213 pickFolder: (options) => electron.ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options),
1314 });
1415`;
apps/desktop/src/preload.ts+2-1
......@@ -6,7 +6,7 @@ import type {
66 DesktopSnapShotEvent,
77} from "@t3tools/contracts";
88import { exposeClerkBridge } from "@clerk/electron/preload";
9import { contextBridge, ipcRenderer, webFrame } from "electron";
9import { contextBridge, ipcRenderer, webFrame, webUtils } from "electron";
1010
1111import * as IpcChannels from "./ipc/channels.ts";
1212
......@@ -69,6 +69,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
6969 }
7070 return result as ReturnType<DesktopBridge["getAppBranding"]>;
7171 },
72 getPathForFile: (file: File) => webUtils.getPathForFile(file),
7273 getClientPlatform: () => clientPlatform,
7374 setNotificationBadge: (badge) =>
7475 ipcRenderer.invoke(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, badge),
apps/web/src/components/ChatView.tsx+1
......@@ -9318,6 +9318,7 @@ export default function ChatView(props: ChatViewProps) {
93189318 const workspaceFileDropHandlers = makeWorkspaceFileDropHandlers({
93199319 setDragActive: setIsWorkspaceFileDragActive,
93209320 addFiles: (files) => composerRef.current?.addDroppedFiles(files),
9321 addFolders: (folders) => composerRef.current?.addDroppedFolders(folders),
93219322 });
93229323
93239324 return (
apps/web/src/components/LegacySidebar.tsx+1
......@@ -391,6 +391,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
391391 addFiles: (files) => {
392392 onFileDropThreads(threadRef, files);
393393 },
394 addFolders: () => {},
394395 }),
395396 [onFileDropThreads, threadRef],
396397 );
apps/web/src/components/Sidebar.tsx+2
......@@ -1278,6 +1278,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
12781278 addFiles: (files) => {
12791279 onFileDropThreads(threadRef, files);
12801280 },
1281 addFolders: () => {},
12811282 })
12821283 : null,
12831284 [onFileDropThreads, threadRef],
......@@ -2057,6 +2058,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
20572058 addFiles: (files) => {
20582059 props.onFileDropThreads(threadRef, files);
20592060 },
2061 addFolders: () => {},
20602062 }),
20612063 [props.onFileDropThreads, threadRef],
20622064 );
apps/web/src/components/chat/ChatComposer.tsx+36
......@@ -1,4 +1,6 @@
11import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText";
2import { isLocalEnvironmentDisabled } from "../../localEnvironment";
3import { usePrimaryEnvironmentId } from "../../state/environments";
24import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig";
35import { useRightPanelStore } from "~/rightPanelStore";
46import { AttachmentFilePreview } from "../files/AttachmentFilePreview";
......@@ -47,6 +49,7 @@ import {
4749 wouldTextPasteExceedLimit,
4850} from "@t3tools/client-runtime/text-paste";
4951import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
52import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop";
5053import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
5154import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits";
5255import {
......@@ -1220,6 +1223,7 @@ export interface ChatComposerHandle {
12201223 restoreAfterTimelineReachedEnd: () => void;
12211224 collapseForTimelineScrollKey: (key: string) => void;
12221225 addDroppedFiles: (files: File[]) => void;
1226 addDroppedFolders: (folders: File[]) => void;
12231227 hasPendingAttachments: () => boolean;
12241228 insertTextAtEnd: (
12251229 text: string,
......@@ -1525,6 +1529,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
15251529 onExpandImage,
15261530 onFileOpen,
15271531 } = props;
1532 const primaryEnvironmentId = usePrimaryEnvironmentId();
15281533 const activeTasksProgress = props.threadSyncPhase === null ? props.activeTasksProgress : null;
15291534 const activeTaskSteps = props.threadSyncPhase === null ? props.activeTaskSteps : null;
15301535 // ------------------------------------------------------------------
......@@ -5715,6 +5720,35 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
57155720 if (!inserted) focusComposer();
57165721 });
57175722 },
5723 addDroppedFolders: (folders: File[]) => {
5724 const target = folderDropTarget({
5725 localEnvironmentDisabled: isLocalEnvironmentDisabled(),
5726 environmentId,
5727 primaryEnvironmentId,
5728 });
5729 if (target === "remote") {
5730 toastManager.add({
5731 type: "error",
5732 title: "Folders can't be dropped into remote environments",
5733 });
5734 return;
5735 }
5736 for (const folder of folders) {
5737 const path = resolveDroppedFolderPath(folder, window.desktopBridge?.getPathForFile);
5738 if (path === null) {
5739 toastManager.add({
5740 type: "error",
5741 title: `Couldn't get the path of "${folder.name}"`,
5742 description: "Type the folder path with @ instead.",
5743 });
5744 continue;
5745 }
5746 insertComposerTextAtEnd(`${serializeComposerFileLink(path)} `, {
5747 ensureLeadingBoundary: true,
5748 });
5749 }
5750 focusComposer();
5751 },
57185752 hasPendingAttachments: () =>
57195753 (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0,
57205754 insertTextAtEnd: insertComposerTextAtEnd,
......@@ -5871,6 +5905,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
58715905 composerPreviewAnnotations,
58725906 composerReviewComments,
58735907 focusComposer,
5908 environmentId,
5909 primaryEnvironmentId,
58745910 isConnecting,
58755911 isComposerApprovalState,
58765912 isChoiceOnlyPendingQuestion,
apps/web/src/components/chat/folderDrop.test.ts created+66
......@@ -0,0 +1,66 @@
1import { describe, expect, it } from "@effect/vitest";
2import { EnvironmentId } from "@t3tools/contracts";
3import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop";
4
5const environmentId = EnvironmentId.make("environment-1");
6
7describe("folderDropTarget", () => {
8 it("targets local when the thread is on the primary environment", () => {
9 expect(
10 folderDropTarget({
11 localEnvironmentDisabled: false,
12 environmentId,
13 primaryEnvironmentId: environmentId,
14 }),
15 ).toBe("local");
16 });
17
18 it("targets remote when Electron has no local environment", () => {
19 expect(
20 folderDropTarget({
21 localEnvironmentDisabled: true,
22 environmentId,
23 primaryEnvironmentId: environmentId,
24 }),
25 ).toBe("remote");
26 });
27
28 it("targets remote when the thread lives on another environment", () => {
29 expect(
30 folderDropTarget({
31 localEnvironmentDisabled: false,
32 environmentId: EnvironmentId.make("environment-2"),
33 primaryEnvironmentId: environmentId,
34 }),
35 ).toBe("remote");
36 });
37
38 it("targets remote when no primary environment is known", () => {
39 expect(
40 folderDropTarget({
41 localEnvironmentDisabled: false,
42 environmentId,
43 primaryEnvironmentId: null,
44 }),
45 ).toBe("remote");
46 });
47});
48
49describe("resolveDroppedFolderPath", () => {
50 it("returns the native path when the bridge provides it", () => {
51 const folder = new File([], "contracts");
52 expect(resolveDroppedFolderPath(folder, () => "/tmp/project/contracts")).toBe(
53 "/tmp/project/contracts",
54 );
55 });
56
57 it("returns null for an outside-folder drop even when the folder name matches a project directory", () => {
58 const folder = new File([], "contracts");
59 expect(resolveDroppedFolderPath(folder, undefined)).toBeNull();
60 });
61
62 it("returns null when the bridge returns an empty path", () => {
63 const folder = new File([], "contracts");
64 expect(resolveDroppedFolderPath(folder, () => "")).toBeNull();
65 });
66});
apps/web/src/components/chat/folderDrop.ts created+24
......@@ -0,0 +1,24 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2
3export function folderDropTarget(input: {
4 localEnvironmentDisabled: boolean;
5 environmentId: EnvironmentId;
6 primaryEnvironmentId: EnvironmentId | null;
7}): "local" | "remote" {
8 if (
9 input.localEnvironmentDisabled ||
10 input.primaryEnvironmentId === null ||
11 input.environmentId !== input.primaryEnvironmentId
12 ) {
13 return "remote";
14 }
15 return "local";
16}
17
18export function resolveDroppedFolderPath(
19 folder: File,
20 getPathForFile: ((file: File) => string) | undefined,
21): string | null {
22 const path = getPathForFile?.(folder);
23 return typeof path === "string" && path.length > 0 ? path : null;
24}
apps/web/src/components/chat/workspaceFileDrop.test.ts+58-2
......@@ -8,6 +8,7 @@ import {
88function makeDragEvent(options?: {
99 types?: string[];
1010 files?: File[];
11 items?: NonNullable<WorkspaceFileDragEvent["dataTransfer"]["items"]>;
1112 movedWithinTarget?: boolean;
1213}) {
1314 const preventDefault = vi.fn();
......@@ -16,6 +17,7 @@ function makeDragEvent(options?: {
1617 types: options?.types ?? ["Files"],
1718 files: options?.files ?? [],
1819 dropEffect: "none",
20 ...(options?.items === undefined ? {} : { items: options.items }),
1921 },
2022 relatedTarget: options?.movedWithinTarget ? ({} as EventTarget) : null,
2123 currentTarget: {
......@@ -29,8 +31,9 @@ function makeDragEvent(options?: {
2931function makeHost() {
3032 const setDragActive = vi.fn();
3133 const addFiles = vi.fn();
32 const host = { setDragActive, addFiles } satisfies WorkspaceFileDropHost;
33 return { host, setDragActive, addFiles };
34 const addFolders = vi.fn();
35 const host = { setDragActive, addFiles, addFolders } satisfies WorkspaceFileDropHost;
36 return { host, setDragActive, addFiles, addFolders };
3437}
3538
3639describe("makeWorkspaceFileDropHandlers", () => {
......@@ -75,4 +78,57 @@ describe("makeWorkspaceFileDropHandlers", () => {
7578 expect(setDragActive).toHaveBeenCalledWith(false);
7679 expect(addFiles).toHaveBeenCalledWith([file]);
7780 });
81
82 it("routes mixed drops to files and folders", () => {
83 const file = new File(["contents"], "example.txt", { type: "text/plain" });
84 const folder = new File([], "project", { type: "" });
85 const directory = {
86 kind: "file",
87 getAsFile: () => folder,
88 webkitGetAsEntry: () => ({ isDirectory: true }),
89 };
90 const { host, addFiles, addFolders } = makeHost();
91 const { event } = makeDragEvent({
92 items: [
93 directory,
94 {
95 kind: "file",
96 getAsFile: () => file,
97 webkitGetAsEntry: () => ({ isDirectory: false }),
98 },
99 ],
100 });
101
102 makeWorkspaceFileDropHandlers(host).onDrop(event);
103
104 expect(addFiles).toHaveBeenCalledWith([file]);
105 expect(addFolders).toHaveBeenCalledWith([folder]);
106 });
107
108 it("routes a folder-only drop without attaching files", () => {
109 const folder = new File([], "project", { type: "" });
110 const directory = {
111 kind: "file",
112 getAsFile: () => folder,
113 webkitGetAsEntry: () => ({ isDirectory: true }),
114 };
115 const { host, addFiles, addFolders } = makeHost();
116 const { event } = makeDragEvent({ items: [directory] });
117
118 makeWorkspaceFileDropHandlers(host).onDrop(event);
119
120 expect(addFiles).not.toHaveBeenCalled();
121 expect(addFolders).toHaveBeenCalledWith([folder]);
122 });
123
124 it("uses files when the browser does not expose drag items", () => {
125 const file = new File(["contents"], "example.txt", { type: "text/plain" });
126 const { host, addFiles, addFolders } = makeHost();
127 const { event } = makeDragEvent({ files: [file] });
128
129 makeWorkspaceFileDropHandlers(host).onDrop(event);
130
131 expect(addFiles).toHaveBeenCalledWith([file]);
132 expect(addFolders).not.toHaveBeenCalled();
133 });
78134});
apps/web/src/components/chat/workspaceFileDrop.ts+31-1
......@@ -2,6 +2,11 @@ export interface WorkspaceFileDragEvent {
22 readonly dataTransfer: {
33 readonly types: ReadonlyArray<string>;
44 readonly files: Iterable<File>;
5 readonly items?: Iterable<{
6 readonly kind: string;
7 getAsFile(): File | null;
8 webkitGetAsEntry(): { readonly isDirectory: boolean } | null;
9 }>;
510 dropEffect: string;
611 };
712 readonly relatedTarget: EventTarget | null;
......@@ -14,6 +19,7 @@ export interface WorkspaceFileDragEvent {
1419export interface WorkspaceFileDropHost {
1520 setDragActive(active: boolean): void;
1621 addFiles(files: File[]): void;
22 addFolders(folders: File[]): void;
1723}
1824
1925function isFileDrag(event: WorkspaceFileDragEvent): boolean {
......@@ -24,6 +30,28 @@ function movedWithinDropTarget(event: WorkspaceFileDragEvent): boolean {
2430 return event.relatedTarget !== null && event.currentTarget.contains(event.relatedTarget as Node);
2531}
2632
33function splitDroppedItems(dataTransfer: WorkspaceFileDragEvent["dataTransfer"]): {
34 files: File[];
35 folders: File[];
36} {
37 if (dataTransfer.items === undefined)
38 return { files: Array.from(dataTransfer.files), folders: [] };
39
40 const files: File[] = [];
41 const folders: File[] = [];
42 for (const item of dataTransfer.items) {
43 if (item.kind !== "file") continue;
44 const file = item.getAsFile();
45 if (file === null) continue;
46 if (item.webkitGetAsEntry()?.isDirectory === true) {
47 folders.push(file);
48 } else {
49 files.push(file);
50 }
51 }
52 return { files, folders };
53}
54
2755export function makeWorkspaceFileDropHandlers(host: WorkspaceFileDropHost) {
2856 return {
2957 onDragEnter(event: WorkspaceFileDragEvent) {
......@@ -48,7 +76,9 @@ export function makeWorkspaceFileDropHandlers(host: WorkspaceFileDropHost) {
4876 if (!isFileDrag(event)) return;
4977 event.preventDefault();
5078 host.setDragActive(false);
51 host.addFiles(Array.from(event.dataTransfer.files));
79 const { files, folders } = splitDroppedItems(event.dataTransfer);
80 if (files.length > 0) host.addFiles(files);
81 if (folders.length > 0) host.addFolders(folders);
5282 },
5383 };
5484}
packages/contracts/src/ipc.ts+2
......@@ -1213,6 +1213,8 @@ export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type;
12131213
12141214export interface DesktopBridge {
12151215 getAppBranding: () => DesktopAppBranding | null;
1216 /** Absolute path of a dropped or picked file; absent on desktop builds predating it. */
1217 getPathForFile?: (file: File) => string;
12161218 /** The desktop client's OS platform, read from Electron's preload process. */
12171219 getClientPlatform?: () => string;
12181220 setNotificationBadge?: (badge: { count: number; image: string | null }) => Promise<void>;