From d1a644897119300688cbd8bf26e0b64aeb92c382 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:28:28 -0700 Subject: [PATCH] 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> --- .../desktop/scripts/verify-preload-bundle.mjs | 4 ++ .../scripts/verify-preload-bundle.test.mjs | 1 + apps/desktop/src/preload.ts | 3 +- apps/web/src/components/ChatView.tsx | 1 + apps/web/src/components/LegacySidebar.tsx | 1 + apps/web/src/components/Sidebar.tsx | 2 + apps/web/src/components/chat/ChatComposer.tsx | 36 ++++++++++ .../src/components/chat/folderDrop.test.ts | 66 +++++++++++++++++++ apps/web/src/components/chat/folderDrop.ts | 24 +++++++ .../components/chat/workspaceFileDrop.test.ts | 60 ++++++++++++++++- .../src/components/chat/workspaceFileDrop.ts | 32 ++++++++- packages/contracts/src/ipc.ts | 2 + 12 files changed, 228 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/chat/folderDrop.test.ts create mode 100644 apps/web/src/components/chat/folderDrop.ts diff --git a/apps/desktop/scripts/verify-preload-bundle.mjs b/apps/desktop/scripts/verify-preload-bundle.mjs index 34d39fb8e3e45400902037ea6627ac5b430a7fa4..f6976220d07fed46457c445d96c49b8564b685c7 100644 --- a/apps/desktop/scripts/verify-preload-bundle.mjs +++ b/apps/desktop/scripts/verify-preload-bundle.mjs @@ -9,6 +9,7 @@ import { parse } from "acorn"; const expectedDesktopBridgeApis = [ "getClientPlatform", "getLocalEnvironmentBootstraps", + "getPathForFile", "pickFolder", ]; const clerkPasskeysGlobal = "__clerk_internal_electron_passkeys"; @@ -68,6 +69,9 @@ const createSandboxModules = (exposedGlobals) => { exposeInMainWorld: (name, api) => exposedGlobals.set(name, api), }, ipcRenderer, + webUtils: { + getPathForFile: () => "", + }, }; return new Map([ diff --git a/apps/desktop/scripts/verify-preload-bundle.test.mjs b/apps/desktop/scripts/verify-preload-bundle.test.mjs index a80a6d0964c1f0d1fbc5fe1580215755964a0352..d2055e2c681770133b9db842331ddfe06484bbd2 100644 --- a/apps/desktop/scripts/verify-preload-bundle.test.mjs +++ b/apps/desktop/scripts/verify-preload-bundle.test.mjs @@ -9,6 +9,7 @@ const validPreload = ` electron.contextBridge.exposeInMainWorld("desktopBridge", { getClientPlatform: () => process.platform, getLocalEnvironmentBootstraps: () => [], + getPathForFile: () => "", pickFolder: (options) => electron.ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options), }); `; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index d4339c21e5fcb7a2f8bfc81250c65451d43b9010..885b31a64d6b6f452f20e4959e50fbd9bfeaee29 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -6,7 +6,7 @@ import type { DesktopSnapShotEvent, } from "@t3tools/contracts"; import { exposeClerkBridge } from "@clerk/electron/preload"; -import { contextBridge, ipcRenderer, webFrame } from "electron"; +import { contextBridge, ipcRenderer, webFrame, webUtils } from "electron"; import * as IpcChannels from "./ipc/channels.ts"; @@ -69,6 +69,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { } return result as ReturnType; }, + getPathForFile: (file: File) => webUtils.getPathForFile(file), getClientPlatform: () => clientPlatform, setNotificationBadge: (badge) => ipcRenderer.invoke(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, badge), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 60b72c6bbc0bb4f2b2150a2e670cbf0a8276178f..6b372437f2ae6b0dcdfa35d78d3e660e0cb5a47c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -9318,6 +9318,7 @@ export default function ChatView(props: ChatViewProps) { const workspaceFileDropHandlers = makeWorkspaceFileDropHandlers({ setDragActive: setIsWorkspaceFileDragActive, addFiles: (files) => composerRef.current?.addDroppedFiles(files), + addFolders: (folders) => composerRef.current?.addDroppedFolders(folders), }); return ( diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 82f8102d38e2953f44f40e68dfaf5b8489704e26..7a13f01a4742c11391b5bb5a81593a3eb5ee2bd4 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -391,6 +391,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP addFiles: (files) => { onFileDropThreads(threadRef, files); }, + addFolders: () => {}, }), [onFileDropThreads, threadRef], ); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e5a86ff98a3c088e4d55c2cdcabfded966b38298..b11032987fd4110fbb8eb628f1f9699e3ccb9c15 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1278,6 +1278,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { addFiles: (files) => { onFileDropThreads(threadRef, files); }, + addFolders: () => {}, }) : null, [onFileDropThreads, threadRef], @@ -2057,6 +2058,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { addFiles: (files) => { props.onFileDropThreads(threadRef, files); }, + addFolders: () => {}, }), [props.onFileDropThreads, threadRef], ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d32f847bcaaa992f66e0405f20de364c85842656..e3939ce1ae0aea951d9ada7f38913e34c16f8093 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1,4 +1,6 @@ import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; +import { isLocalEnvironmentDisabled } from "../../localEnvironment"; +import { usePrimaryEnvironmentId } from "../../state/environments"; import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig"; import { useRightPanelStore } from "~/rightPanelStore"; import { AttachmentFilePreview } from "../files/AttachmentFilePreview"; @@ -47,6 +49,7 @@ import { wouldTextPasteExceedLimit, } from "@t3tools/client-runtime/text-paste"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; +import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { @@ -1220,6 +1223,7 @@ export interface ChatComposerHandle { restoreAfterTimelineReachedEnd: () => void; collapseForTimelineScrollKey: (key: string) => void; addDroppedFiles: (files: File[]) => void; + addDroppedFolders: (folders: File[]) => void; hasPendingAttachments: () => boolean; insertTextAtEnd: ( text: string, @@ -1525,6 +1529,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onExpandImage, onFileOpen, } = props; + const primaryEnvironmentId = usePrimaryEnvironmentId(); const activeTasksProgress = props.threadSyncPhase === null ? props.activeTasksProgress : null; const activeTaskSteps = props.threadSyncPhase === null ? props.activeTaskSteps : null; // ------------------------------------------------------------------ @@ -5715,6 +5720,35 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (!inserted) focusComposer(); }); }, + addDroppedFolders: (folders: File[]) => { + const target = folderDropTarget({ + localEnvironmentDisabled: isLocalEnvironmentDisabled(), + environmentId, + primaryEnvironmentId, + }); + if (target === "remote") { + toastManager.add({ + type: "error", + title: "Folders can't be dropped into remote environments", + }); + return; + } + for (const folder of folders) { + const path = resolveDroppedFolderPath(folder, window.desktopBridge?.getPathForFile); + if (path === null) { + toastManager.add({ + type: "error", + title: `Couldn't get the path of "${folder.name}"`, + description: "Type the folder path with @ instead.", + }); + continue; + } + insertComposerTextAtEnd(`${serializeComposerFileLink(path)} `, { + ensureLeadingBoundary: true, + }); + } + focusComposer(); + }, hasPendingAttachments: () => (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0, insertTextAtEnd: insertComposerTextAtEnd, @@ -5871,6 +5905,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerPreviewAnnotations, composerReviewComments, focusComposer, + environmentId, + primaryEnvironmentId, isConnecting, isComposerApprovalState, isChoiceOnlyPendingQuestion, diff --git a/apps/web/src/components/chat/folderDrop.test.ts b/apps/web/src/components/chat/folderDrop.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..872dd210fb7922fb6206fe2560f11cba62b2b9f9 --- /dev/null +++ b/apps/web/src/components/chat/folderDrop.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId } from "@t3tools/contracts"; +import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop"; + +const environmentId = EnvironmentId.make("environment-1"); + +describe("folderDropTarget", () => { + it("targets local when the thread is on the primary environment", () => { + expect( + folderDropTarget({ + localEnvironmentDisabled: false, + environmentId, + primaryEnvironmentId: environmentId, + }), + ).toBe("local"); + }); + + it("targets remote when Electron has no local environment", () => { + expect( + folderDropTarget({ + localEnvironmentDisabled: true, + environmentId, + primaryEnvironmentId: environmentId, + }), + ).toBe("remote"); + }); + + it("targets remote when the thread lives on another environment", () => { + expect( + folderDropTarget({ + localEnvironmentDisabled: false, + environmentId: EnvironmentId.make("environment-2"), + primaryEnvironmentId: environmentId, + }), + ).toBe("remote"); + }); + + it("targets remote when no primary environment is known", () => { + expect( + folderDropTarget({ + localEnvironmentDisabled: false, + environmentId, + primaryEnvironmentId: null, + }), + ).toBe("remote"); + }); +}); + +describe("resolveDroppedFolderPath", () => { + it("returns the native path when the bridge provides it", () => { + const folder = new File([], "contracts"); + expect(resolveDroppedFolderPath(folder, () => "/tmp/project/contracts")).toBe( + "/tmp/project/contracts", + ); + }); + + it("returns null for an outside-folder drop even when the folder name matches a project directory", () => { + const folder = new File([], "contracts"); + expect(resolveDroppedFolderPath(folder, undefined)).toBeNull(); + }); + + it("returns null when the bridge returns an empty path", () => { + const folder = new File([], "contracts"); + expect(resolveDroppedFolderPath(folder, () => "")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/chat/folderDrop.ts b/apps/web/src/components/chat/folderDrop.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f462394e8fac937436351d6947eeed86f0e0e6a --- /dev/null +++ b/apps/web/src/components/chat/folderDrop.ts @@ -0,0 +1,24 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +export function folderDropTarget(input: { + localEnvironmentDisabled: boolean; + environmentId: EnvironmentId; + primaryEnvironmentId: EnvironmentId | null; +}): "local" | "remote" { + if ( + input.localEnvironmentDisabled || + input.primaryEnvironmentId === null || + input.environmentId !== input.primaryEnvironmentId + ) { + return "remote"; + } + return "local"; +} + +export function resolveDroppedFolderPath( + folder: File, + getPathForFile: ((file: File) => string) | undefined, +): string | null { + const path = getPathForFile?.(folder); + return typeof path === "string" && path.length > 0 ? path : null; +} diff --git a/apps/web/src/components/chat/workspaceFileDrop.test.ts b/apps/web/src/components/chat/workspaceFileDrop.test.ts index ec5d074a3eb7a28b35255aa7a53b992af7945195..e7efc2864f25b12672f2018918cec0297aa0c574 100644 --- a/apps/web/src/components/chat/workspaceFileDrop.test.ts +++ b/apps/web/src/components/chat/workspaceFileDrop.test.ts @@ -8,6 +8,7 @@ import { function makeDragEvent(options?: { types?: string[]; files?: File[]; + items?: NonNullable; movedWithinTarget?: boolean; }) { const preventDefault = vi.fn(); @@ -16,6 +17,7 @@ function makeDragEvent(options?: { types: options?.types ?? ["Files"], files: options?.files ?? [], dropEffect: "none", + ...(options?.items === undefined ? {} : { items: options.items }), }, relatedTarget: options?.movedWithinTarget ? ({} as EventTarget) : null, currentTarget: { @@ -29,8 +31,9 @@ function makeDragEvent(options?: { function makeHost() { const setDragActive = vi.fn(); const addFiles = vi.fn(); - const host = { setDragActive, addFiles } satisfies WorkspaceFileDropHost; - return { host, setDragActive, addFiles }; + const addFolders = vi.fn(); + const host = { setDragActive, addFiles, addFolders } satisfies WorkspaceFileDropHost; + return { host, setDragActive, addFiles, addFolders }; } describe("makeWorkspaceFileDropHandlers", () => { @@ -75,4 +78,57 @@ describe("makeWorkspaceFileDropHandlers", () => { expect(setDragActive).toHaveBeenCalledWith(false); expect(addFiles).toHaveBeenCalledWith([file]); }); + + it("routes mixed drops to files and folders", () => { + const file = new File(["contents"], "example.txt", { type: "text/plain" }); + const folder = new File([], "project", { type: "" }); + const directory = { + kind: "file", + getAsFile: () => folder, + webkitGetAsEntry: () => ({ isDirectory: true }), + }; + const { host, addFiles, addFolders } = makeHost(); + const { event } = makeDragEvent({ + items: [ + directory, + { + kind: "file", + getAsFile: () => file, + webkitGetAsEntry: () => ({ isDirectory: false }), + }, + ], + }); + + makeWorkspaceFileDropHandlers(host).onDrop(event); + + expect(addFiles).toHaveBeenCalledWith([file]); + expect(addFolders).toHaveBeenCalledWith([folder]); + }); + + it("routes a folder-only drop without attaching files", () => { + const folder = new File([], "project", { type: "" }); + const directory = { + kind: "file", + getAsFile: () => folder, + webkitGetAsEntry: () => ({ isDirectory: true }), + }; + const { host, addFiles, addFolders } = makeHost(); + const { event } = makeDragEvent({ items: [directory] }); + + makeWorkspaceFileDropHandlers(host).onDrop(event); + + expect(addFiles).not.toHaveBeenCalled(); + expect(addFolders).toHaveBeenCalledWith([folder]); + }); + + it("uses files when the browser does not expose drag items", () => { + const file = new File(["contents"], "example.txt", { type: "text/plain" }); + const { host, addFiles, addFolders } = makeHost(); + const { event } = makeDragEvent({ files: [file] }); + + makeWorkspaceFileDropHandlers(host).onDrop(event); + + expect(addFiles).toHaveBeenCalledWith([file]); + expect(addFolders).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/components/chat/workspaceFileDrop.ts b/apps/web/src/components/chat/workspaceFileDrop.ts index 132a8051e15970b7b18a5d4cc6bcf669de316cc5..9ac85419f1785a56f860f404c3ede14ad9865432 100644 --- a/apps/web/src/components/chat/workspaceFileDrop.ts +++ b/apps/web/src/components/chat/workspaceFileDrop.ts @@ -2,6 +2,11 @@ export interface WorkspaceFileDragEvent { readonly dataTransfer: { readonly types: ReadonlyArray; readonly files: Iterable; + readonly items?: Iterable<{ + readonly kind: string; + getAsFile(): File | null; + webkitGetAsEntry(): { readonly isDirectory: boolean } | null; + }>; dropEffect: string; }; readonly relatedTarget: EventTarget | null; @@ -14,6 +19,7 @@ export interface WorkspaceFileDragEvent { export interface WorkspaceFileDropHost { setDragActive(active: boolean): void; addFiles(files: File[]): void; + addFolders(folders: File[]): void; } function isFileDrag(event: WorkspaceFileDragEvent): boolean { @@ -24,6 +30,28 @@ function movedWithinDropTarget(event: WorkspaceFileDragEvent): boolean { return event.relatedTarget !== null && event.currentTarget.contains(event.relatedTarget as Node); } +function splitDroppedItems(dataTransfer: WorkspaceFileDragEvent["dataTransfer"]): { + files: File[]; + folders: File[]; +} { + if (dataTransfer.items === undefined) + return { files: Array.from(dataTransfer.files), folders: [] }; + + const files: File[] = []; + const folders: File[] = []; + for (const item of dataTransfer.items) { + if (item.kind !== "file") continue; + const file = item.getAsFile(); + if (file === null) continue; + if (item.webkitGetAsEntry()?.isDirectory === true) { + folders.push(file); + } else { + files.push(file); + } + } + return { files, folders }; +} + export function makeWorkspaceFileDropHandlers(host: WorkspaceFileDropHost) { return { onDragEnter(event: WorkspaceFileDragEvent) { @@ -48,7 +76,9 @@ export function makeWorkspaceFileDropHandlers(host: WorkspaceFileDropHost) { if (!isFileDrag(event)) return; event.preventDefault(); host.setDragActive(false); - host.addFiles(Array.from(event.dataTransfer.files)); + const { files, folders } = splitDroppedItems(event.dataTransfer); + if (files.length > 0) host.addFiles(files); + if (folders.length > 0) host.addFolders(folders); }, }; } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index dd972b7aa8164bbc9ae19c90f5cb9d6a50845c91..e1af4f9361418f9f362b6e62b4cfa943a5754d68 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1213,6 +1213,8 @@ export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; + /** Absolute path of a dropped or picked file; absent on desktop builds predating it. */ + getPathForFile?: (file: File) => string; /** The desktop client's OS platform, read from Electron's preload process. */ getClientPlatform?: () => string; setNotificationBadge?: (badge: { count: number; image: string | null }) => Promise; -- 2.54.0