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";...@@ -141,6 +141,7 @@ import { closePreviewSession } from "./preview/closePreviewSession";
141import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer";141import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer";
142import { subscribePreviewAction } from "./preview/previewActionBus";142import { subscribePreviewAction } from "./preview/previewActionBus";
143import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";143import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";
144import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";
144import {145import {
145 selectThreadPreviewMiniPlayer,146 selectThreadPreviewMiniPlayer,
146 usePreviewMiniPlayerStore,147 usePreviewMiniPlayerStore,
...@@ -164,6 +165,7 @@ import {...@@ -164,6 +165,7 @@ import {
164 CheckCircle2Icon,165 CheckCircle2Icon,
165 ChevronDownIcon,166 ChevronDownIcon,
166 GitBranchIcon,167 GitBranchIcon,
168 PaperclipIcon,
167 WifiOffIcon,169 WifiOffIcon,
168} from "lucide-react";170} from "lucide-react";
169import { cn, randomHex } from "~/lib/utils";171import { cn, randomHex } from "~/lib/utils";
...@@ -1336,6 +1338,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1336,6 +1338,7 @@ function ChatViewContent(props: ChatViewProps) {
1336 const composerElementContextsRef = useRef<ElementContextDraft[]>([]);1338 const composerElementContextsRef = useRef<ElementContextDraft[]>([]);
1337 const localComposerRef = useRef<ChatComposerHandle | null>(null);1339 const localComposerRef = useRef<ChatComposerHandle | null>(null);
1338 const composerRef = useComposerHandleContext() ?? localComposerRef;1340 const composerRef = useComposerHandleContext() ?? localComposerRef;
1341 const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false);
1339 const [showScrollToBottom, setShowScrollToBottom] = useState(false);1342 const [showScrollToBottom, setShowScrollToBottom] = useState(false);
1340 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);1343 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
1341 const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);1344 const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
...@@ -1356,6 +1359,17 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1356,6 +1359,17 @@ function ChatViewContent(props: ChatViewProps) {
1356 const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState<1359 const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState<
1357 ApprovalRequestId[]1360 ApprovalRequestId[]
1358 >([]);1361 >([]);
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]);
1359 const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState<1373 const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState<
1360 Record<string, Record<string, PendingUserInputDraftAnswer>>1374 Record<string, Record<string, PendingUserInputDraftAnswer>>
1361 >({});1375 >({});
...@@ -6149,6 +6163,11 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6149,6 +6163,11 @@ function ChatViewContent(props: ChatViewProps) {
6149 ) : null6163 ) : null
6150 ) : null;6164 ) : null;
61516165
6166 const workspaceFileDropHandlers = makeWorkspaceFileDropHandlers({
6167 setDragActive: setIsWorkspaceFileDragActive,
6168 addFiles: (files) => composerRef.current?.addDroppedFiles(files),
6169 });
6170
6152 return (6171 return (
6153 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">6172 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
6154 {rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null}6173 {rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null}
...@@ -6217,7 +6236,28 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6217,7 +6236,28 @@ function ChatViewContent(props: ChatViewProps) {
6217 {/* Main content area with optional plan sidebar */}6236 {/* Main content area with optional plan sidebar */}
6218 <div className="flex min-h-0 min-w-0 flex-1">6237 <div className="flex min-h-0 min-w-0 flex-1">
6219 {/* Chat column */}6238 {/* 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}
6221 {/* Provider status overlays the timeline without changing its content height. */}6261 {/* Provider status overlays the timeline without changing its content height. */}
6222 <div className="pointer-events-none absolute inset-x-0 top-0 z-20">6262 <div className="pointer-events-none absolute inset-x-0 top-0 z-20">
6223 <ProviderStatusBanner6263 <ProviderStatusBanner
apps/web/src/components/chat/ChatComposer.tsx+7-42
...@@ -450,6 +450,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -450,6 +450,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
450export interface ChatComposerHandle {450export interface ChatComposerHandle {
451 focusAtEnd: () => void;451 focusAtEnd: () => void;
452 focusAt: (cursor: number) => void;452 focusAt: (cursor: number) => void;
453 addDroppedFiles: (files: File[]) => void;
453 insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean;454 insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean;
454 openModelPicker: () => void;455 openModelPicker: () => void;
455 toggleModelPicker: () => void;456 toggleModelPicker: () => void;
...@@ -971,7 +972,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -971,7 +972,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
971 const mobileComposerExpandFrameRef = useRef<number | null>(null);972 const mobileComposerExpandFrameRef = useRef<number | null>(null);
972 const mobileComposerExpandReleaseFrameRef = useRef<number | null>(null);973 const mobileComposerExpandReleaseFrameRef = useRef<number | null>(null);
973 const mobileComposerExpandInFlightRef = useRef(false);974 const mobileComposerExpandInFlightRef = useRef(false);
974 const dragDepthRef = useRef(0);
975 const stashPulseKeyRef = useRef(0);975 const stashPulseKeyRef = useRef(0);
976 const stashPulseTimeoutRef = useRef<number | null>(null);976 const stashPulseTimeoutRef = useRef<number | null>(null);
977 /**977 /**
...@@ -1399,7 +1399,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1399,7 +1399,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1399 setComposerHighlightedItemId(null);1399 setComposerHighlightedItemId(null);
1400 setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));1400 setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));
1401 setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));1401 setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));
1402 dragDepthRef.current = 0;
1403 setIsDragOverComposer(false);1402 setIsDragOverComposer(false);
1404 }, [draftId, activeThreadId, promptRef]);1403 }, [draftId, activeThreadId, promptRef]);
14051404
...@@ -2380,41 +2379,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2380,41 +2379,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2380 void addComposerImages(imageFiles);2379 void addComposerImages(imageFiles);
2381 };2380 };
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
2418 const insertComposerTextAtEnd = (2382 const insertComposerTextAtEnd = (
2419 text: string,2383 text: string,
2420 options?: { ensureLeadingBoundary?: boolean },2384 options?: { ensureLeadingBoundary?: boolean },
...@@ -2468,7 +2432,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2468,7 +2432,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2468 useEffect(() => {2432 useEffect(() => {
2469 if (!isDragOverComposer) return;2433 if (!isDragOverComposer) return;
2470 const onWindowDragEnd = () => {2434 const onWindowDragEnd = () => {
2471 dragDepthRef.current = 0;
2472 setIsDragOverComposer(false);2435 setIsDragOverComposer(false);
2473 };2436 };
2474 window.addEventListener("dragend", onWindowDragEnd);2437 window.addEventListener("dragend", onWindowDragEnd);
...@@ -2537,6 +2500,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2537,6 +2500,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2537 focusAt: (cursor: number) => {2500 focusAt: (cursor: number) => {
2538 composerEditorRef.current?.focusAt(cursor);2501 composerEditorRef.current?.focusAt(cursor);
2539 },2502 },
2503 addDroppedFiles: (files: File[]) => {
2504 void addComposerImages(files);
2505 focusComposer();
2506 },
2540 insertTextAtEnd: insertComposerTextAtEnd,2507 insertTextAtEnd: insertComposerTextAtEnd,
2541 openModelPicker: () => {2508 openModelPicker: () => {
2542 setIsComposerModelPickerOpen(true);2509 setIsComposerModelPickerOpen(true);
...@@ -2619,6 +2586,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2619,6 +2586,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2619 }),2586 }),
2620 [2587 [
2621 activeThread,2588 activeThread,
2589 addComposerImages,
2622 composerDraftTarget,2590 composerDraftTarget,
2623 composerCursor,2591 composerCursor,
2624 composerTerminalContexts,2592 composerTerminalContexts,
...@@ -2629,6 +2597,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2629,6 +2597,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2629 composerElementContextsRef,2597 composerElementContextsRef,
2630 composerPreviewAnnotations,2598 composerPreviewAnnotations,
2631 composerReviewComments,2599 composerReviewComments,
2600 focusComposer,
2632 isConnecting,2601 isConnecting,
2633 isComposerApprovalState,2602 isComposerApprovalState,
2634 pendingUserInputs.length,2603 pendingUserInputs.length,
...@@ -2660,10 +2629,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2660,10 +2629,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2660 "group rounded-[22px] p-px transition-colors duration-200",2629 "group rounded-[22px] p-px transition-colors duration-200",
2661 composerProviderState.composerFrameClassName,2630 composerProviderState.composerFrameClassName,
2662 )}2631 )}
2663 onDragEnter={onComposerDragEnter}
2664 onDragOver={onComposerDragOver}
2665 onDragLeave={onComposerDragLeave}
2666 onDrop={onComposerDrop}
2667 onDragEnterCapture={composerMentionDragHandlers.onDragEnter}2632 onDragEnterCapture={composerMentionDragHandlers.onDragEnter}
2668 onDragOverCapture={composerMentionDragHandlers.onDragOver}2633 onDragOverCapture={composerMentionDragHandlers.onDragOver}
2669 onDragLeaveCapture={onComposerMentionDragLeaveCapture}2634 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}