📬 refactor: Improved Rendering and Localization for Drag & Drop Files (#9547)

* 📬 refactor: Improved Rendering and Localization for Drag & Drop Files

- Refactored DragDropOverlay to use memoization and props for active state management.
- Updated the overlay to always render, reducing mount/unmount overhead.
- Improved user experience with localized text for drag-and-drop instructions.
- Enhanced file handling logic in useDragHelpers for better performance and clarity.

* fix: agent data retrieval in drag helper
This commit is contained in:
Danny Avila 2025-09-10 14:27:57 -04:00 committed by GitHub
parent 1247207afe
commit 749f539dfc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 190 additions and 116 deletions

View file

@ -1,18 +1,52 @@
export default function DragDropOverlay() { import { memo } from 'react';
import { useLocalize } from '~/hooks';
interface DragDropOverlayProps {
isActive: boolean;
}
const DragDropOverlay = memo(({ isActive }: DragDropOverlayProps) => {
const localize = useLocalize();
return ( return (
<>
{/** Modal backdrop overlay */}
<div <div
className="bg-surface-primary/85 fixed inset-0 z-[9999] flex flex-col items-center justify-center className={`fixed inset-0 z-[9998] transition-opacity duration-200 ease-in-out ${
gap-2 text-text-primary isActive
backdrop-blur-[4px] transition-all duration-200 ? 'pointer-events-auto visible opacity-100'
ease-in-out animate-in fade-in : 'pointer-events-none invisible opacity-0'
zoom-in-95 hover:backdrop-blur-sm" } `}
style={{
/** Semi-transparent black overlay that works in both themes */
backgroundColor: 'rgba(0, 0, 0, 0.4)',
willChange: 'opacity',
}}
/>
{/** Main content overlay */}
<div
className={`fixed inset-0 z-[9999] flex flex-col items-center justify-center gap-2 text-text-primary transition-all duration-200 ease-in-out ${
isActive
? 'pointer-events-auto visible opacity-100'
: 'pointer-events-none invisible opacity-0'
} `}
style={{
transform: isActive ? 'scale(1)' : 'scale(0.95)',
/** Use will-change to hint browser about upcoming changes */
willChange: 'opacity, transform',
}}
> >
{/** Content area with subtle background */}
<div className="bg-surface-primary/95 flex flex-col items-center rounded-lg p-8 shadow-xl">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 132 108" viewBox="0 0 132 108"
fill="none" fill="none"
width="132" width="132"
height="108" height="108"
style={{
transform: isActive ? 'translateY(0)' : 'translateY(-10px)',
transition: 'transform 0.2s ease-in-out',
}}
> >
<g clipPath="url(#clip0_3605_64419)"> <g clipPath="url(#clip0_3605_64419)">
<path <path
@ -55,8 +89,14 @@ export default function DragDropOverlay() {
</clipPath> </clipPath>
</defs> </defs>
</svg> </svg>
<h3>Add anything</h3> <h3 className="mt-4 text-lg font-semibold">{localize('com_ui_upload_files')}</h3>
<h4>Drop any file here to add it to the conversation</h4> <h4 className="text-sm text-text-secondary">{localize('com_ui_drag_drop')}</h4>
</div> </div>
</div>
</>
); );
} });
DragDropOverlay.displayName = 'DragDropOverlay';
export default DragDropOverlay;

View file

@ -17,7 +17,8 @@ export default function DragDropWrapper({ children, className }: DragDropWrapper
return ( return (
<div ref={drop} className={cn('relative flex h-full w-full', className)}> <div ref={drop} className={cn('relative flex h-full w-full', className)}>
{children} {children}
{isActive && <DragDropOverlay />} {/** Always render overlay to avoid mount/unmount overhead */}
<DragDropOverlay isActive={isActive} />
<DragDropModal <DragDropModal
files={draggedFiles} files={draggedFiles}
isVisible={showModal} isVisible={showModal}

View file

@ -1,9 +1,10 @@
import { useState, useMemo } from 'react'; import { useState, useMemo, useCallback, useRef } from 'react';
import { useDrop } from 'react-dnd'; import { useDrop } from 'react-dnd';
import { NativeTypes } from 'react-dnd-html5-backend'; import { NativeTypes } from 'react-dnd-html5-backend';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useRecoilValue, useSetRecoilState } from 'recoil'; import { useRecoilValue, useSetRecoilState } from 'recoil';
import { import {
Tools,
QueryKeys, QueryKeys,
Constants, Constants,
EModelEndpoint, EModelEndpoint,
@ -15,7 +16,6 @@ import {
import type { DropTargetMonitor } from 'react-dnd'; import type { DropTargetMonitor } from 'react-dnd';
import type * as t from 'librechat-data-provider'; import type * as t from 'librechat-data-provider';
import store, { ephemeralAgentByConvoId } from '~/store'; import store, { ephemeralAgentByConvoId } from '~/store';
import { useAgentToolPermissions } from '~/hooks';
import useFileHandling from './useFileHandling'; import useFileHandling from './useFileHandling';
export default function useDragHelpers() { export default function useDragHelpers() {
@ -23,25 +23,10 @@ export default function useDragHelpers() {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [draggedFiles, setDraggedFiles] = useState<File[]>([]); const [draggedFiles, setDraggedFiles] = useState<File[]>([]);
const conversation = useRecoilValue(store.conversationByIndex(0)) || undefined; const conversation = useRecoilValue(store.conversationByIndex(0)) || undefined;
const agentId = conversation?.agent_id ?? '';
const { fileSearchAllowedByAgent, codeAllowedByAgent } = useAgentToolPermissions(agentId);
const setEphemeralAgent = useSetRecoilState( const setEphemeralAgent = useSetRecoilState(
ephemeralAgentByConvoId(conversation?.conversationId ?? Constants.NEW_CONVO), ephemeralAgentByConvoId(conversation?.conversationId ?? Constants.NEW_CONVO),
); );
const handleOptionSelect = (toolResource: EToolResources | undefined) => {
/** File search is not automatically enabled to simulate legacy behavior */
if (toolResource && toolResource !== EToolResources.file_search) {
setEphemeralAgent((prev) => ({
...prev,
[toolResource]: true,
}));
}
handleFiles(draggedFiles, toolResource);
setShowModal(false);
setDraggedFiles([]);
};
const isAssistants = useMemo( const isAssistants = useMemo(
() => isAssistantsEndpoint(conversation?.endpoint), () => isAssistantsEndpoint(conversation?.endpoint),
[conversation?.endpoint], [conversation?.endpoint],
@ -51,13 +36,33 @@ export default function useDragHelpers() {
overrideEndpoint: isAssistants ? undefined : EModelEndpoint.agents, overrideEndpoint: isAssistants ? undefined : EModelEndpoint.agents,
}); });
const [{ canDrop, isOver }, drop] = useDrop( const handleOptionSelect = useCallback(
() => ({ (toolResource: EToolResources | undefined) => {
accept: [NativeTypes.FILE], /** File search is not automatically enabled to simulate legacy behavior */
drop(item: { files: File[] }) { if (toolResource && toolResource !== EToolResources.file_search) {
console.log('drop', item.files); setEphemeralAgent((prev) => ({
...prev,
[toolResource]: true,
}));
}
handleFiles(draggedFiles, toolResource);
setShowModal(false);
setDraggedFiles([]);
},
[draggedFiles, handleFiles, setEphemeralAgent],
);
/** Use refs to avoid re-creating the drop handler */
const handleFilesRef = useRef(handleFiles);
const conversationRef = useRef(conversation);
handleFilesRef.current = handleFiles;
conversationRef.current = conversation;
const handleDrop = useCallback(
(item: { files: File[] }) => {
if (isAssistants) { if (isAssistants) {
handleFiles(item.files); handleFilesRef.current(item.files);
return; return;
} }
@ -68,6 +73,25 @@ export default function useDragHelpers() {
const codeEnabled = capabilities.includes(AgentCapabilities.execute_code) === true; const codeEnabled = capabilities.includes(AgentCapabilities.execute_code) === true;
const ocrEnabled = capabilities.includes(AgentCapabilities.ocr) === true; const ocrEnabled = capabilities.includes(AgentCapabilities.ocr) === true;
/** Get agent permissions at drop time */
const agentId = conversationRef.current?.agent_id;
let fileSearchAllowedByAgent = true;
let codeAllowedByAgent = true;
if (agentId && agentId !== Constants.EPHEMERAL_AGENT_ID) {
/** Agent data from cache */
const agent = queryClient.getQueryData<t.Agent>([QueryKeys.agent, agentId]);
if (agent) {
const agentTools = agent.tools as string[] | undefined;
fileSearchAllowedByAgent = agentTools?.includes(Tools.file_search) ?? false;
codeAllowedByAgent = agentTools?.includes(Tools.execute_code) ?? false;
} else {
/** If agent exists but not found, disallow */
fileSearchAllowedByAgent = false;
codeAllowedByAgent = false;
}
}
/** Determine if dragged files are all images (enables the base image option) */ /** Determine if dragged files are all images (enables the base image option) */
const allImages = item.files.every((f) => f.type?.startsWith('image/')); const allImages = item.files.every((f) => f.type?.startsWith('image/'));
@ -79,19 +103,28 @@ export default function useDragHelpers() {
if (!shouldShowModal) { if (!shouldShowModal) {
// Fallback: directly handle files without showing modal // Fallback: directly handle files without showing modal
handleFiles(item.files); handleFilesRef.current(item.files);
return; return;
} }
setDraggedFiles(item.files); setDraggedFiles(item.files);
setShowModal(true); setShowModal(true);
}, },
[isAssistants, queryClient],
);
const [{ canDrop, isOver }, drop] = useDrop(
() => ({
accept: [NativeTypes.FILE],
drop: handleDrop,
canDrop: () => true, canDrop: () => true,
collect: (monitor: DropTargetMonitor) => ({ collect: (monitor: DropTargetMonitor) => {
isOver: monitor.isOver(), /** Optimize collect to reduce re-renders */
canDrop: monitor.canDrop(), const isOver = monitor.isOver();
const canDrop = monitor.canDrop();
return { isOver, canDrop };
},
}), }),
}), [handleDrop],
[handleFiles],
); );
return { return {

View file

@ -848,7 +848,7 @@
"com_ui_download_backup": "Download Backup Codes", "com_ui_download_backup": "Download Backup Codes",
"com_ui_download_backup_tooltip": "Before you continue, download your backup codes. You will need them to regain access if you lose your authenticator device", "com_ui_download_backup_tooltip": "Before you continue, download your backup codes. You will need them to regain access if you lose your authenticator device",
"com_ui_download_error": "Error downloading file. The file may have been deleted.", "com_ui_download_error": "Error downloading file. The file may have been deleted.",
"com_ui_drag_drop": "something needs to go here. was empty", "com_ui_drag_drop": "Drop any file here to add it to the conversation",
"com_ui_dropdown_variables": "Dropdown variables:", "com_ui_dropdown_variables": "Dropdown variables:",
"com_ui_dropdown_variables_info": "Create custom dropdown menus for your prompts: `{{variable_name:option1|option2|option3}}`", "com_ui_dropdown_variables_info": "Create custom dropdown menus for your prompts: `{{variable_name:option1|option2|option3}}`",
"com_ui_duplicate": "Duplicate", "com_ui_duplicate": "Duplicate",