Chat input: drag-drop files and folders from file tree with @folder autocomplete (#963)

* feat(chat): drag-drop files/folders from file tree into chat input

Features:
- Support drag-and-drop files and folders from file tree into chat input
- Support @folder autocomplete for file/folder mentions in chat input
- Add drag support to file tree nodes for dropping into chat input
- Persist confirmed mentions to localStorage across sessions

Fixes:
- Fix drag state getting stuck with drag enter counter and onDragEnd cleanup
- Fix pendingSearchRef counter leak on query cancellation
- Fix confirmed mentions highlight/delete for folder paths
- Fix clear confirmed mentions on send and autocomplete loading state
- Guard drag-start against empty and root-relative paths
- Reset loading state when pendingSearchRef reaches zero

* fix(chat): address Greptile PR review findings

- P1: move confirmedMentionsRef.clear() out of handleQueueMessage to
  prevent bare-name mentions from being lost when queued messages are
  sent via handleSubmit
- P2: distinguish internal file-tree drag from external file drag in
  overlay text (Drop to insert as mention vs Drop files here to attach)
- P2: add directories to marquee overflow effect dependency array

* fix(chat): prune stale confirmed mentions

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
youfch
2026-04-21 20:31:14 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 36b177dc81
commit bc6cb91954
3 changed files with 281 additions and 30 deletions
@@ -1,5 +1,5 @@
import React from 'react';
import { RiCodeLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiRefreshLine } from '@remixicon/react';
import { RiCodeLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiFolder3Fill, RiRefreshLine } from '@remixicon/react';
import { cn, truncatePathMiddle } from '@/lib/utils';
import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { useConfigStore } from '@/stores/useConfigStore';
@@ -70,8 +70,10 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
const showHidden = useDirectoryShowHidden();
const showGitignored = useFilesViewShowGitignored();
const [files, setFiles] = React.useState<FileInfo[]>([]);
const [directories, setDirectories] = React.useState<FileInfo[]>([]);
const [agents, setAgents] = React.useState<AgentInfo[]>([]);
const [loading, setLoading] = React.useState(false);
const pendingSearchRef = React.useRef(0);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const [marqueeWidth, setMarqueeWidth] = React.useState(360);
const [overflowMap, setOverflowMap] = React.useState<Record<number, boolean>>({});
@@ -143,7 +145,6 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
React.useEffect(() => {
if (!currentDirectory) {
setFiles([]);
setLoading(false);
return;
}
@@ -155,11 +156,11 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
if (!normalizedQueryLower) {
setFiles([]);
setLoading(false);
return;
}
let cancelled = false;
pendingSearchRef.current++;
setLoading(true);
searchFiles(currentDirectory, normalizedQueryLower, 80, {
@@ -182,15 +183,78 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
})
.finally(() => {
if (!cancelled) {
setLoading(false);
pendingSearchRef.current--;
if (pendingSearchRef.current <= 0) {
pendingSearchRef.current = 0;
setLoading(false);
}
}
});
return () => {
cancelled = true;
pendingSearchRef.current = Math.max(0, pendingSearchRef.current - 1);
if (pendingSearchRef.current <= 0) {
setLoading(false);
}
};
}, [currentDirectory, debouncedQuery, recentFiles, searchFiles, showHidden, showGitignored]);
React.useEffect(() => {
if (!currentDirectory) {
setDirectories([]);
return;
}
const normalizedQuery = (debouncedQuery ?? '').trim();
const normalizedQueryLower = normalizedQuery
.replace(/^\.\//, '')
.replace(/^\/+/, '')
.toLowerCase();
if (!normalizedQueryLower) {
setDirectories([]);
return;
}
let cancelled = false;
pendingSearchRef.current++;
setLoading(true);
searchFiles(currentDirectory, normalizedQueryLower, 20, {
includeHidden: showHidden,
respectGitignore: !showGitignored,
type: 'directory',
})
.then((hits) => {
if (!cancelled) {
setDirectories(hits.slice(0, 10));
}
})
.catch(() => {
if (!cancelled) {
setDirectories([]);
}
})
.finally(() => {
if (!cancelled) {
pendingSearchRef.current--;
if (pendingSearchRef.current <= 0) {
pendingSearchRef.current = 0;
setLoading(false);
}
}
});
return () => {
cancelled = true;
pendingSearchRef.current = Math.max(0, pendingSearchRef.current - 1);
if (pendingSearchRef.current <= 0) {
setLoading(false);
}
};
}, [currentDirectory, debouncedQuery, searchFiles, showHidden, showGitignored]);
React.useEffect(() => {
const visibleAgents = getVisibleAgents();
const normalizedQuery = (searchQuery ?? '').trim().toLowerCase();
@@ -214,7 +278,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
setSelectedIndex(0);
setOverflowMap({});
setMarqueeDurations({});
}, [files, recentFiles.length, visibleAgents.length]);
}, [files, directories, recentFiles.length, visibleAgents.length]);
React.useEffect(() => {
itemRefs.current[selectedIndex]?.scrollIntoView({
@@ -261,7 +325,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
}
window.removeEventListener('resize', updateOverflow);
};
}, [files]);
}, [files, directories]);
React.useEffect(() => {
const labelNode = labelRefs.current[selectedIndex];
@@ -305,7 +369,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
return;
}
const total = visibleAgents.length + recentFiles.length + files.length;
const total = visibleAgents.length + directories.length + recentFiles.length + files.length;
if (total === 0) {
return;
}
@@ -329,7 +393,15 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
}
return;
}
const fileIndex = safeIndex - visibleAgents.length;
const dirIndex = safeIndex - visibleAgents.length;
if (dirIndex < directories.length) {
const dir = directories[dirIndex];
if (dir) {
handleFileSelect(dir);
}
return;
}
const fileIndex = dirIndex - directories.length;
const selectedFile = fileIndex < recentFiles.length
? recentFiles[fileIndex]
: files[fileIndex - recentFiles.length];
@@ -338,7 +410,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
}
}
}
}), [files, recentFiles, visibleAgents, selectedIndex, onClose, handleFileSelect, handleAgentPick]);
}), [files, directories, recentFiles, visibleAgents, selectedIndex, onClose, handleFileSelect, handleAgentPick]);
const getFileIcon = (file: FileInfo) => {
const ext = file.extension?.toLowerCase();
@@ -444,11 +516,38 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
Type to search more agents
</div>
)}
{visibleAgents.length > 0 && (recentFiles.length > 0 || files.length > 0) && (
{visibleAgents.length > 0 && (directories.length > 0 || recentFiles.length > 0 || files.length > 0) && (
<div className="my-1 border-t border-border/60" />
)}
{directories.map((dir, index) => {
const rowIndex = visibleAgents.length + index;
const relativePath = dir.relativePath || dir.name;
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
const isSelected = selectedIndex === rowIndex;
return (
<div
key={`dir-${dir.path}`}
ref={(el) => { itemRefs.current[rowIndex] = el; }}
className={cn(
"flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg",
isSelected && "bg-interactive-selection"
)}
onClick={() => handleFileSelect(dir)}
onMouseEnter={() => setSelectedIndex(rowIndex)}
>
<RiFolder3Fill className="h-3.5 w-3.5 text-primary/60" />
<span className="flex-1 min-w-0 truncate" aria-label={relativePath}>
{displayPath}
</span>
</div>
);
})}
{directories.length > 0 && (recentFiles.length > 0 || files.length > 0) && (
<div className="my-1 border-t border-border/60" />
)}
{recentFiles.map((file, index) => {
const rowIndex = visibleAgents.length + index;
const rowIndex = visibleAgents.length + directories.length + index;
const relativePath = file.relativePath || file.name;
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
const isSelected = selectedIndex === rowIndex;
@@ -500,7 +599,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
<div className="my-1 border-t border-border/60" />
)}
{files.map((file, index) => {
const rowIndex = visibleAgents.length + recentFiles.length + index;
const rowIndex = visibleAgents.length + directories.length + recentFiles.length + index;
const relativePath = file.relativePath || file.name;
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
const isSelected = selectedIndex === rowIndex;
@@ -553,7 +652,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
</React.Fragment>
);
})}
{files.length === 0 && recentFiles.length === 0 && visibleAgents.length === 0 && (
{files.length === 0 && directories.length === 0 && recentFiles.length === 0 && visibleAgents.length === 0 && (
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
No matches found
</div>