Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 78 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ import { BranchToolbar } from "./BranchToolbar";
import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
import PlanSidebar from "./PlanSidebar";
import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react";
import { ChevronDownIcon, ImageUpIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react";
import { cn, randomHex } from "~/lib/utils";
import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
import { stackedThreadToast, toastManager } from "./ui/toast";
Expand Down Expand Up @@ -1196,6 +1196,22 @@ function ChatViewContent(props: ChatViewProps) {
const localComposerRef = useRef<ChatComposerHandle | null>(null);
const composerRef = useComposerHandleContext() ?? localComposerRef;
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [isDraggingFilesOverView, setIsDraggingFilesOverView] = useState(false);
const viewDragDepthRef = useRef(0);
const resetViewDragState = useCallback(() => {
viewDragDepthRef.current = 0;
setIsDraggingFilesOverView(false);
}, []);

// A cancelled drag (Escape) can end without a dragleave on the hovered
// target, which would leave the drop overlay stuck. dragend always fires
// on the in-page drag source and bubbles to window, so it is the reset of
// last resort while the overlay is up.
useEffect(() => {
if (!isDraggingFilesOverView) return;
window.addEventListener("dragend", resetViewDragState);
return () => window.removeEventListener("dragend", resetViewDragState);
}, [isDraggingFilesOverView, resetViewDragState]);
const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
const optimisticUserMessagesRef = useRef(optimisticUserMessages);
Expand Down Expand Up @@ -5084,6 +5100,17 @@ function ChatViewContent(props: ChatViewProps) {
void onRevertToTurnCountRef.current(targetTurnCount);
}, []);

const canDropImagesOnView =
activeEnvironmentUnavailableState === null && !(isLocalDraftThread && activeProject === null);

// If attaching becomes unavailable mid-drag, retract the overlay so it
// does not keep inviting a drop that would be discarded.
useEffect(() => {
if (!canDropImagesOnView) {
resetViewDragState();
}
}, [canDropImagesOnView, resetViewDragState]);

// Empty state: no active thread
if (!activeThread) {
return <NoActiveThreadState />;
Expand Down Expand Up @@ -5187,8 +5214,57 @@ function ChatViewContent(props: ChatViewProps) {
) : null
) : null;

const onViewDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
if (!canDropImagesOnView || !event.dataTransfer.types.includes("Files")) return;
event.preventDefault();
viewDragDepthRef.current += 1;
setIsDraggingFilesOverView(true);
};

const onViewDragOver = (event: React.DragEvent<HTMLDivElement>) => {
if (!canDropImagesOnView || !event.dataTransfer.types.includes("Files")) return;
Comment thread
cursor[bot] marked this conversation as resolved.
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
setIsDraggingFilesOverView(true);
};

// dragenter/dragleave bubble in balanced pairs from descendants, so a
// plain depth counter tracks whether the pointer is still inside the
// view without inspecting relatedTarget (which nested targets and some
// browsers report as null).
const onViewDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
if (!event.dataTransfer.types.includes("Files")) return;
event.preventDefault();
viewDragDepthRef.current = Math.max(0, viewDragDepthRef.current - 1);
if (viewDragDepthRef.current === 0) {
setIsDraggingFilesOverView(false);
}
};
Comment thread
cursor[bot] marked this conversation as resolved.

const onViewDrop = (event: React.DragEvent<HTMLDivElement>) => {
if (!event.dataTransfer.types.includes("Files")) return;
event.preventDefault();
resetViewDragState();
if (!canDropImagesOnView) return;
composerRef.current?.addImages(Array.from(event.dataTransfer.files));
Comment thread
cursor[bot] marked this conversation as resolved.
};
Comment thread
cursor[bot] marked this conversation as resolved.

