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
@@ -85,6 +85,18 @@ const normalizePath = (value: string): string => {
return normalized;
};
const getRelativePath = (root: string, path: string): string => {
const normalizedPath = normalizePath(path);
const normalizedRoot = normalizePath(root).replace(/\/+$/, '');
if (normalizedPath === normalizedRoot) {
return '.';
}
if (!normalizedRoot || !normalizedPath.startsWith(`${normalizedRoot}/`)) {
return normalizedPath;
}
return normalizedPath.slice(normalizedRoot.length + 1);
};
const isAbsolutePath = (value: string): boolean => {
return value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:\//.test(value);
};
@@ -122,6 +134,7 @@ const FileStatusDot: React.FC<{ status: FileStatus }> = ({ status }) => {
interface FileRowProps {
node: FileNode;
root: string;
isExpanded: boolean;
isActive: boolean;
status?: FileStatus | null;
@@ -144,6 +157,7 @@ interface FileRowProps {
const FileRow: React.FC<FileRowProps> = ({
node,
root,
isExpanded,
isActive,
status,
@@ -179,6 +193,13 @@ const FileRow: React.FC<FileRowProps> = ({
setContextMenuPath(node.path);
}, [node.path, setContextMenuPath]);
const handleDragStart = React.useCallback((e: React.DragEvent) => {
const path = getRelativePath(root, node.path);
if (!path || path === '.') return;
e.dataTransfer.setData('application/x-openchamber-file-path', path);
e.dataTransfer.effectAllowed = 'copy';
}, [node.path, root]);
return (
<div
className="group relative flex items-center"
@@ -188,9 +209,12 @@ const FileRow: React.FC<FileRowProps> = ({
type="button"
onClick={handleInteraction}
onContextMenu={handleContextMenu}
draggable
onDragStart={handleDragStart}
className={cn(
'flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-foreground transition-colors pr-8 select-none',
isActive ? 'bg-interactive-selection/70' : 'hover:bg-interactive-hover/40'
isActive ? 'bg-interactive-selection/70' : 'hover:bg-interactive-hover/40',
'cursor-grab active:cursor-grabbing'
)}
>
{isDir ? (
@@ -755,6 +779,7 @@ export const SidebarFilesTree: React.FC = () => {
)}
<FileRow
node={node}
root={root}
isExpanded={isExpanded}
isActive={isActive}
status={!isDir ? getFileStatus(node.path) : undefined}
@@ -848,8 +873,15 @@ export const SidebarFilesTree: React.FC = () => {
<button
type="button"
onClick={() => handleOpenFile(node)}
draggable
onDragStart={(e) => {
const path = node.relativePath || getRelativePath(root ?? '', node.path);
if (!path || path === '.') return;
e.dataTransfer.setData('application/x-openchamber-file-path', path);
e.dataTransfer.effectAllowed = 'copy';
}}
className={cn(
'flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-foreground transition-colors',
'flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-foreground transition-colors cursor-grab active:cursor-grabbing',
isActive ? 'bg-interactive-selection/70' : 'hover:bg-interactive-hover/40'
)}
title={node.path}