From bc6cb91954d191e26583957797ab47b568ac5e31 Mon Sep 17 00:00:00 2001 From: youfch <97601975+youfch@users.noreply.github.com> Date: Wed, 22 Apr 2026 01:31:14 +0800 Subject: [PATCH] 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 --- packages/ui/src/components/chat/ChatInput.tsx | 150 ++++++++++++++++-- .../chat/FileMentionAutocomplete.tsx | 125 +++++++++++++-- .../components/layout/SidebarFilesTree.tsx | 36 ++++- 3 files changed, 281 insertions(+), 30 deletions(-) diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index b0cf079e..91efbde4 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -655,6 +655,37 @@ const saveStoredDraft = (sessionId: string | null, draft: string): void => { } }; +// Per-session confirmed mentions key — tracks which @mentions are confirmed (blue) vs plain text +const getConfirmedMentionsKey = (sessionId: string | null): string => + `openchamber_chat_confirmed_mentions_${sessionId ?? 'new'}`; + +const saveConfirmedMentions = (sessionId: string | null, mentions: Set): void => { + try { + if (mentions.size > 0) { + localStorage.setItem(getConfirmedMentionsKey(sessionId), JSON.stringify([...mentions])); + } else { + localStorage.removeItem(getConfirmedMentionsKey(sessionId)); + } + } catch { + // Ignore localStorage errors + } +}; + +const loadConfirmedMentions = (sessionId: string | null): Set => { + try { + const raw = localStorage.getItem(getConfirmedMentionsKey(sessionId)); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return new Set(parsed.filter((v): v is string => typeof v === 'string')); + } + } + } catch { + // Ignore localStorage errors + } + return new Set(); +}; + const ChatInputComponent: React.FC = ({ onOpenSettings, scrollToBottom }) => { // Track if we restored a draft on mount (for text selection) const initialDraftRef = React.useRef(null); @@ -670,8 +701,14 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } return draft; }); + // Restore confirmed mentions from localStorage on mount + const confirmedMentionsRef = React.useRef>(loadConfirmedMentions(initialSessionIdRef.current)); + // Helper: check if a mention path looks like a file/folder (has path separators, extension, or was explicitly confirmed) + const isConfirmedFilePath = (text: string): boolean => + text.includes('/') || text.includes('\\') || text.includes('.') || confirmedMentionsRef.current.has(text); const [inputMode, setInputMode] = React.useState<'normal' | 'shell'>('normal'); const [isDragging, setIsDragging] = React.useState(false); + const [isInternalDrag, setIsInternalDrag] = React.useState(false); const [showFileMention, setShowFileMention] = React.useState(false); const [mentionQuery, setMentionQuery] = React.useState(''); const [showCommandAutocomplete, setShowCommandAutocomplete] = React.useState(false); @@ -686,8 +723,10 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const [historyIndex, setHistoryIndex] = React.useState(-1); // -1 = not browsing, 0+ = index from most recent const [draftMessage, setDraftMessage] = React.useState(''); // Preserves input when entering history mode const textareaRef = React.useRef(null); + const cursorPosRef = React.useRef(0); const previousMessageLengthRef = React.useRef(message.length); const dropZoneRef = React.useRef(null); + const dragEnterCountRef = React.useRef(0); const suppressNextFileDropTextInsertRef = React.useRef(false); const suppressNextFileDropTextInsertTimeoutRef = React.useRef | null>(null); const pendingDroppedAbsolutePathsRef = React.useRef([]); @@ -780,7 +819,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo if (knownAgentNames.has(mentionPath.toLowerCase())) { return true; } - if (mentionPath.includes('/') || mentionPath.includes('\\') || mentionPath.includes('.')) { + if (isConfirmedFilePath(mentionPath)) { return true; } } @@ -809,7 +848,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const isFileMention = isBoundary && mention.length > 0 && !knownAgentNames.has(mention.toLowerCase()) - && (mention.includes('/') || mention.includes('\\') || mention.includes('.')); + && isConfirmedFilePath(mention); if (start > lastIndex) { parts.push({ text: message.slice(lastIndex, start), mentionKind: 'none' }); @@ -873,7 +912,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo continue; } - const looksLikeFilePath = mentionPath.includes('/') || mentionPath.includes('\\') || mentionPath.includes('.'); + const looksLikeFilePath = isConfirmedFilePath(mentionPath); if (!looksLikeFilePath) { continue; } @@ -992,6 +1031,15 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } saveStoredDraft(sessionId, draft); + // Only persist confirmed mentions that are actually present in the draft text + const activeMentions = new Set(); + for (const mention of confirmedMentionsRef.current) { + if (draft.includes(`@${mention}`)) { + activeMentions.add(mention); + } + } + confirmedMentionsRef.current = activeMentions; + saveConfirmedMentions(sessionId, activeMentions); lastPersistedDraftRef.current.set(key, draft); }, []); @@ -1044,6 +1092,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // Restore draft for the session we're entering const newDraft = getStoredDraft(currentSessionId); setMessage(newDraft); + confirmedMentionsRef.current = loadConfirmedMentions(currentSessionId); if (newDraft) { requestAnimationFrame(() => { textareaRef.current?.select(); @@ -1052,6 +1101,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } else { // Persist disabled: clear input without saving setMessage(''); + confirmedMentionsRef.current = new Set(); } } }, [clearPendingDraftPersist, currentSessionId, persistChatDraft, persistDraftImmediately]); @@ -1263,6 +1313,9 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo }); // Clear input and attachments + // Note: confirmedMentionsRef is NOT cleared here because queued messages + // are processed later in handleSubmit which reads the ref via extractInlineFileMentions. + // The ref is cleared in handleSubmit after all queued messages are sent. setMessage(''); if (attachmentsToQueue.length > 0) { clearAttachedFiles(); @@ -1426,8 +1479,10 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } if (!queuedOnly) { setMessage(''); + confirmedMentionsRef.current.clear(); // Clear per-session draft on submit saveStoredDraft(currentSessionId, ''); + saveConfirmedMentions(currentSessionId, confirmedMentionsRef.current); // Reset message history navigation state setHistoryIndex(-1); setDraftMessage(''); @@ -1599,6 +1654,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const selectionStart = textarea?.selectionStart ?? message.length; const selectionEnd = textarea?.selectionEnd ?? message.length; const hasCollapsedSelection = selectionStart === selectionEnd; + const knownAgentNames = new Set(agents.map((agent) => agent.name.toLowerCase())); if (hasCollapsedSelection) { const probeIndex = e.key === 'Backspace' ? selectionStart - 1 : selectionStart; @@ -1614,10 +1670,13 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } const token = message.slice(tokenStart, tokenEnd); + const mentionContent = token.slice(1); const looksLikeFileMention = FILE_MENTION_TOKEN.test(token) - && (token.includes('/') || token.includes('\\') || token.includes('.')); + && !knownAgentNames.has(mentionContent.toLowerCase()) + && isConfirmedFilePath(mentionContent); if (looksLikeFileMention) { + confirmedMentionsRef.current.delete(mentionContent); const removeUntil = message[tokenEnd] === ' ' ? tokenEnd + 1 : tokenEnd; const nextMessage = `${message.slice(0, tokenStart)}${message.slice(removeUntil)}`; e.preventDefault(); @@ -2266,6 +2325,8 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo ? file.relativePath.trim() : (toProjectRelativeMentionPath(file.path) || file.name); + confirmedMentionsRef.current.add(mentionPath); + if (lastAtSymbol !== -1) { const newMessage = message.substring(0, lastAtSymbol) + @@ -2443,6 +2504,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo if (lowerTypes.includes('files')) return true; if (lowerTypes.includes('text/uri-list')) return true; if (lowerTypes.includes('codefiles')) return true; + if (lowerTypes.includes('application/x-openchamber-file-path')) return true; if (lowerTypes.some((type) => type.includes('vnd.code.tree'))) return true; } @@ -2542,12 +2604,17 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const addVSCodeDroppedUrisAsMentions = React.useCallback((uris: string[]) => { if (uris.length === 0) return; - const mentions = Array.from(new Set(uris + const paths = uris .map((entry) => normalizeDroppedPath(entry)) .map((entry) => toProjectRelativeMentionPath(entry)) .map((entry) => entry.trim().replace(/^\.\//, '')) - .filter((entry) => entry.length > 0) - .map((entry) => `@${entry}`))); + .filter((entry) => entry.length > 0); + + for (const p of paths) { + confirmedMentionsRef.current.add(p); + } + + const mentions = Array.from(new Set(paths.map((entry) => `@${entry}`))); if (mentions.length === 0) { return; @@ -2563,6 +2630,11 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } e.preventDefault(); e.stopPropagation(); + dragEnterCountRef.current++; + const isInternal = e.dataTransfer.types?.includes('application/x-openchamber-file-path') ?? false; + if (isInternal !== isInternalDrag) { + setIsInternalDrag(isInternal); + } if ((currentSessionId || newSessionDraftOpen) && !isDragging) { setIsDragging(true); } @@ -2583,13 +2655,24 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); - if (e.currentTarget === e.target) { + dragEnterCountRef.current--; + if (dragEnterCountRef.current <= 0) { + dragEnterCountRef.current = 0; setIsDragging(false); + setIsInternalDrag(false); clearDropTextSuppression(); } }; + const handleDragEnd = () => { + dragEnterCountRef.current = 0; + setIsDragging(false); + setIsInternalDrag(false); + clearDropTextSuppression(); + }; + const handleDrop = async (e: React.DragEvent) => { + dragEnterCountRef.current = 0; const draggedFiles = hasDraggedFiles(e.dataTransfer); if (!draggedFiles) { clearDropTextSuppression(); @@ -2601,6 +2684,37 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo if (!currentSessionId && !newSessionDraftOpen) return; + // Internal drag: file tree → chat input (relative path as @mention) + const internalPath = e.dataTransfer.getData('application/x-openchamber-file-path'); + if (internalPath && internalPath !== '.') { + confirmedMentionsRef.current.add(internalPath); + const mention = `@${internalPath}`; + const textarea = textareaRef.current; + const currentMessage = messageRef.current; + if (textarea) { + const pos = textarea.selectionStart ?? cursorPosRef.current; + const end = textarea.selectionEnd ?? pos; + const before = currentMessage.slice(0, pos); + const after = currentMessage.slice(end); + const needSpaceBefore = before.length > 0 && !/\s$/.test(before); + const needSpaceAfter = after.length > 0 && !/^\s/.test(after); + const insert = `${needSpaceBefore ? ' ' : ''}${mention}${needSpaceAfter ? ' ' : ''}`; + const nextMessage = `${before}${insert}${after}`; + setMessage(nextMessage); + requestAnimationFrame(() => { + const cursorPos = pos + insert.length; + textarea.selectionStart = cursorPos; + textarea.selectionEnd = cursorPos; + cursorPosRef.current = cursorPos; + textarea.focus(); + }); + } else { + setMessage((prev) => appendInlineText(prev, mention)); + } + clearDropTextSuppression(); + return; + } + const files = collectDroppedFiles(e.dataTransfer); if (files.length === 0 && isVSCodeRuntime()) { @@ -2631,15 +2745,15 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo }; const handleDropCapture = (e: React.DragEvent) => { - if (!isVSCodeRuntime()) { - return; - } if (!hasDraggedFiles(e.dataTransfer)) { return; } - suppressNextFileDropTextInsertRef.current = true; - scheduleDropTextSuppressionExpiry(); + // Prevent native textarea drop text insertion for all runtimes e.preventDefault(); + if (isVSCodeRuntime()) { + suppressNextFileDropTextInsertRef.current = true; + scheduleDropTextSuppressionExpiry(); + } }; // Tauri desktop: handle native file drops via onDragDropEvent @@ -3379,6 +3493,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} + onDragEnd={handleDragEnd} > {isDragging && (
@@ -3394,7 +3509,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
-

