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