mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-17 14:17:34 +00:00
feat: add highlight for file mentions in chat input
This commit is contained in:
@@ -93,6 +93,10 @@ function unescapeWithMathProtection(text) {
|
|||||||
return processedText;
|
return processedText;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value) {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
// Small wrapper to keep markdown behavior consistent in one place
|
// Small wrapper to keep markdown behavior consistent in one place
|
||||||
const Markdown = ({ children, className }) => {
|
const Markdown = ({ children, className }) => {
|
||||||
const content = normalizeInlineCodeFences(String(children ?? ''));
|
const content = normalizeInlineCodeFences(String(children ?? ''));
|
||||||
@@ -1855,6 +1859,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
const messagesEndRef = useRef(null);
|
const messagesEndRef = useRef(null);
|
||||||
const textareaRef = useRef(null);
|
const textareaRef = useRef(null);
|
||||||
const inputContainerRef = useRef(null);
|
const inputContainerRef = useRef(null);
|
||||||
|
const inputHighlightRef = useRef(null);
|
||||||
const scrollContainerRef = useRef(null);
|
const scrollContainerRef = useRef(null);
|
||||||
const isLoadingSessionRef = useRef(false); // Track session loading to prevent multiple scrolls
|
const isLoadingSessionRef = useRef(false); // Track session loading to prevent multiple scrolls
|
||||||
const isLoadingMoreRef = useRef(false);
|
const isLoadingMoreRef = useRef(false);
|
||||||
@@ -1867,6 +1872,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
const [debouncedInput, setDebouncedInput] = useState('');
|
const [debouncedInput, setDebouncedInput] = useState('');
|
||||||
const [showFileDropdown, setShowFileDropdown] = useState(false);
|
const [showFileDropdown, setShowFileDropdown] = useState(false);
|
||||||
const [fileList, setFileList] = useState([]);
|
const [fileList, setFileList] = useState([]);
|
||||||
|
const [fileMentions, setFileMentions] = useState([]);
|
||||||
const [filteredFiles, setFilteredFiles] = useState([]);
|
const [filteredFiles, setFilteredFiles] = useState([]);
|
||||||
const [selectedFileIndex, setSelectedFileIndex] = useState(-1);
|
const [selectedFileIndex, setSelectedFileIndex] = useState(-1);
|
||||||
const [cursorPosition, setCursorPosition] = useState(0);
|
const [cursorPosition, setCursorPosition] = useState(0);
|
||||||
@@ -3962,6 +3968,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Handle @ symbol detection and file filtering
|
// Handle @ symbol detection and file filtering
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const textBeforeCursor = input.slice(0, cursorPosition);
|
const textBeforeCursor = input.slice(0, cursorPosition);
|
||||||
@@ -3992,6 +3999,43 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
}
|
}
|
||||||
}, [input, cursorPosition, fileList]);
|
}, [input, cursorPosition, fileList]);
|
||||||
|
|
||||||
|
const activeFileMentions = useMemo(() => {
|
||||||
|
if (!input || fileMentions.length === 0) return [];
|
||||||
|
return fileMentions.filter(path => input.includes(path));
|
||||||
|
}, [fileMentions, input]);
|
||||||
|
|
||||||
|
const sortedFileMentions = useMemo(() => {
|
||||||
|
if (activeFileMentions.length === 0) return [];
|
||||||
|
const unique = Array.from(new Set(activeFileMentions));
|
||||||
|
return unique.sort((a, b) => b.length - a.length);
|
||||||
|
}, [activeFileMentions]);
|
||||||
|
|
||||||
|
const fileMentionRegex = useMemo(() => {
|
||||||
|
if (sortedFileMentions.length === 0) return null;
|
||||||
|
const pattern = sortedFileMentions.map(escapeRegExp).join('|');
|
||||||
|
return new RegExp(`(${pattern})`, 'g');
|
||||||
|
}, [sortedFileMentions]);
|
||||||
|
|
||||||
|
const fileMentionSet = useMemo(() => new Set(sortedFileMentions), [sortedFileMentions]);
|
||||||
|
|
||||||
|
const renderInputWithMentions = useCallback((text) => {
|
||||||
|
if (!text) return '';
|
||||||
|
if (!fileMentionRegex) return text;
|
||||||
|
const parts = text.split(fileMentionRegex);
|
||||||
|
return parts.map((part, index) => (
|
||||||
|
fileMentionSet.has(part) ? (
|
||||||
|
<span
|
||||||
|
key={`mention-${index}`}
|
||||||
|
className="bg-blue-200/70 -ml-0.5 dark:bg-blue-300/40 px-0.5 rounded-md box-decoration-clone text-transparent"
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span key={`text-${index}`}>{part}</span>
|
||||||
|
)
|
||||||
|
));
|
||||||
|
}, [fileMentionRegex, fileMentionSet]);
|
||||||
|
|
||||||
// Debounced input handling
|
// Debounced input handling
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -4566,8 +4610,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
const spaceIndex = textAfterAtQuery.indexOf(' ');
|
const spaceIndex = textAfterAtQuery.indexOf(' ');
|
||||||
const textAfterQuery = spaceIndex !== -1 ? textAfterAtQuery.slice(spaceIndex) : '';
|
const textAfterQuery = spaceIndex !== -1 ? textAfterAtQuery.slice(spaceIndex) : '';
|
||||||
|
|
||||||
const newInput = textBeforeAt + '@' + file.path + ' ' + textAfterQuery;
|
const newInput = textBeforeAt + file.path + ' ' + textAfterQuery;
|
||||||
const newCursorPos = textBeforeAt.length + 1 + file.path.length + 1;
|
const newCursorPos = textBeforeAt.length + file.path.length + 1;
|
||||||
|
|
||||||
// Immediately ensure focus is maintained
|
// Immediately ensure focus is maintained
|
||||||
if (textareaRef.current && !textareaRef.current.matches(':focus')) {
|
if (textareaRef.current && !textareaRef.current.matches(':focus')) {
|
||||||
@@ -4577,6 +4621,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
// Update input and cursor position
|
// Update input and cursor position
|
||||||
setInput(newInput);
|
setInput(newInput);
|
||||||
setCursorPosition(newCursorPos);
|
setCursorPosition(newCursorPos);
|
||||||
|
setFileMentions(prev => (prev.includes(file.path) ? prev : [...prev, file.path]));
|
||||||
|
|
||||||
// Hide dropdown
|
// Hide dropdown
|
||||||
setShowFileDropdown(false);
|
setShowFileDropdown(false);
|
||||||
@@ -4670,6 +4715,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const syncInputOverlayScroll = useCallback((target) => {
|
||||||
|
if (!inputHighlightRef.current || !target) return;
|
||||||
|
inputHighlightRef.current.scrollTop = target.scrollTop;
|
||||||
|
inputHighlightRef.current.scrollLeft = target.scrollLeft;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleTextareaClick = (e) => {
|
const handleTextareaClick = (e) => {
|
||||||
setCursorPosition(e.target.selectionStart);
|
setCursorPosition(e.target.selectionStart);
|
||||||
};
|
};
|
||||||
@@ -5370,6 +5421,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
|
|
||||||
<div {...getRootProps()} className={`relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-600 focus-within:ring-2 focus-within:ring-blue-500 dark:focus-within:ring-blue-500 focus-within:border-blue-500 transition-all duration-200 overflow-hidden ${isTextareaExpanded ? 'chat-input-expanded' : ''}`}>
|
<div {...getRootProps()} className={`relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-600 focus-within:ring-2 focus-within:ring-blue-500 dark:focus-within:ring-blue-500 focus-within:border-blue-500 transition-all duration-200 overflow-hidden ${isTextareaExpanded ? 'chat-input-expanded' : ''}`}>
|
||||||
<input {...getInputProps()} />
|
<input {...getInputProps()} />
|
||||||
|
<div
|
||||||
|
ref={inputHighlightRef}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 pointer-events-none overflow-hidden rounded-2xl"
|
||||||
|
>
|
||||||
|
<div className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 text-transparent text-sm sm:text-base leading-[21px] sm:leading-6 whitespace-pre-wrap break-words">
|
||||||
|
{renderInputWithMentions(input)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative z-10">
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={input}
|
value={input}
|
||||||
@@ -5377,6 +5438,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
onClick={handleTextareaClick}
|
onClick={handleTextareaClick}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
|
onScroll={(e) => syncInputOverlayScroll(e.target)}
|
||||||
onFocus={() => setIsInputFocused(true)}
|
onFocus={() => setIsInputFocused(true)}
|
||||||
onBlur={() => setIsInputFocused(false)}
|
onBlur={() => setIsInputFocused(false)}
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
@@ -5384,6 +5446,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
e.target.style.height = 'auto';
|
e.target.style.height = 'auto';
|
||||||
e.target.style.height = e.target.scrollHeight + 'px';
|
e.target.style.height = e.target.scrollHeight + 'px';
|
||||||
setCursorPosition(e.target.selectionStart);
|
setCursorPosition(e.target.selectionStart);
|
||||||
|
syncInputOverlayScroll(e.target);
|
||||||
|
|
||||||
// Check if textarea is expanded (more than 2 lines worth of height)
|
// Check if textarea is expanded (more than 2 lines worth of height)
|
||||||
const lineHeight = parseInt(window.getComputedStyle(e.target).lineHeight);
|
const lineHeight = parseInt(window.getComputedStyle(e.target).lineHeight);
|
||||||
@@ -5452,6 +5515,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
? "Ctrl+Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"
|
? "Ctrl+Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"
|
||||||
: "Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"}
|
: "Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user