fix: ignore pasted @ for file mentions (#1649)

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Nicolas Charpentier
2026-06-23 14:07:09 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 18744bf723
commit b87de3c5b2
3 changed files with 200 additions and 22 deletions
+94 -22
View File
@@ -90,6 +90,7 @@ import {
buildAttachmentCitationText,
findAttachmentCitationRanges,
} from './attachmentCitations';
import { getFileMentionAutocompleteQuery, type FileMentionAutocompleteInputSource } from './fileMentionAutocompleteState';
import type { Part } from '@opencode-ai/sdk/v2/client';
const MAX_VISIBLE_TEXTAREA_LINES = 8;
@@ -128,6 +129,38 @@ const buildImagePasteInsertion = (pastedText: string, citationText: string): str
return `${text}${/\s$/.test(text) ? '' : ' '}${citationText}`;
};
const getInsertedTextFromChange = (previousValue: string, nextValue: string): string => {
if (previousValue === nextValue) {
return '';
}
let prefixLength = 0;
while (
prefixLength < previousValue.length
&& prefixLength < nextValue.length
&& previousValue[prefixLength] === nextValue[prefixLength]
) {
prefixLength += 1;
}
let previousSuffix = previousValue.length;
let nextSuffix = nextValue.length;
while (
previousSuffix > prefixLength
&& nextSuffix > prefixLength
&& previousValue[previousSuffix - 1] === nextValue[nextSuffix - 1]
) {
previousSuffix -= 1;
nextSuffix -= 1;
}
return nextValue.slice(prefixLength, nextSuffix);
};
const getFileMentionInputSourceForInsertedText = (insertedText: string): FileMentionAutocompleteInputSource => (
insertedText.includes('@') ? 'paste' : 'manual'
);
const withInlineInsertionBoundaries = (content: string, before: string, after: string): string => {
if (!content) {
return content;
@@ -963,6 +996,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const dragEnterCountRef = React.useRef(0);
const suppressNextFileDropTextInsertRef = React.useRef(false);
const suppressNextFileDropTextInsertTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const suppressNextFileMentionPasteRef = React.useRef(false);
const suppressNextFileMentionPasteTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingDroppedAbsolutePathsRef = React.useRef<string[]>([]);
const canAcceptDropRef = React.useRef(false);
const mentionRef = React.useRef<FileMentionHandle>(null);
@@ -2700,7 +2735,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
adjustTextareaHeight({ allowShrink });
}, [adjustTextareaHeight, message, isMobile]);
const updateAutocompleteState = React.useCallback((value: string, cursorPosition: number) => {
const updateAutocompleteState = React.useCallback((
value: string,
cursorPosition: number,
inputSource: FileMentionAutocompleteInputSource = 'manual',
insertedText?: string,
) => {
if (inputMode === 'shell') {
setShowCommandAutocomplete(false);
setShowFileMention(false);
@@ -2765,19 +2805,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
setShowSnippetAutocomplete(false);
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
if (lastAtSymbol !== -1) {
const charBefore = lastAtSymbol > 0 ? textBeforeCursor[lastAtSymbol - 1] : null;
const textAfterAt = textBeforeCursor.substring(lastAtSymbol + 1);
const isWordBoundary = !charBefore || /\s/.test(charBefore);
if (isWordBoundary && !textAfterAt.includes(' ') && !textAfterAt.includes('\n')) {
setMentionQuery(textAfterAt);
setShowFileMention(true);
} else {
setShowFileMention(false);
}
} else {
const nextMentionQuery = getFileMentionAutocompleteQuery({ value, cursorPosition, inputSource, insertedText });
if (nextMentionQuery === null) {
setShowFileMention(false);
} else {
setMentionQuery(nextMentionQuery);
setShowFileMention(true);
}
}, [
inputMode,
@@ -2791,7 +2824,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
setSnippetQuery,
]);
const insertTextAtSelection = React.useCallback((text: string) => {
const insertTextAtSelection = React.useCallback((
text: string,
inputSource: FileMentionAutocompleteInputSource = 'manual',
) => {
if (!text) {
return;
}
@@ -2800,7 +2836,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
if (!textarea) {
const nextValue = message + text;
setMessage(nextValue);
updateAutocompleteState(nextValue, nextValue.length);
updateAutocompleteState(nextValue, nextValue.length, inputSource, text);
requestAnimationFrame(() => adjustTextareaHeight());
return;
}
@@ -2820,7 +2856,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
adjustTextareaHeight();
});
updateAutocompleteState(nextValue, cursorPosition);
updateAutocompleteState(nextValue, cursorPosition, inputSource, text);
}, [adjustTextareaHeight, message, updateAutocompleteState]);
const clearDropTextSuppression = React.useCallback(() => {
@@ -2841,6 +2877,25 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}, 700);
}, [clearDropTextSuppression]);
const clearFileMentionPasteSuppression = React.useCallback(() => {
suppressNextFileMentionPasteRef.current = false;
if (suppressNextFileMentionPasteTimeoutRef.current) {
clearTimeout(suppressNextFileMentionPasteTimeoutRef.current);
suppressNextFileMentionPasteTimeoutRef.current = null;
}
}, []);
const markFileMentionPasteSuppression = React.useCallback(() => {
suppressNextFileMentionPasteRef.current = true;
if (suppressNextFileMentionPasteTimeoutRef.current) {
clearTimeout(suppressNextFileMentionPasteTimeoutRef.current);
}
suppressNextFileMentionPasteTimeoutRef.current = setTimeout(() => {
suppressNextFileMentionPasteRef.current = false;
suppressNextFileMentionPasteTimeoutRef.current = null;
}, 700);
}, []);
const handleBeforeInput = React.useCallback((e: React.FormEvent<HTMLTextAreaElement>) => {
if (!isVSCodeRuntime() || !suppressNextFileDropTextInsertRef.current) {
return;
@@ -2868,6 +2923,16 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const value = e.target.value;
const cursorPosition = e.target.selectionStart ?? value.length;
const pastedInsertedText = nativeInputEvent?.inputType?.startsWith('insertFromPaste')
? getInsertedTextFromChange(messageRef.current, value)
: '';
const isPasteInput = pastedInsertedText.includes('@') || suppressNextFileMentionPasteRef.current;
if (suppressNextFileMentionPasteRef.current) {
clearFileMentionPasteSuppression();
}
const inputSource: FileMentionAutocompleteInputSource = isPasteInput
? 'paste'
: 'manual';
if (inputMode === 'normal' && value.startsWith('!')) {
const shellCommand = value.slice(1);
@@ -2889,14 +2954,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
setMessage(value);
adjustTextareaHeight();
updateAutocompleteState(value, cursorPosition);
updateAutocompleteState(value, cursorPosition, inputSource, pastedInsertedText);
};
React.useEffect(() => {
return () => {
clearDropTextSuppression();
clearFileMentionPasteSuppression();
};
}, [clearDropTextSuppression]);
}, [clearDropTextSuppression, clearFileMentionPasteSuppression]);
const handlePaste = React.useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
// Pasting a URL over a selection wraps it as a markdown link:
@@ -2927,7 +2993,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
adjustTextareaHeight();
});
updateAutocompleteState(next, caret);
updateAutocompleteState(next, caret, getFileMentionInputSourceForInsertedText(url), url);
return;
}
}
@@ -2951,17 +3017,23 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
});
const imageFiles = Array.from(fileMap.values());
const pastedText = e.clipboardData.getData('text');
if (imageFiles.length === 0) {
if (pastedText.includes('@')) {
markFileMentionPasteSuppression();
}
return;
}
if (!currentSessionId && !newSessionDraftOpen) {
if (pastedText.includes('@')) {
markFileMentionPasteSuppression();
}
return;
}
e.preventDefault();
const pastedText = e.clipboardData.getData('text');
const assignedFilenames = assignImageAttachmentFilenames(
imageFiles,
[
@@ -2979,7 +3051,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
message.slice(selectionEnd),
);
insertTextAtSelection(insertionText);
insertTextAtSelection(insertionText, getFileMentionInputSourceForInsertedText(insertionText));
for (let index = 0; index < imageFiles.length; index += 1) {
const filename = assignedFilenames[index];
@@ -2994,7 +3066,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
pendingPastedAttachmentFilenamesRef.current.delete(filename);
}
}
}, [addAttachedFile, attachedFiles, adjustTextareaHeight, currentSessionId, inputMode, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]);
}, [addAttachedFile, attachedFiles, adjustTextareaHeight, currentSessionId, inputMode, markFileMentionPasteSuppression, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]);
const handleFileSelect = (file: { name: string; path: string; relativePath?: string }) => {
@@ -0,0 +1,74 @@
import { describe, expect, test } from 'bun:test';
import { getFileMentionAutocompleteQuery } from '../fileMentionAutocompleteState';
describe('getFileMentionAutocompleteQuery', () => {
test('opens file mention autocomplete for manually typed boundary @ text', () => {
expect(getFileMentionAutocompleteQuery({
value: '@config',
cursorPosition: '@config'.length,
inputSource: 'manual',
})).toBe('config');
expect(getFileMentionAutocompleteQuery({
value: 'check @main.ts',
cursorPosition: 'check @main.ts'.length,
inputSource: 'manual',
})).toBe('main.ts');
expect(getFileMentionAutocompleteQuery({
value: 'check @docs',
cursorPosition: 'check @docs'.length,
})).toBe('docs');
});
test('does not open file mention autocomplete when pasted text contains @', () => {
const pastedValues = [
'@config',
'@/path/to/file',
'Use @main.ts',
];
for (const value of pastedValues) {
expect(getFileMentionAutocompleteQuery({
value,
cursorPosition: value.length,
inputSource: 'paste',
insertedText: value,
})).toBeNull();
}
});
test('does not open file mention autocomplete for pasted package and email text', () => {
const pastedValues = [
'user@email.com',
'npx @scope/pkg@latest',
];
for (const value of pastedValues) {
expect(getFileMentionAutocompleteQuery({
value,
cursorPosition: value.length,
inputSource: 'paste',
insertedText: value,
})).toBeNull();
}
});
test('keeps autocomplete open when pasting a query fragment after a manually typed @', () => {
expect(getFileMentionAutocompleteQuery({
value: '@config',
cursorPosition: '@config'.length,
inputSource: 'paste',
insertedText: 'config',
})).toBe('config');
});
test('uses current value when paste source lacks inserted text context', () => {
expect(getFileMentionAutocompleteQuery({
value: '@config',
cursorPosition: '@config'.length,
inputSource: 'paste',
})).toBe('config');
});
});
@@ -0,0 +1,32 @@
export type FileMentionAutocompleteInputSource = 'manual' | 'paste';
export const getFileMentionAutocompleteQuery = ({
value,
cursorPosition,
inputSource = 'manual',
insertedText,
}: {
value: string;
cursorPosition: number;
inputSource?: FileMentionAutocompleteInputSource;
insertedText?: string;
}): string | null => {
if (inputSource === 'paste' && insertedText?.includes('@')) {
return null;
}
const textBeforeCursor = value.substring(0, cursorPosition);
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
if (lastAtSymbol === -1) {
return null;
}
const charBefore = lastAtSymbol > 0 ? textBeforeCursor[lastAtSymbol - 1] : null;
const textAfterAt = textBeforeCursor.substring(lastAtSymbol + 1);
const isWordBoundary = !charBefore || /\s/.test(charBefore);
if (!isWordBoundary || textAfterAt.includes(' ') || textAfterAt.includes('\n')) {
return null;
}
return textAfterAt;
};