Skip to content
Merged
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
73 changes: 72 additions & 1 deletion frontend/features/chat/components/app-chat-area.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ import { cn } from "@/lib/utils";
const MODEL_OPTIONS_STORAGE_PREFIX = "deeix-chat:chat-model-options:";
const EMPTY_CONVERSATION_OPTIONS: ConversationOptions = {};

function dragEventContainsFiles(event: React.DragEvent<HTMLElement>): boolean {
return Array.from(event.dataTransfer.types ?? []).includes("Files");
}

function droppedFiles(event: React.DragEvent<HTMLElement>): File[] {
return Array.from(event.dataTransfer.files ?? []).filter((file) => file.name.trim() || file.size > 0);
}

function modelOptionsStorageKey(platformModelName: string): string {
return `${MODEL_OPTIONS_STORAGE_PREFIX}${encodeURIComponent(platformModelName)}`;
}
Expand Down Expand Up @@ -215,6 +223,8 @@ export function AppChatArea() {
const [selectedToolIDs, setSelectedToolIDs] = React.useState<number[]>([]);
const htmlVisualPrompt = useHTMLVisualPrompt();
const initializedOptionsModelRef = React.useRef("");
const fileDragDepthRef = React.useRef(0);
const [fileDragActive, setFileDragActive] = React.useState(false);

React.useEffect(() => {
setSelectedToolIDs((current) => {
Expand Down Expand Up @@ -359,6 +369,7 @@ export function AppChatArea() {
activeGenerationRunsRef,
});
const generating = sending || Boolean(resumingRunID);
const uploadDropDisabled = generating || loading || uploading;
const showLiveAssistant = showPendingAssistant || Boolean(resumingRunID);
const latestMessageKey = visibleMessages.at(-1)?.key ?? "";
const onStopActiveMessage = React.useCallback(() => {
Expand Down Expand Up @@ -637,6 +648,59 @@ export function AppChatArea() {
const selectedModelDefaultOptions = modelOptionPolicyDisabled
? EMPTY_CONVERSATION_OPTIONS
: (selectedModel?.defaultOptions ?? EMPTY_CONVERSATION_OPTIONS);
const resetFileDragState = React.useCallback(() => {
fileDragDepthRef.current = 0;
setFileDragActive(false);
}, []);
const onFileDragEnter = React.useCallback((event: React.DragEvent<HTMLDivElement>) => {
if (!dragEventContainsFiles(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
if (uploadDropDisabled) {
return;
}
fileDragDepthRef.current += 1;
setFileDragActive(true);
}, [uploadDropDisabled]);
const onFileDragOver = React.useCallback((event: React.DragEvent<HTMLDivElement>) => {
if (!dragEventContainsFiles(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = uploadDropDisabled ? "none" : "copy";
}, [uploadDropDisabled]);
const onFileDragLeave = React.useCallback((event: React.DragEvent<HTMLDivElement>) => {
if (!dragEventContainsFiles(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
fileDragDepthRef.current = Math.max(0, fileDragDepthRef.current - 1);
if (fileDragDepthRef.current === 0) {
setFileDragActive(false);
}
}, []);
const onFileDrop = React.useCallback((event: React.DragEvent<HTMLDivElement>) => {
if (!dragEventContainsFiles(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
const files = droppedFiles(event);
resetFileDragState();
if (uploadDropDisabled || files.length === 0) {
return;
}
void onUploadFiles(files);
}, [onUploadFiles, resetFileDragState, uploadDropDisabled]);
React.useEffect(() => {
if (uploadDropDisabled) {
resetFileDragState();
}
}, [resetFileDragState, uploadDropDisabled]);

const chatInputProps = {
draft,
Expand All @@ -661,6 +725,7 @@ export function AppChatArea() {
defaultOptions: selectedModelDefaultOptions,
modelOptionPolicy,
modelLoading: modelsLoading,
dropActive: fileDragActive,
onDraftChange: setDraft,
onModelChange: setSelectedPlatformModelName,
onSelectedToolsChange: setSelectedToolIDs,
Expand All @@ -679,7 +744,13 @@ export function AppChatArea() {
!isConversationLoading && !isConversationLoadFailed && !isConversationMode && messagesWithInlineError.length === 0;

return (
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden md:overflow-visible">
<div
className="relative flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden md:overflow-visible"
onDragEnter={onFileDragEnter}
onDragOver={onFileDragOver}
onDragLeave={onFileDragLeave}
onDrop={onFileDrop}
>
{shouldUseCenteredComposer ? (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<ChatEmptyState greetingTitle={greetingTitle}>
Expand Down
7 changes: 5 additions & 2 deletions frontend/features/chat/components/sections/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ type ChatInputProps = {
modelOptionPolicy: ModelOptionPolicy | null;
modelLoading: boolean;
modelDisabled?: boolean;
dropActive?: boolean;
onDraftChange: (value: string) => void;
onModelChange: (platformModelName: string) => void;
onSelectedToolsChange: (toolIDs: number[]) => void;
Expand Down Expand Up @@ -178,6 +179,7 @@ function ChatInputComponent({
modelOptionPolicy,
modelLoading,
modelDisabled = false,
dropActive = false,
onDraftChange,
onModelChange,
onSelectedToolsChange,
Expand Down Expand Up @@ -262,6 +264,7 @@ function ChatInputComponent({
<InputGroup
className={cn(
"bg-pure rounded-3xl border-[0.5px] border-border/70 shadow-xs has-[[data-slot=input-group-control]:focus-visible]:ring-0 has-[[data-slot=input-group-control]:focus-visible]:border-border",
dropActive && "border-dashed border-foreground/30 bg-muted/20 shadow-none",
)}
>
{attachments.length > 0 || uploadingAttachments.length > 0 ? (
Expand Down Expand Up @@ -379,11 +382,11 @@ function ChatInputComponent({
value={draft}
disabled={sending || loading || uploading}
readOnly={speechInput.active}
placeholder={speechInput.placeholder}
placeholder={dropActive ? tChat("attachments.dropTitle") : speechInput.placeholder}
rows={1}
style={{ fontFamily: "var(--font-chat)", fontWeight: "var(--font-chat-weight)" }}
className={cn(
"rounded-3xl min-h-12 overflow-y-auto px-5 pt-4 text-[15px] leading-6 placeholder:text-[15px] placeholder:font-[inherit] placeholder:leading-6",
"rounded-3xl min-h-12 overflow-y-auto px-5 pt-4 text-[15px] leading-6 placeholder:text-[inherit] placeholder:font-[inherit] placeholder:leading-[inherit]",
inputHeightClassName,
speechInput.active ? "placeholder:font-normal placeholder:text-muted-foreground" : "",
)}
Expand Down
1 change: 1 addition & 0 deletions frontend/i18n/messages/en-US/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@
"autoTruncatedDescription": "Kept the first {max} attachments and ignored {count} files over the limit.",
"uploadFailed": "Upload failed",
"uploadSignInRequired": "Sign in before uploading files.",
"dropTitle": "Drop files to attach",
"duplicateReused": "Duplicate file detected and reused",
"partialUploadFailed": "Some files failed to upload",
"retryFailedFiles": "Please retry the failed files.",
Expand Down
1 change: 1 addition & 0 deletions frontend/i18n/messages/zh-CN/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@
"autoTruncatedDescription": "最多保留前 {max} 个附件,已忽略 {count} 个超出上限的文件。",
"uploadFailed": "上传失败",
"uploadSignInRequired": "未登录,无法上传文件。",
"dropTitle": "松开以添加附件",
"duplicateReused": "检测到文件重复,已复用",
"partialUploadFailed": "部分文件上传失败",
"retryFailedFiles": "请重试失败的文件。",
Expand Down
Loading