Drop files here to attach

+

{isInternalDrag ? 'Drop to insert as mention' : 'Drop files here to attach'}

)} @@ -3507,6 +3622,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo onDragOver={handleDragOver} onDropCapture={handleDropCapture} onDrop={handleDrop} + onDragEnd={handleDragEnd} onPointerDownCapture={handleTextareaPointerDownCapture} onKeyUp={updateAutocompleteOverlayPosition} onClick={updateAutocompleteOverlayPosition} @@ -3517,7 +3633,11 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo composerHighlightRef.current.style.transform = `translateY(-${scrollTop}px)`; } }} - onSelect={updateAutocompleteOverlayPosition} + onSelect={(e) => { + const ta = e.currentTarget; + cursorPosRef.current = ta.selectionStart ?? 0; + updateAutocompleteOverlayPosition(); + }} placeholder={currentSessionId || newSessionDraftOpen ? inputMode === 'shell' ? "Enter shell command..." diff --git a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx index 79ab1a2a..f2c596ca 100644 --- a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx +++ b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx @@ -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([]); + const [directories, setDirectories] = React.useState([]); const [agents, setAgents] = React.useState([]); 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>({}); @@ -143,7 +145,6 @@ export const FileMentionAutocomplete = React.forwardRef { if (!currentDirectory) { setFiles([]); - setLoading(false); return; } @@ -155,11 +156,11 @@ export const FileMentionAutocomplete = React.forwardRef { 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 { itemRefs.current[selectedIndex]?.scrollIntoView({ @@ -261,7 +325,7 @@ export const FileMentionAutocomplete = React.forwardRef { const labelNode = labelRefs.current[selectedIndex]; @@ -305,7 +369,7 @@ export const FileMentionAutocomplete = React.forwardRef { const ext = file.extension?.toLowerCase(); @@ -444,11 +516,38 @@ export const FileMentionAutocomplete = React.forwardRef )} - {visibleAgents.length > 0 && (recentFiles.length > 0 || files.length > 0) && ( + {visibleAgents.length > 0 && (directories.length > 0 || recentFiles.length > 0 || files.length > 0) && ( +
+ )} + {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 ( +
{ 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)} + > + + + {displayPath} + +
+ ); + })} + {directories.length > 0 && (recentFiles.length > 0 || files.length > 0) && (
)} {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 )} {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 ); })} - {files.length === 0 && recentFiles.length === 0 && visibleAgents.length === 0 && ( + {files.length === 0 && directories.length === 0 && recentFiles.length === 0 && visibleAgents.length === 0 && (
No matches found
diff --git a/packages/ui/src/components/layout/SidebarFilesTree.tsx b/packages/ui/src/components/layout/SidebarFilesTree.tsx index 24086af5..e7db89c3 100644 --- a/packages/ui/src/components/layout/SidebarFilesTree.tsx +++ b/packages/ui/src/components/layout/SidebarFilesTree.tsx @@ -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 = ({ node, + root, isExpanded, isActive, status, @@ -179,6 +193,13 @@ const FileRow: React.FC = ({ 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 (
= ({ 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 = () => { )} {