Feat/add ide opened file to chat context (#1106)
* feat(chat): add active editor file context and related functionality * feat(chat): improve active editor file context handling and update translations * feat(i18n): standardize quotation marks in file attachment messages * update Korean translation for image removal action in file attachment * feat(chat): refine active editor file handling and optimize broadcast logic * fix(chat): stabilize VS Code editor context chips --------- Signed-off-by: David Saz <david.saz.g@gmail.com> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
7fc22bc69b
commit
5475ef2db3
@@ -29,7 +29,7 @@ import { useUserMessageHistory } from '@/sync/sync-context';
|
||||
import { useInlineCommentDraftStore, type InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||
import { appendInlineComments } from '@/lib/messages/inlineComments';
|
||||
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||||
import { AttachedFilesList } from './FileAttachment';
|
||||
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
|
||||
import { QueuedMessageChips } from './QueuedMessageChips';
|
||||
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
|
||||
import { CommandAutocomplete, type CommandAutocompleteHandle, type CommandInfo } from './CommandAutocomplete';
|
||||
@@ -3677,95 +3677,101 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
: undefined}
|
||||
/>
|
||||
)}
|
||||
<div className={cn("relative overflow-hidden", isDesktopExpanded && 'flex flex-1 min-h-0 flex-col')}>
|
||||
{highlightedComposerContent && (
|
||||
<div
|
||||
aria-hidden
|
||||
<div className={cn("overflow-hidden", isDesktopExpanded && 'flex flex-1 min-h-0 flex-col')}>
|
||||
<div className="flex items-center gap-1 px-3 pt-1 flex-wrap relative z-10">
|
||||
<AttachedVSCodeFileChips />
|
||||
<ActiveEditorFileSuggestion />
|
||||
</div>
|
||||
<div className={cn("relative overflow-hidden", isDesktopExpanded && 'flex flex-1 min-h-0 flex-col')}>
|
||||
{highlightedComposerContent && (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 z-0 whitespace-pre-wrap break-words px-3 rounded-b-none',
|
||||
isDesktopExpanded
|
||||
? 'h-full min-h-0 py-4'
|
||||
: isMobile
|
||||
? 'py-2.5'
|
||||
: 'pt-4 pb-2',
|
||||
inputMode === 'shell' ? 'font-mono' : 'typography-markdown md:typography-ui-label',
|
||||
)}
|
||||
ref={composerHighlightRef}
|
||||
>
|
||||
{highlightedComposerContent.map((part, index) => (
|
||||
<span
|
||||
key={`${index}-${part.text.length}`}
|
||||
className={
|
||||
part.mentionKind === 'file'
|
||||
? 'text-[var(--status-info)]'
|
||||
: part.mentionKind === 'agent'
|
||||
? 'text-[var(--status-success)]'
|
||||
: 'text-foreground'
|
||||
}
|
||||
>
|
||||
{part.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Textarea
|
||||
simple
|
||||
ref={textareaRef}
|
||||
data-chat-input="true"
|
||||
value={message}
|
||||
onChange={handleTextChange}
|
||||
onBeforeInput={handleBeforeInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDropCapture={handleDropCapture}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={handleDragEnd}
|
||||
onKeyUp={updateAutocompleteOverlayPosition}
|
||||
onClick={updateAutocompleteOverlayPosition}
|
||||
onScroll={(event) => {
|
||||
updateAutocompleteOverlayPosition();
|
||||
const scrollTop = event.currentTarget.scrollTop;
|
||||
if (composerHighlightRef.current) {
|
||||
composerHighlightRef.current.style.transform = `translateY(-${scrollTop}px)`;
|
||||
}
|
||||
}}
|
||||
onSelect={(e) => {
|
||||
const ta = e.currentTarget;
|
||||
cursorPosRef.current = ta.selectionStart ?? 0;
|
||||
updateAutocompleteOverlayPosition();
|
||||
}}
|
||||
placeholder={currentSessionId || newSessionDraftOpen
|
||||
? inputMode === 'shell'
|
||||
? t('chat.chatInput.placeholder.shell')
|
||||
: t('chat.chatInput.placeholder.chat')
|
||||
: t('chat.chatInput.placeholder.selectSession')}
|
||||
disabled={!currentSessionId && !newSessionDraftOpen}
|
||||
autoCorrect={isMobile ? "on" : "off"}
|
||||
autoCapitalize={isMobile ? "sentences" : "off"}
|
||||
spellCheck={isMobile || inputSpellcheckEnabled}
|
||||
fillContainer={isDesktopExpanded}
|
||||
outerClassName={cn('ring-0 bg-transparent shadow-none hover:bg-transparent focus-within:ring-0', isDesktopExpanded && 'flex-1 min-h-0')}
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 z-0 whitespace-pre-wrap break-words px-3 rounded-b-none',
|
||||
'min-h-[52px] resize-none border-0 px-3 rounded-b-none appearance-none hover:border-transparent bg-transparent relative z-10',
|
||||
isDesktopExpanded
|
||||
? 'h-full min-h-0 py-4'
|
||||
: isMobile
|
||||
? 'py-2.5'
|
||||
: 'pt-4 pb-2',
|
||||
inputMode === 'shell' ? 'font-mono' : 'typography-markdown md:typography-ui-label',
|
||||
inputMode === 'shell' && 'font-mono',
|
||||
highlightedComposerContent && 'text-transparent caret-[var(--surface-foreground)]',
|
||||
)}
|
||||
ref={composerHighlightRef}
|
||||
>
|
||||
{highlightedComposerContent.map((part, index) => (
|
||||
<span
|
||||
key={`${index}-${part.text.length}`}
|
||||
className={
|
||||
part.mentionKind === 'file'
|
||||
? 'text-[var(--status-info)]'
|
||||
: part.mentionKind === 'agent'
|
||||
? 'text-[var(--status-success)]'
|
||||
: 'text-foreground'
|
||||
}
|
||||
>
|
||||
{part.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Textarea
|
||||
simple
|
||||
ref={textareaRef}
|
||||
data-chat-input="true"
|
||||
value={message}
|
||||
onChange={handleTextChange}
|
||||
onBeforeInput={handleBeforeInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDropCapture={handleDropCapture}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={handleDragEnd}
|
||||
onKeyUp={updateAutocompleteOverlayPosition}
|
||||
onClick={updateAutocompleteOverlayPosition}
|
||||
onScroll={(event) => {
|
||||
updateAutocompleteOverlayPosition();
|
||||
const scrollTop = event.currentTarget.scrollTop;
|
||||
if (composerHighlightRef.current) {
|
||||
composerHighlightRef.current.style.transform = `translateY(-${scrollTop}px)`;
|
||||
}
|
||||
}}
|
||||
onSelect={(e) => {
|
||||
const ta = e.currentTarget;
|
||||
cursorPosRef.current = ta.selectionStart ?? 0;
|
||||
updateAutocompleteOverlayPosition();
|
||||
}}
|
||||
placeholder={currentSessionId || newSessionDraftOpen
|
||||
? inputMode === 'shell'
|
||||
? t('chat.chatInput.placeholder.shell')
|
||||
: t('chat.chatInput.placeholder.chat')
|
||||
: t('chat.chatInput.placeholder.selectSession')}
|
||||
disabled={!currentSessionId && !newSessionDraftOpen}
|
||||
autoCorrect={isMobile ? "on" : "off"}
|
||||
autoCapitalize={isMobile ? "sentences" : "off"}
|
||||
spellCheck={isMobile || inputSpellcheckEnabled}
|
||||
fillContainer={isDesktopExpanded}
|
||||
outerClassName={cn('ring-0 bg-transparent shadow-none hover:bg-transparent focus-within:ring-0', isDesktopExpanded && 'flex-1 min-h-0')}
|
||||
className={cn(
|
||||
'min-h-[52px] resize-none border-0 px-3 rounded-b-none appearance-none hover:border-transparent bg-transparent relative z-10',
|
||||
isDesktopExpanded
|
||||
? 'h-full min-h-0 py-4'
|
||||
: isMobile
|
||||
? 'py-2.5'
|
||||
: 'pt-4 pb-2',
|
||||
inputMode === 'shell' && 'font-mono',
|
||||
highlightedComposerContent && 'text-transparent caret-[var(--surface-foreground)]',
|
||||
)}
|
||||
style={{
|
||||
flex: isDesktopExpanded ? '1 1 auto' : 'none',
|
||||
height: !isDesktopExpanded && textareaSize ? `${textareaSize.height}px` : undefined,
|
||||
maxHeight: !isDesktopExpanded && textareaSize ? `${textareaSize.maxHeight}px` : undefined,
|
||||
borderTopLeftRadius: chatInputRadius,
|
||||
borderTopRightRadius: chatInputRadius,
|
||||
}}
|
||||
rows={1}
|
||||
/>
|
||||
style={{
|
||||
flex: isDesktopExpanded ? '1 1 auto' : 'none',
|
||||
height: !isDesktopExpanded && textareaSize ? `${textareaSize.height}px` : undefined,
|
||||
maxHeight: !isDesktopExpanded && textareaSize ? `${textareaSize.maxHeight}px` : undefined,
|
||||
borderTopLeftRadius: chatInputRadius,
|
||||
borderTopRightRadius: chatInputRadius,
|
||||
}}
|
||||
rows={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useRef, memo } from 'react';
|
||||
import { RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiGithubLine, RiGitPullRequestLine } from '@remixicon/react';
|
||||
import { RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiGithubLine, RiGitPullRequestLine, RiAddLine, RiPushpin2Line } from '@remixicon/react';
|
||||
import { useInputStore } from '@/sync/input-store';
|
||||
import type { AttachedFile } from '@/sync/session-ui-store';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
@@ -202,13 +202,7 @@ const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => {
|
||||
|
||||
ImagePreview.displayName = 'ImagePreview';
|
||||
|
||||
interface FileChipProps {
|
||||
file: AttachedFile;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
const FileChip = memo(({ file, onRemove }: FileChipProps) => {
|
||||
const { t } = useI18n();
|
||||
const useFileDetails = (file: AttachedFile) => {
|
||||
const getFileExtension = (filename: string): string => {
|
||||
const parts = filename.split('.');
|
||||
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
|
||||
@@ -228,9 +222,21 @@ const FileChip = memo(({ file, onRemove }: FileChipProps) => {
|
||||
return filename || path;
|
||||
};
|
||||
|
||||
const displayName = extractFilename(file.filename);
|
||||
const fileSize = formatFileSize(file.size);
|
||||
const extension = getFileExtension(file.filename);
|
||||
return {
|
||||
displayName: extractFilename(file.filename),
|
||||
fileSize: formatFileSize(file.size),
|
||||
extension: getFileExtension(file.filename),
|
||||
};
|
||||
};
|
||||
|
||||
interface FileChipProps {
|
||||
file: AttachedFile;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
const FileChip = memo(({ file, onRemove }: FileChipProps) => {
|
||||
const { t } = useI18n();
|
||||
const { displayName, fileSize, extension } = useFileDetails(file);
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -265,11 +271,78 @@ const FileChip = memo(({ file, onRemove }: FileChipProps) => {
|
||||
|
||||
FileChip.displayName = 'FileChip';
|
||||
|
||||
const VSCodeFileChip = memo(({ file, onRemove }: FileChipProps) => {
|
||||
const { t } = useI18n();
|
||||
const { displayName, extension } = useFileDetails(file);
|
||||
|
||||
// Detect selection-style attachments: ends with ":N" or ":N-M"
|
||||
const isSelectionAttachment = /:\d+(?:-\d+)?$/.test(displayName);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
// Prevent click from bubbling if clicking the remove button
|
||||
if ((e.target as HTMLElement).closest('[data-remove-button]')) {
|
||||
return;
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs pr-1 rounded-sm border border-solid bg-transparent text-foreground not-italic hover:opacity-90 transition-colors text-left"
|
||||
style={{ borderColor: 'var(--syntax-punctuation)' }}
|
||||
title={file.vscodePath}
|
||||
>
|
||||
<span
|
||||
data-remove-button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
className="flex items-center justify-center h-5 w-5 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
|
||||
aria-label={t('chat.fileAttachment.activeEditor.remove')}
|
||||
title={t('chat.fileAttachment.activeEditor.remove')}
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
<FileTypeIcon filePath={file.filename} extension={extension} className="h-4 w-4" />
|
||||
<span className={cn('text-foreground', isSelectionAttachment ? 'whitespace-nowrap' : 'truncate max-w-[200px]')}>
|
||||
{displayName}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
VSCodeFileChip.displayName = 'VSCodeFileChip';
|
||||
|
||||
export const AttachedVSCodeFileChips = memo(() => {
|
||||
const attachedFiles = useInputStore((state) => state.attachedFiles);
|
||||
const removeAttachedFile = useInputStore((state) => state.removeAttachedFile);
|
||||
|
||||
const vscodeFiles = attachedFiles.filter((file) => file.source === 'vscode');
|
||||
|
||||
if (vscodeFiles.length === 0) return null;
|
||||
|
||||
const images = vscodeFiles.filter((f) => f.mimeType.startsWith('image/'));
|
||||
const otherFiles = vscodeFiles.filter((f) => !f.mimeType.startsWith('image/'));
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{images.map((file) => (
|
||||
<ImagePreview key={file.id} file={file} onRemove={() => removeAttachedFile(file.id)} />
|
||||
))}
|
||||
{otherFiles.map((file) => (
|
||||
<VSCodeFileChip key={file.id} file={file} onRemove={() => removeAttachedFile(file.id)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
AttachedVSCodeFileChips.displayName = 'AttachedVSCodeFileChips';
|
||||
|
||||
export const AttachedFilesList = memo(() => {
|
||||
const attachedFiles = useInputStore((state) => state.attachedFiles);
|
||||
const removeAttachedFile = useInputStore((state) => state.removeAttachedFile);
|
||||
|
||||
const localFiles = attachedFiles.filter((file) => file.source !== 'server');
|
||||
const localFiles = attachedFiles.filter((file) => file.source !== 'server' && file.source !== 'vscode');
|
||||
|
||||
if (localFiles.length === 0) return null;
|
||||
|
||||
@@ -309,6 +382,105 @@ export const AttachedFilesList = memo(() => {
|
||||
|
||||
AttachedFilesList.displayName = 'AttachedFilesList';
|
||||
|
||||
export const ActiveEditorFileSuggestion = memo(() => {
|
||||
const { t } = useI18n();
|
||||
const activeEditorFile = useInputStore((s) => s.activeEditorFile);
|
||||
const attachedFiles = useInputStore((s) => s.attachedFiles)
|
||||
const addVSCodeFileAttachment = useInputStore((s) => s.addVSCodeFileAttachment)
|
||||
const addVSCodeSelectionAttachment = useInputStore((s) => s.addVSCodeSelectionAttachment)
|
||||
const isVSCodeRuntime = useIsVSCodeRuntime();
|
||||
|
||||
if (!isVSCodeRuntime || !activeEditorFile) return null;
|
||||
|
||||
const { filePath, fileName, relativePath, selection, fileSize } = activeEditorFile;
|
||||
|
||||
// Normalize to forward slashes for comparison
|
||||
const isFileAttached = attachedFiles.some(
|
||||
(f) => f.source === 'vscode' && f.vscodeSource === 'file' && (f.vscodePath || '') === filePath
|
||||
)
|
||||
|
||||
// Compute selection label using a compact range (single line shown as "N" not "N-N")
|
||||
let selectionRange = ''
|
||||
if (selection) {
|
||||
selectionRange = selection.startLine === selection.endLine
|
||||
? `${selection.startLine}`
|
||||
: `${selection.startLine}-${selection.endLine}`
|
||||
}
|
||||
const selectionLabel = selection ? `${fileName}:${selectionRange}` : ''
|
||||
const isSelectionAttached = !!selectionLabel && attachedFiles.some(
|
||||
(f) => f.source === 'vscode' && f.vscodeSource === 'selection' && f.filename === selectionLabel && f.vscodePath === filePath
|
||||
)
|
||||
|
||||
// Nothing to show — file is already attached and there's no (or already-attached) selection
|
||||
if (isFileAttached && (!selection || isSelectionAttached)) return null;
|
||||
|
||||
const ext = fileName.split('.').pop() || '';
|
||||
// Always show only the filename in the suggestion UI
|
||||
const displayName = fileName;
|
||||
|
||||
const handleAddFile = () => {
|
||||
addVSCodeFileAttachment(filePath, fileName, fileSize);
|
||||
};
|
||||
|
||||
const handlePinSelection = async () => {
|
||||
if (!selection) return;
|
||||
const blob = new Blob([selection.text], { type: 'text/plain' });
|
||||
const file = new File([blob], selectionLabel, { type: 'text/plain' });
|
||||
await addVSCodeSelectionAttachment(filePath, file);
|
||||
};
|
||||
|
||||
// If there is a selection, prefer showing the pin-selection UI only.
|
||||
const showSelectionPin = !!selection && !isSelectionAttached;
|
||||
const showFileAdd = !showSelectionPin && !isFileAttached;
|
||||
|
||||
if (!showSelectionPin && !showFileAdd) return null;
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center">
|
||||
{showSelectionPin && (
|
||||
<div
|
||||
className="inline-flex items-center gap-1 text-xs pr-1 rounded-sm italic text-muted-foreground border border-dashed bg-transparent"
|
||||
style={{ borderColor: 'var(--syntax-punctuation)' }}
|
||||
title={relativePath}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title={t('chat.fileAttachment.activeEditor.pinSelection')}
|
||||
aria-label={t('chat.fileAttachment.activeEditor.pinSelection')}
|
||||
onClick={() => { void handlePinSelection(); }}
|
||||
className="flex items-center justify-center h-5 w-5 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
|
||||
>
|
||||
<RiPushpin2Line className="h-4 w-4" />
|
||||
</button>
|
||||
<FileTypeIcon filePath={fileName} extension={ext} className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="text-xs whitespace-nowrap">{`${displayName}:${selectionRange}`}</span>
|
||||
</div>
|
||||
)}
|
||||
{showFileAdd && (
|
||||
<div
|
||||
className="inline-flex items-center gap-1 text-xs pr-1 rounded-sm italic text-muted-foreground border border-dashed bg-transparent"
|
||||
style={{ borderColor: 'var(--syntax-punctuation)' }}
|
||||
title={relativePath}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title={t('chat.fileAttachment.activeEditor.addFile', { name: displayName })}
|
||||
aria-label={t('chat.fileAttachment.activeEditor.addFile', { name: displayName })}
|
||||
onClick={handleAddFile}
|
||||
className="flex items-center justify-center h-5 w-5 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
|
||||
>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
</button>
|
||||
<FileTypeIcon filePath={fileName} extension={ext} className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="text-xs truncate max-w-[220px]">{displayName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ActiveEditorFileSuggestion.displayName = 'ActiveEditorFileSuggestion';
|
||||
|
||||
interface FilePart {
|
||||
type: string;
|
||||
mime?: string;
|
||||
@@ -423,6 +595,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{otherFiles.map((file, index) => {
|
||||
const fileName = resolveDisplayName(file);
|
||||
const ext = fileName.split('.').pop() || '';
|
||||
const sizeText = formatFileSize(file.size);
|
||||
const githubLinkKind = getGitHubLinkKind(file);
|
||||
return (
|
||||
@@ -450,7 +623,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
{file.mime?.includes('pdf') ? (
|
||||
<RiFilePdfLine className="text-muted-foreground h-3.5 w-3.5" />
|
||||
) : (
|
||||
<RiFileLine className="text-muted-foreground h-3.5 w-3.5" />
|
||||
<FileTypeIcon filePath={fileName} extension={ext} className="text-muted-foreground h-3.5 w-3.5" />
|
||||
)}
|
||||
<div className="overflow-hidden max-w-[140px]">
|
||||
<span className="truncate block" title={fileName}>{fileName}</span>
|
||||
|
||||
Reference in New Issue
Block a user