return (
<div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
<div
className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background"
onDragEnter={onViewDragEnter}
onDragOver={onViewDragOver}
onDragLeave={onViewDragLeave}
onDrop={onViewDrop}
>
{isDraggingFilesOverView ? (
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-background/70 p-6 backdrop-blur-sm">
<div className="flex items-center gap-2.5 rounded-2xl border-2 border-dashed border-primary/60 bg-card/90 px-6 py-4 font-medium text-foreground text-sm shadow-lg">
<ImageUpIcon className="size-5 text-primary" />
Drop images to attach
</div>
</div>
) : null}
{rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null}
<div
className={cn(
Expand Down
95 changes: 55 additions & 40 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ import {
LockIcon,
LockOpenIcon,
PenLineIcon,
PlusIcon,
XIcon,
} from "lucide-react";
import { proposedPlanTitle } from "../../proposedPlan";
Expand Down Expand Up @@ -422,6 +423,8 @@ export interface ChatComposerHandle {
}) => void;
/** Insert a terminal context from the terminal drawer. */
addTerminalContext: (selection: TerminalContextSelection) => void;
/** Attach image files (e.g. from a view-level drop zone). */
addImages: (files: File[]) => void;
/** Get the current prompt/effort/model state for use in send. */
getSendContext: () => {
prompt: string;
Expand Down Expand Up @@ -919,6 +922,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const mobileComposerExpandReleaseFrameRef = useRef<number | null>(null);
const mobileComposerExpandInFlightRef = useRef(false);
const dragDepthRef = useRef(0);
const attachFileInputRef = useRef<HTMLInputElement | null>(null);

// ------------------------------------------------------------------
// Derived: composer send state
Expand Down Expand Up @@ -1836,8 +1840,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
removeComposerImageFromDraft(imageId);
};

const openAttachFilePicker = () => {
attachFileInputRef.current?.click();
};

const onAttachFileInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
event.target.value = "";
if (files.length === 0) return;
addComposerImages(files);
focusComposer();
};

// ------------------------------------------------------------------
// Callbacks: paste / drag
// Callbacks: paste
// ------------------------------------------------------------------
const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => {
const files = Array.from(event.clipboardData.files);
Expand All @@ -1848,41 +1864,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
addComposerImages(imageFiles);
};

const onComposerDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
if (!event.dataTransfer.types.includes("Files")) return;
event.preventDefault();
dragDepthRef.current += 1;
setIsDragOverComposer(true);
};

const onComposerDragOver = (event: React.DragEvent<HTMLDivElement>) => {
if (!event.dataTransfer.types.includes("Files")) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
setIsDragOverComposer(true);
};

const onComposerDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
if (!event.dataTransfer.types.includes("Files")) return;
event.preventDefault();
const nextTarget = event.relatedTarget;
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) {
setIsDragOverComposer(false);
}
};

const onComposerDrop = (event: React.DragEvent<HTMLDivElement>) => {
if (!event.dataTransfer.types.includes("Files")) return;
event.preventDefault();
dragDepthRef.current = 0;
setIsDragOverComposer(false);
const files = Array.from(event.dataTransfer.files);
addComposerImages(files);
focusComposer();
};

const insertComposerTextAtEnd = (
text: string,
options?: { ensureLeadingBoundary?: boolean },
Expand Down Expand Up @@ -1943,6 +1924,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
window.addEventListener("dragend", onWindowDragEnd);
return () => window.removeEventListener("dragend", onWindowDragEnd);
}, [isDragOverComposer]);

const handleInterruptPrimaryAction = useCallback(() => {
void onInterrupt();
}, [onInterrupt]);
Expand Down Expand Up @@ -2035,6 +2017,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
: null,
);
},
addImages: (files: File[]) => {
if (environmentUnavailable !== null || projectSelectionRequired) return;
addComposerImages(files);
if (isComposerCollapsedMobile) {
expandMobileComposer();
} else {
focusComposer();
}
},
addTerminalContext: (selection: TerminalContextSelection) => {
if (!activeThread) return;
const snapshot = composerEditorRef.current?.readSnapshot() ?? {
Expand Down Expand Up @@ -2104,6 +2095,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
environmentUnavailable,
activePendingProgress,
applyPromptReplacement,
isComposerCollapsedMobile,
expandMobileComposer,
isComposerModelPickerOpen,
readComposerSnapshot,
selectedModel,
Expand All @@ -2129,10 +2122,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
"group rounded-[22px] p-px transition-colors duration-200",
composerProviderState.composerFrameClassName,
)}
onDragEnter={onComposerDragEnter}
onDragOver={onComposerDragOver}
onDragLeave={onComposerDragLeave}
onDrop={onComposerDrop}
onDragEnterCapture={composerMentionDragHandlers.onDragEnter}
onDragOverCapture={composerMentionDragHandlers.onDragOver}
onDragLeaveCapture={onComposerMentionDragLeaveCapture}
Expand Down Expand Up @@ -2558,6 +2547,32 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
)}
>
<div className="-m-1 flex min-w-0 flex-1 items-center gap-1 overflow-x-auto p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<input
ref={attachFileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={onAttachFileInputChange}
/>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
type="button"
className="shrink-0 text-muted-foreground/70 hover:text-foreground/80"
disabled={environmentUnavailable !== null || projectSelectionRequired}
onClick={openAttachFilePicker}
aria-label="Attach images"
/>
}
>
<PlusIcon />
</TooltipTrigger>
<TooltipPopup side="top">Attach images</TooltipPopup>
</Tooltip>
<ProviderModelPicker
compact={isComposerFooterCompact}
activeInstanceId={selectedInstanceId}
Expand Down
Loading