authorgravatar for dbalders@gmail.comDavid Balderston <dbalders@gmail.com> 2026-08-15 03:43:58-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 12:43:58+02:00
loga6ac27e7fd965f26ee0c61946e056f0fd889818c
tree590fc2966e18365536b6d144ce40ac0ff7bafc5d
parent1e87029261f9b81061a2a7420849b9eeaf1a2ebe
signature Signed by PGP key B5690EEEBB952194

feat(web): accept file drops across the chat workspace (#6636)


4 files changed, 180 insertions(+), 43 deletions(-)

apps/web/src/components/ChatView.tsx+41-1
......@@ -141,6 +141,7 @@ import { closePreviewSession } from "./preview/closePreviewSession";
141141import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer";
142142import { subscribePreviewAction } from "./preview/previewActionBus";
143143import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";
144import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";
144145import {
145146 selectThreadPreviewMiniPlayer,
146147 usePreviewMiniPlayerStore,
......@@ -164,6 +165,7 @@ import {
164165 CheckCircle2Icon,
165166 ChevronDownIcon,
166167 GitBranchIcon,
168 PaperclipIcon,
167169 WifiOffIcon,
168170} from "lucide-react";
169171import { cn, randomHex } from "~/lib/utils";
......@@ -1336,6 +1338,7 @@ function ChatViewContent(props: ChatViewProps) {
13361338 const composerElementContextsRef = useRef<ElementContextDraft[]>([]);
13371339 const localComposerRef = useRef<ChatComposerHandle | null>(null);
13381340 const composerRef = useComposerHandleContext() ?? localComposerRef;
1341 const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false);
13391342 const [showScrollToBottom, setShowScrollToBottom] = useState(false);
13401343 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
13411344 const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
......@@ -1356,6 +1359,17 @@ function ChatViewContent(props: ChatViewProps) {
13561359 const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState<
13571360 ApprovalRequestId[]
13581361 >([]);
1362
1363 useEffect(() => {
1364 setIsWorkspaceFileDragActive(false);
1365 }, [draftId, routeThreadKey]);
1366
1367 useEffect(() => {
1368 if (!isWorkspaceFileDragActive) return;
1369 const clearWorkspaceFileDrag = () => setIsWorkspaceFileDragActive(false);
1370 window.addEventListener("dragend", clearWorkspaceFileDrag);
1371 return () => window.removeEventListener("dragend", clearWorkspaceFileDrag);
1372 }, [isWorkspaceFileDragActive]);
13591373 const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState<
13601374 Record<string, Record<string, PendingUserInputDraftAnswer>>
13611375 >({});
......@@ -6149,6 +6163,11 @@ function ChatViewContent(props: ChatViewProps) {
61496163 ) : null
61506164 ) : null;
61516165
6166 const workspaceFileDropHandlers = makeWorkspaceFileDropHandlers({
6167 setDragActive: setIsWorkspaceFileDragActive,
6168 addFiles: (files) => composerRef.current?.addDroppedFiles(files),
6169 });
6170
61526171 return (
61536172 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
61546173 {rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null}
......@@ -6217,7 +6236,28 @@ function ChatViewContent(props: ChatViewProps) {
62176236 {/* Main content area with optional plan sidebar */}
62186237 <div className="flex min-h-0 min-w-0 flex-1">
62196238 {/* Chat column */}
6220 <div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
6239 <div
6240 className="relative flex min-h-0 min-w-0 flex-1 flex-col"
6241 data-chat-workspace-drop-target="true"
6242 onDragEnter={workspaceFileDropHandlers.onDragEnter}
6243 onDragOver={workspaceFileDropHandlers.onDragOver}
6244 onDragLeave={workspaceFileDropHandlers.onDragLeave}
6245 onDrop={workspaceFileDropHandlers.onDrop}
6246 >
6247 {isWorkspaceFileDragActive ? (
6248 <div
6249 className="pointer-events-none absolute inset-2 z-40 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/60 bg-primary/[0.035]"
6250 data-chat-workspace-drop-overlay="true"
6251 >
6252 <div
6253 role="status"
6254 className="flex items-center gap-2 rounded-full border border-primary/25 bg-background/95 px-4 py-2.5 text-sm font-medium text-foreground shadow-lg"
6255 >
6256 <PaperclipIcon className="size-4 text-primary" aria-hidden="true" />
6257 Drop files to attach
6258 </div>
6259 </div>
6260 ) : null}
62216261 {/* Provider status overlays the timeline without changing its content height. */}
62226262 <div className="pointer-events-none absolute inset-x-0 top-0 z-20">
62236263 <ProviderStatusBanner
apps/web/src/components/chat/ChatComposer.tsx+7-42
......@@ -450,6 +450,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
450450export interface ChatComposerHandle {
451451 focusAtEnd: () => void;
452452 focusAt: (cursor: number) => void;
453 addDroppedFiles: (files: File[]) => void;
453454 insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean;
454455 openModelPicker: () => void;
455456 toggleModelPicker: () => void;
......@@ -971,7 +972,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
971972 const mobileComposerExpandFrameRef = useRef<number | null>(null);
972973 const mobileComposerExpandReleaseFrameRef = useRef<number | null>(null);
973974 const mobileComposerExpandInFlightRef = useRef(false);
974 const dragDepthRef = useRef(0);
975975 const stashPulseKeyRef = useRef(0);
976976 const stashPulseTimeoutRef = useRef<number | null>(null);
977977 /**
......@@ -1399,7 +1399,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
13991399 setComposerHighlightedItemId(null);
14001400 setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));
14011401 setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));
1402 dragDepthRef.current = 0;
14031402 setIsDragOverComposer(false);
14041403 }, [draftId, activeThreadId, promptRef]);
14051404
......@@ -2380,41 +2379,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
23802379 void addComposerImages(imageFiles);
23812380 };
23822381
2383 const onComposerDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
2384 if (!event.dataTransfer.types.includes("Files")) return;
2385 event.preventDefault();
2386 dragDepthRef.current += 1;
2387 setIsDragOverComposer(true);
2388 };
2389
2390 const onComposerDragOver = (event: React.DragEvent<HTMLDivElement>) => {
2391 if (!event.dataTransfer.types.includes("Files")) return;
2392 event.preventDefault();
2393 event.dataTransfer.dropEffect = "copy";
2394 setIsDragOverComposer(true);
2395 };
2396
2397 const onComposerDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
2398 if (!event.dataTransfer.types.includes("Files")) return;
2399 event.preventDefault();
2400 const nextTarget = event.relatedTarget;
2401 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;
2402 dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
2403 if (dragDepthRef.current === 0) {
2404 setIsDragOverComposer(false);
2405 }
2406 };
2407
2408 const onComposerDrop = (event: React.DragEvent<HTMLDivElement>) => {
2409 if (!event.dataTransfer.types.includes("Files")) return;
2410 event.preventDefault();
2411 dragDepthRef.current = 0;
2412 setIsDragOverComposer(false);
2413 const files = Array.from(event.dataTransfer.files);
2414 void addComposerImages(files);
2415 focusComposer();
2416 };
2417
24182382 const insertComposerTextAtEnd = (
24192383 text: string,
24202384 options?: { ensureLeadingBoundary?: boolean },
......@@ -2468,7 +2432,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
24682432 useEffect(() => {
24692433 if (!isDragOverComposer) return;
24702434 const onWindowDragEnd = () => {
2471 dragDepthRef.current = 0;
24722435 setIsDragOverComposer(false);
24732436 };
24742437 window.addEventListener("dragend", onWindowDragEnd);
......@@ -2537,6 +2500,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25372500 focusAt: (cursor: number) => {
25382501 composerEditorRef.current?.focusAt(cursor);
25392502 },
2503 addDroppedFiles: (files: File[]) => {
2504 void addComposerImages(files);
2505 focusComposer();
2506 },
25402507 insertTextAtEnd: insertComposerTextAtEnd,
25412508 openModelPicker: () => {
25422509 setIsComposerModelPickerOpen(true);
......@@ -2619,6 +2586,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
26192586 }),
26202587 [
26212588 activeThread,
2589 addComposerImages,
26222590 composerDraftTarget,
26232591 composerCursor,
26242592 composerTerminalContexts,
......@@ -2629,6 +2597,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
26292597 composerElementContextsRef,
26302598 composerPreviewAnnotations,
26312599 composerReviewComments,
2600 focusComposer,
26322601 isConnecting,
26332602 isComposerApprovalState,
26342603 pendingUserInputs.length,
......@@ -2660,10 +2629,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
26602629 "group rounded-[22px] p-px transition-colors duration-200",
26612630 composerProviderState.composerFrameClassName,
26622631 )}
2663 onDragEnter={onComposerDragEnter}
2664 onDragOver={onComposerDragOver}
2665 onDragLeave={onComposerDragLeave}
2666 onDrop={onComposerDrop}
26672632 onDragEnterCapture={composerMentionDragHandlers.onDragEnter}
26682633 onDragOverCapture={composerMentionDragHandlers.onDragOver}
26692634 onDragLeaveCapture={onComposerMentionDragLeaveCapture}
apps/web/src/components/chat/workspaceFileDrop.test.ts created+78
......@@ -0,0 +1,78 @@
1import { describe, expect, it, vi } from "@effect/vitest";
2import {
3 makeWorkspaceFileDropHandlers,
4 type WorkspaceFileDragEvent,
5 type WorkspaceFileDropHost,
6} from "./workspaceFileDrop";
7
8function makeDragEvent(options?: {
9 types?: string[];
10 files?: File[];
11 movedWithinTarget?: boolean;
12}) {
13 const preventDefault = vi.fn();
14 const event = {
15 dataTransfer: {
16 types: options?.types ?? ["Files"],
17 files: options?.files ?? [],
18 dropEffect: "none",
19 },
20 relatedTarget: options?.movedWithinTarget ? ({} as EventTarget) : null,
21 currentTarget: {
22 contains: () => options?.movedWithinTarget ?? false,
23 },
24 preventDefault,
25 } satisfies WorkspaceFileDragEvent;
26 return { event, preventDefault };
27}
28
29function makeHost() {
30 const setDragActive = vi.fn();
31 const addFiles = vi.fn();
32 const host = { setDragActive, addFiles } satisfies WorkspaceFileDropHost;
33 return { host, setDragActive, addFiles };
34}
35
36describe("makeWorkspaceFileDropHandlers", () => {
37 it("activates the target for an external file drag", () => {
38 const { host, setDragActive } = makeHost();
39 const { event, preventDefault } = makeDragEvent();
40
41 makeWorkspaceFileDropHandlers(host).onDragEnter(event);
42
43 expect(preventDefault).toHaveBeenCalledOnce();
44 expect(setDragActive).toHaveBeenCalledWith(true);
45 });
46
47 it("ignores non-file drags", () => {
48 const { host, setDragActive } = makeHost();
49 const { event, preventDefault } = makeDragEvent({ types: ["text/plain"] });
50
51 makeWorkspaceFileDropHandlers(host).onDragOver(event);
52
53 expect(preventDefault).not.toHaveBeenCalled();
54 expect(setDragActive).not.toHaveBeenCalled();
55 });
56
57 it("does not flicker when the drag moves between children", () => {
58 const { host, setDragActive } = makeHost();
59 const { event } = makeDragEvent({ movedWithinTarget: true });
60
61 const handlers = makeWorkspaceFileDropHandlers(host);
62 handlers.onDragEnter(event);
63 handlers.onDragLeave(event);
64
65 expect(setDragActive).not.toHaveBeenCalled();
66 });
67
68 it("forwards dropped files and clears the active state", () => {
69 const file = new File(["contents"], "example.txt", { type: "text/plain" });
70 const { host, setDragActive, addFiles } = makeHost();
71 const { event } = makeDragEvent({ files: [file] });
72
73 makeWorkspaceFileDropHandlers(host).onDrop(event);
74
75 expect(setDragActive).toHaveBeenCalledWith(false);
76 expect(addFiles).toHaveBeenCalledWith([file]);
77 });
78});
apps/web/src/components/chat/workspaceFileDrop.ts created+54
......@@ -0,0 +1,54 @@
1export interface WorkspaceFileDragEvent {
2 readonly dataTransfer: {
3 readonly types: ReadonlyArray<string>;
4 readonly files: Iterable<File>;
5 dropEffect: string;
6 };
7 readonly relatedTarget: EventTarget | null;
8 readonly currentTarget: {
9 contains(target: Node | null): boolean;
10 };
11 preventDefault(): void;
12}
13
14export interface WorkspaceFileDropHost {
15 setDragActive(active: boolean): void;
16 addFiles(files: File[]): void;
17}
18
19function isFileDrag(event: WorkspaceFileDragEvent): boolean {
20 return event.dataTransfer.types.includes("Files");
21}
22
23function movedWithinDropTarget(event: WorkspaceFileDragEvent): boolean {
24 return event.relatedTarget !== null && event.currentTarget.contains(event.relatedTarget as Node);
25}
26
27export function makeWorkspaceFileDropHandlers(host: WorkspaceFileDropHost) {
28 return {
29 onDragEnter(event: WorkspaceFileDragEvent) {
30 if (!isFileDrag(event)) return;
31 event.preventDefault();
32 if (movedWithinDropTarget(event)) return;
33 host.setDragActive(true);
34 },
35 onDragOver(event: WorkspaceFileDragEvent) {
36 if (!isFileDrag(event)) return;
37 event.preventDefault();
38 event.dataTransfer.dropEffect = "copy";
39 host.setDragActive(true);
40 },
41 onDragLeave(event: WorkspaceFileDragEvent) {
42 if (!isFileDrag(event)) return;
43 event.preventDefault();
44 if (movedWithinDropTarget(event)) return;
45 host.setDragActive(false);
46 },
47 onDrop(event: WorkspaceFileDragEvent) {
48 if (!isFileDrag(event)) return;
49 event.preventDefault();
50 host.setDragActive(false);
51 host.addFiles(Array.from(event.dataTransfer.files));
52 },
53 };
54}