fix: ignore pasted @ for file mentions (#1649)
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
18744bf723
commit
b87de3c5b2
@@ -90,6 +90,7 @@ import {
|
|||||||
buildAttachmentCitationText,
|
buildAttachmentCitationText,
|
||||||
findAttachmentCitationRanges,
|
findAttachmentCitationRanges,
|
||||||
} from './attachmentCitations';
|
} from './attachmentCitations';
|
||||||
|
import { getFileMentionAutocompleteQuery, type FileMentionAutocompleteInputSource } from './fileMentionAutocompleteState';
|
||||||
import type { Part } from '@opencode-ai/sdk/v2/client';
|
import type { Part } from '@opencode-ai/sdk/v2/client';
|
||||||
|
|
||||||
const MAX_VISIBLE_TEXTAREA_LINES = 8;
|
const MAX_VISIBLE_TEXTAREA_LINES = 8;
|
||||||
@@ -128,6 +129,38 @@ const buildImagePasteInsertion = (pastedText: string, citationText: string): str
|
|||||||
return `${text}${/\s$/.test(text) ? '' : ' '}${citationText}`;
|
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 => {
|
const withInlineInsertionBoundaries = (content: string, before: string, after: string): string => {
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return content;
|
return content;
|
||||||
@@ -963,6 +996,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
const dragEnterCountRef = React.useRef(0);
|
const dragEnterCountRef = React.useRef(0);
|
||||||
const suppressNextFileDropTextInsertRef = React.useRef(false);
|
const suppressNextFileDropTextInsertRef = React.useRef(false);
|
||||||
const suppressNextFileDropTextInsertTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
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 pendingDroppedAbsolutePathsRef = React.useRef<string[]>([]);
|
||||||
const canAcceptDropRef = React.useRef(false);
|
const canAcceptDropRef = React.useRef(false);
|
||||||
const mentionRef = React.useRef<FileMentionHandle>(null);
|
const mentionRef = React.useRef<FileMentionHandle>(null);
|
||||||
@@ -2700,7 +2735,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
adjustTextareaHeight({ allowShrink });
|
adjustTextareaHeight({ allowShrink });
|
||||||
}, [adjustTextareaHeight, message, isMobile]);
|
}, [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') {
|
if (inputMode === 'shell') {
|
||||||
setShowCommandAutocomplete(false);
|
setShowCommandAutocomplete(false);
|
||||||
setShowFileMention(false);
|
setShowFileMention(false);
|
||||||
@@ -2765,19 +2805,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
|
|
||||||
setShowSnippetAutocomplete(false);
|
setShowSnippetAutocomplete(false);
|
||||||
|
|
||||||
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
|
const nextMentionQuery = getFileMentionAutocompleteQuery({ value, cursorPosition, inputSource, insertedText });
|
||||||
if (lastAtSymbol !== -1) {
|
if (nextMentionQuery === 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')) {
|
|
||||||
setMentionQuery(textAfterAt);
|
|
||||||
setShowFileMention(true);
|
|
||||||
} else {
|
|
||||||
setShowFileMention(false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setShowFileMention(false);
|
setShowFileMention(false);
|
||||||
|
} else {
|
||||||
|
setMentionQuery(nextMentionQuery);
|
||||||
|
setShowFileMention(true);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
inputMode,
|
inputMode,
|
||||||
@@ -2791,7 +2824,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
setSnippetQuery,
|
setSnippetQuery,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const insertTextAtSelection = React.useCallback((text: string) => {
|
const insertTextAtSelection = React.useCallback((
|
||||||
|
text: string,
|
||||||
|
inputSource: FileMentionAutocompleteInputSource = 'manual',
|
||||||
|
) => {
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2800,7 +2836,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
if (!textarea) {
|
if (!textarea) {
|
||||||
const nextValue = message + text;
|
const nextValue = message + text;
|
||||||
setMessage(nextValue);
|
setMessage(nextValue);
|
||||||
updateAutocompleteState(nextValue, nextValue.length);
|
updateAutocompleteState(nextValue, nextValue.length, inputSource, text);
|
||||||
requestAnimationFrame(() => adjustTextareaHeight());
|
requestAnimationFrame(() => adjustTextareaHeight());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2820,7 +2856,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
adjustTextareaHeight();
|
adjustTextareaHeight();
|
||||||
});
|
});
|
||||||
|
|
||||||
updateAutocompleteState(nextValue, cursorPosition);
|
updateAutocompleteState(nextValue, cursorPosition, inputSource, text);
|
||||||
}, [adjustTextareaHeight, message, updateAutocompleteState]);
|
}, [adjustTextareaHeight, message, updateAutocompleteState]);
|
||||||
|
|
||||||
const clearDropTextSuppression = React.useCallback(() => {
|
const clearDropTextSuppression = React.useCallback(() => {
|
||||||
@@ -2841,6 +2877,25 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
}, 700);
|
}, 700);
|
||||||
}, [clearDropTextSuppression]);
|
}, [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>) => {
|
const handleBeforeInput = React.useCallback((e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||||
if (!isVSCodeRuntime() || !suppressNextFileDropTextInsertRef.current) {
|
if (!isVSCodeRuntime() || !suppressNextFileDropTextInsertRef.current) {
|
||||||
return;
|
return;
|
||||||
@@ -2868,6 +2923,16 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
|
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
const cursorPosition = e.target.selectionStart ?? value.length;
|
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('!')) {
|
if (inputMode === 'normal' && value.startsWith('!')) {
|
||||||
const shellCommand = value.slice(1);
|
const shellCommand = value.slice(1);
|
||||||
@@ -2889,14 +2954,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
|
|
||||||
setMessage(value);
|
setMessage(value);
|
||||||
adjustTextareaHeight();
|
adjustTextareaHeight();
|
||||||
updateAutocompleteState(value, cursorPosition);
|
updateAutocompleteState(value, cursorPosition, inputSource, pastedInsertedText);
|
||||||
};
|
};
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
clearDropTextSuppression();
|
clearDropTextSuppression();
|
||||||
|
clearFileMentionPasteSuppression();
|
||||||
};
|
};
|
||||||
}, [clearDropTextSuppression]);
|
}, [clearDropTextSuppression, clearFileMentionPasteSuppression]);
|
||||||
|
|
||||||
const handlePaste = React.useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
const handlePaste = React.useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||||
// Pasting a URL over a selection wraps it as a markdown link:
|
// Pasting a URL over a selection wraps it as a markdown link:
|
||||||
@@ -2927,7 +2993,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
}
|
}
|
||||||
adjustTextareaHeight();
|
adjustTextareaHeight();
|
||||||
});
|
});
|
||||||
updateAutocompleteState(next, caret);
|
updateAutocompleteState(next, caret, getFileMentionInputSourceForInsertedText(url), url);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2951,17 +3017,23 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
});
|
});
|
||||||
|
|
||||||
const imageFiles = Array.from(fileMap.values());
|
const imageFiles = Array.from(fileMap.values());
|
||||||
|
const pastedText = e.clipboardData.getData('text');
|
||||||
if (imageFiles.length === 0) {
|
if (imageFiles.length === 0) {
|
||||||
|
if (pastedText.includes('@')) {
|
||||||
|
markFileMentionPasteSuppression();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentSessionId && !newSessionDraftOpen) {
|
if (!currentSessionId && !newSessionDraftOpen) {
|
||||||
|
if (pastedText.includes('@')) {
|
||||||
|
markFileMentionPasteSuppression();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const pastedText = e.clipboardData.getData('text');
|
|
||||||
const assignedFilenames = assignImageAttachmentFilenames(
|
const assignedFilenames = assignImageAttachmentFilenames(
|
||||||
imageFiles,
|
imageFiles,
|
||||||
[
|
[
|
||||||
@@ -2979,7 +3051,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
message.slice(selectionEnd),
|
message.slice(selectionEnd),
|
||||||
);
|
);
|
||||||
|
|
||||||
insertTextAtSelection(insertionText);
|
insertTextAtSelection(insertionText, getFileMentionInputSourceForInsertedText(insertionText));
|
||||||
|
|
||||||
for (let index = 0; index < imageFiles.length; index += 1) {
|
for (let index = 0; index < imageFiles.length; index += 1) {
|
||||||
const filename = assignedFilenames[index];
|
const filename = assignedFilenames[index];
|
||||||
@@ -2994,7 +3066,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
pendingPastedAttachmentFilenamesRef.current.delete(filename);
|
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 }) => {
|
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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user