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 { useInlineCommentDraftStore, type InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||||
import { appendInlineComments } from '@/lib/messages/inlineComments';
|
import { appendInlineComments } from '@/lib/messages/inlineComments';
|
||||||
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||||||
import { AttachedFilesList } from './FileAttachment';
|
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
|
||||||
import { QueuedMessageChips } from './QueuedMessageChips';
|
import { QueuedMessageChips } from './QueuedMessageChips';
|
||||||
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
|
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
|
||||||
import { CommandAutocomplete, type CommandAutocompleteHandle, type CommandInfo } from './CommandAutocomplete';
|
import { CommandAutocomplete, type CommandAutocompleteHandle, type CommandInfo } from './CommandAutocomplete';
|
||||||
@@ -3677,95 +3677,101 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
: undefined}
|
: undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className={cn("relative overflow-hidden", isDesktopExpanded && 'flex flex-1 min-h-0 flex-col')}>
|
<div className={cn("overflow-hidden", isDesktopExpanded && 'flex flex-1 min-h-0 flex-col')}>
|
||||||
{highlightedComposerContent && (
|
<div className="flex items-center gap-1 px-3 pt-1 flex-wrap relative z-10">
|
||||||
<div
|
<AttachedVSCodeFileChips />
|
||||||
aria-hidden
|
<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(
|
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
|
isDesktopExpanded
|
||||||
? 'h-full min-h-0 py-4'
|
? 'h-full min-h-0 py-4'
|
||||||
: isMobile
|
: isMobile
|
||||||
? 'py-2.5'
|
? 'py-2.5'
|
||||||
: 'pt-4 pb-2',
|
: '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}
|
style={{
|
||||||
>
|
flex: isDesktopExpanded ? '1 1 auto' : 'none',
|
||||||
{highlightedComposerContent.map((part, index) => (
|
height: !isDesktopExpanded && textareaSize ? `${textareaSize.height}px` : undefined,
|
||||||
<span
|
maxHeight: !isDesktopExpanded && textareaSize ? `${textareaSize.maxHeight}px` : undefined,
|
||||||
key={`${index}-${part.text.length}`}
|
borderTopLeftRadius: chatInputRadius,
|
||||||
className={
|
borderTopRightRadius: chatInputRadius,
|
||||||
part.mentionKind === 'file'
|
}}
|
||||||
? 'text-[var(--status-info)]'
|
rows={1}
|
||||||
: part.mentionKind === 'agent'
|
/>
|
||||||
? 'text-[var(--status-success)]'
|
</div>
|
||||||
: '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}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useRef, memo } from 'react';
|
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 { useInputStore } from '@/sync/input-store';
|
||||||
import type { AttachedFile } from '@/sync/session-ui-store';
|
import type { AttachedFile } from '@/sync/session-ui-store';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
@@ -202,13 +202,7 @@ const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => {
|
|||||||
|
|
||||||
ImagePreview.displayName = 'ImagePreview';
|
ImagePreview.displayName = 'ImagePreview';
|
||||||
|
|
||||||
interface FileChipProps {
|
const useFileDetails = (file: AttachedFile) => {
|
||||||
file: AttachedFile;
|
|
||||||
onRemove: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FileChip = memo(({ file, onRemove }: FileChipProps) => {
|
|
||||||
const { t } = useI18n();
|
|
||||||
const getFileExtension = (filename: string): string => {
|
const getFileExtension = (filename: string): string => {
|
||||||
const parts = filename.split('.');
|
const parts = filename.split('.');
|
||||||
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
|
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
|
||||||
@@ -228,9 +222,21 @@ const FileChip = memo(({ file, onRemove }: FileChipProps) => {
|
|||||||
return filename || path;
|
return filename || path;
|
||||||
};
|
};
|
||||||
|
|
||||||
const displayName = extractFilename(file.filename);
|
return {
|
||||||
const fileSize = formatFileSize(file.size);
|
displayName: extractFilename(file.filename),
|
||||||
const extension = getFileExtension(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 (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -265,11 +271,78 @@ const FileChip = memo(({ file, onRemove }: FileChipProps) => {
|
|||||||
|
|
||||||
FileChip.displayName = 'FileChip';
|
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(() => {
|
export const AttachedFilesList = memo(() => {
|
||||||
const attachedFiles = useInputStore((state) => state.attachedFiles);
|
const attachedFiles = useInputStore((state) => state.attachedFiles);
|
||||||
const removeAttachedFile = useInputStore((state) => state.removeAttachedFile);
|
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;
|
if (localFiles.length === 0) return null;
|
||||||
|
|
||||||
@@ -309,6 +382,105 @@ export const AttachedFilesList = memo(() => {
|
|||||||
|
|
||||||
AttachedFilesList.displayName = 'AttachedFilesList';
|
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 {
|
interface FilePart {
|
||||||
type: string;
|
type: string;
|
||||||
mime?: string;
|
mime?: string;
|
||||||
@@ -423,6 +595,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
|||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{otherFiles.map((file, index) => {
|
{otherFiles.map((file, index) => {
|
||||||
const fileName = resolveDisplayName(file);
|
const fileName = resolveDisplayName(file);
|
||||||
|
const ext = fileName.split('.').pop() || '';
|
||||||
const sizeText = formatFileSize(file.size);
|
const sizeText = formatFileSize(file.size);
|
||||||
const githubLinkKind = getGitHubLinkKind(file);
|
const githubLinkKind = getGitHubLinkKind(file);
|
||||||
return (
|
return (
|
||||||
@@ -450,7 +623,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
|||||||
{file.mime?.includes('pdf') ? (
|
{file.mime?.includes('pdf') ? (
|
||||||
<RiFilePdfLine className="text-muted-foreground h-3.5 w-3.5" />
|
<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]">
|
<div className="overflow-hidden max-w-[140px]">
|
||||||
<span className="truncate block" title={fileName}>{fileName}</span>
|
<span className="truncate block" title={fileName}>{fileName}</span>
|
||||||
|
|||||||
@@ -1278,6 +1278,9 @@ export const dict = {
|
|||||||
'chat.fileAttachment.actions.attach': 'Attach files',
|
'chat.fileAttachment.actions.attach': 'Attach files',
|
||||||
'chat.fileAttachment.actions.removeNamed': 'Remove {name}',
|
'chat.fileAttachment.actions.removeNamed': 'Remove {name}',
|
||||||
'chat.fileAttachment.actions.removeImage': 'Remove image',
|
'chat.fileAttachment.actions.removeImage': 'Remove image',
|
||||||
|
'chat.fileAttachment.activeEditor.addFile': 'Add file:{name} to context',
|
||||||
|
'chat.fileAttachment.activeEditor.pinSelection': 'Pin selection to context',
|
||||||
|
'chat.fileAttachment.activeEditor.remove': 'Remove from context',
|
||||||
'chat.pendingChanges.fileCountSingle': '{count} file',
|
'chat.pendingChanges.fileCountSingle': '{count} file',
|
||||||
'chat.pendingChanges.fileCountPlural': '{count} files',
|
'chat.pendingChanges.fileCountPlural': '{count} files',
|
||||||
'chat.pendingChanges.changedInWorkspace': 'changed in workspace',
|
'chat.pendingChanges.changedInWorkspace': 'changed in workspace',
|
||||||
|
|||||||
@@ -1244,6 +1244,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"chat.fileAttachment.actions.attach": "Adjuntar archivos",
|
"chat.fileAttachment.actions.attach": "Adjuntar archivos",
|
||||||
"chat.fileAttachment.actions.removeNamed": "Eliminar {name}",
|
"chat.fileAttachment.actions.removeNamed": "Eliminar {name}",
|
||||||
"chat.fileAttachment.actions.removeImage": "Eliminar imagen",
|
"chat.fileAttachment.actions.removeImage": "Eliminar imagen",
|
||||||
|
"chat.fileAttachment.activeEditor.addFile": "Agregar archivo:{name} al contexto",
|
||||||
|
"chat.fileAttachment.activeEditor.pinSelection": "Anclar selección al contexto",
|
||||||
|
"chat.fileAttachment.activeEditor.remove": "Quitar del contexto",
|
||||||
"chat.pendingChanges.fileCountSingle": "{count} archivo",
|
"chat.pendingChanges.fileCountSingle": "{count} archivo",
|
||||||
"chat.pendingChanges.fileCountPlural": "{count} archivos",
|
"chat.pendingChanges.fileCountPlural": "{count} archivos",
|
||||||
"chat.pendingChanges.changedInWorkspace": "modificado en el espacio de trabajo",
|
"chat.pendingChanges.changedInWorkspace": "modificado en el espacio de trabajo",
|
||||||
|
|||||||
@@ -1280,6 +1280,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.fileAttachment.actions.attach': '파일 첨부',
|
'chat.fileAttachment.actions.attach': '파일 첨부',
|
||||||
'chat.fileAttachment.actions.removeNamed': '{name} 제거',
|
'chat.fileAttachment.actions.removeNamed': '{name} 제거',
|
||||||
'chat.fileAttachment.actions.removeImage': '이미지 제거',
|
'chat.fileAttachment.actions.removeImage': '이미지 제거',
|
||||||
|
'chat.fileAttachment.activeEditor.addFile': '컨텍스트에 파일 추가:{name}',
|
||||||
|
'chat.fileAttachment.activeEditor.pinSelection': '컨텍스트에 선택 고정',
|
||||||
|
'chat.fileAttachment.activeEditor.remove': '컨텍스트에서 제거',
|
||||||
'chat.pendingChanges.fileCountSingle': '{count} 파일',
|
'chat.pendingChanges.fileCountSingle': '{count} 파일',
|
||||||
'chat.pendingChanges.fileCountPlural': '{count} 파일',
|
'chat.pendingChanges.fileCountPlural': '{count} 파일',
|
||||||
'chat.pendingChanges.changedInWorkspace': '워크스페이스에서 변경됨',
|
'chat.pendingChanges.changedInWorkspace': '워크스페이스에서 변경됨',
|
||||||
|
|||||||
@@ -1244,6 +1244,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"chat.fileAttachment.actions.attach": "Anexar arquivos",
|
"chat.fileAttachment.actions.attach": "Anexar arquivos",
|
||||||
"chat.fileAttachment.actions.removeNamed": "Excluir {name}",
|
"chat.fileAttachment.actions.removeNamed": "Excluir {name}",
|
||||||
"chat.fileAttachment.actions.removeImage": "Excluir imagem",
|
"chat.fileAttachment.actions.removeImage": "Excluir imagem",
|
||||||
|
"chat.fileAttachment.activeEditor.addFile": "Adicionar arquivo:{name} ao contexto",
|
||||||
|
"chat.fileAttachment.activeEditor.pinSelection": "Fixar seleção no contexto",
|
||||||
|
"chat.fileAttachment.activeEditor.remove": "Remover do contexto",
|
||||||
"chat.pendingChanges.fileCountSingle": "{count} arquivo",
|
"chat.pendingChanges.fileCountSingle": "{count} arquivo",
|
||||||
"chat.pendingChanges.fileCountPlural": "{count} arquivos",
|
"chat.pendingChanges.fileCountPlural": "{count} arquivos",
|
||||||
"chat.pendingChanges.changedInWorkspace": "modificado no workspace",
|
"chat.pendingChanges.changedInWorkspace": "modificado no workspace",
|
||||||
|
|||||||
@@ -1244,6 +1244,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"chat.fileAttachment.actions.attach": "Прикріпити файли",
|
"chat.fileAttachment.actions.attach": "Прикріпити файли",
|
||||||
"chat.fileAttachment.actions.removeNamed": "Видалити {name}",
|
"chat.fileAttachment.actions.removeNamed": "Видалити {name}",
|
||||||
"chat.fileAttachment.actions.removeImage": "Видалити зображення",
|
"chat.fileAttachment.actions.removeImage": "Видалити зображення",
|
||||||
|
"chat.fileAttachment.activeEditor.addFile": "Додати файл:{name} до контексту",
|
||||||
|
"chat.fileAttachment.activeEditor.pinSelection": "Закріпити вибір у контексті",
|
||||||
|
"chat.fileAttachment.activeEditor.remove": "Видалити з контексту",
|
||||||
"chat.pendingChanges.fileCountSingle": "Файл: {count}",
|
"chat.pendingChanges.fileCountSingle": "Файл: {count}",
|
||||||
"chat.pendingChanges.fileCountPlural": "Файлів: {count}",
|
"chat.pendingChanges.fileCountPlural": "Файлів: {count}",
|
||||||
"chat.pendingChanges.changedInWorkspace": "змінено в гілці",
|
"chat.pendingChanges.changedInWorkspace": "змінено в гілці",
|
||||||
|
|||||||
@@ -1244,6 +1244,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.fileAttachment.actions.attach': '附加文件',
|
'chat.fileAttachment.actions.attach': '附加文件',
|
||||||
'chat.fileAttachment.actions.removeNamed': '移除 {name}',
|
'chat.fileAttachment.actions.removeNamed': '移除 {name}',
|
||||||
'chat.fileAttachment.actions.removeImage': '移除图片',
|
'chat.fileAttachment.actions.removeImage': '移除图片',
|
||||||
|
'chat.fileAttachment.activeEditor.addFile': '将文件添加到上下文:{name}',
|
||||||
|
'chat.fileAttachment.activeEditor.pinSelection': '将选择固定到上下文',
|
||||||
|
'chat.fileAttachment.activeEditor.remove': '从上下文中移除',
|
||||||
'chat.pendingChanges.fileCountSingle': '{count} 个文件',
|
'chat.pendingChanges.fileCountSingle': '{count} 个文件',
|
||||||
'chat.pendingChanges.fileCountPlural': '{count} 个文件',
|
'chat.pendingChanges.fileCountPlural': '{count} 个文件',
|
||||||
'chat.pendingChanges.changedInWorkspace': '工作区中有变更',
|
'chat.pendingChanges.changedInWorkspace': '工作区中有变更',
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ export interface AttachedFile {
|
|||||||
mimeType: string;
|
mimeType: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
size: number;
|
size: number;
|
||||||
source: "local" | "server";
|
source: "local" | "server" | "vscode";
|
||||||
serverPath?: string;
|
serverPath?: string;
|
||||||
|
vscodePath?: string;
|
||||||
|
vscodeSource?: 'file' | 'selection';
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EditPermissionMode = 'allow' | 'ask' | 'deny' | 'full';
|
export type EditPermissionMode = 'allow' | 'ask' | 'deny' | 'full';
|
||||||
|
|||||||
@@ -6,17 +6,65 @@
|
|||||||
import { create } from "zustand"
|
import { create } from "zustand"
|
||||||
import type { AttachedFile } from "@/stores/types/sessionTypes"
|
import type { AttachedFile } from "@/stores/types/sessionTypes"
|
||||||
|
|
||||||
|
const FILE_URI_PREFIX = "file://"
|
||||||
|
const pendingVSCodeSelectionKeys = new Set<string>()
|
||||||
|
|
||||||
|
const encodeFilePath = (filepath: string): string => {
|
||||||
|
let normalized = filepath.replace(/\\/g, "/")
|
||||||
|
if (/^[A-Za-z]:/.test(normalized)) {
|
||||||
|
normalized = `/${normalized}`
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
.split("/")
|
||||||
|
.map((segment, index) => {
|
||||||
|
if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment
|
||||||
|
return encodeURIComponent(segment)
|
||||||
|
})
|
||||||
|
.join("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
const toFileUrl = (filepath: string): string => {
|
||||||
|
const normalized = filepath.replace(/\\/g, "/").trim()
|
||||||
|
if (normalized.toLowerCase().startsWith(FILE_URI_PREFIX)) {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
return `${FILE_URI_PREFIX}${encodeFilePath(normalized)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getVSCodeSelectionKey = (path: string, filename: string): string => `${path}\u0000${filename}`
|
||||||
|
|
||||||
|
const isSameVSCodeActiveEditorFile = (a: VSCodeActiveEditorFile | null, b: VSCodeActiveEditorFile | null): boolean => {
|
||||||
|
if (a === b) return true
|
||||||
|
if (!a || !b) return false
|
||||||
|
return a.filePath === b.filePath
|
||||||
|
&& a.fileName === b.fileName
|
||||||
|
&& a.relativePath === b.relativePath
|
||||||
|
&& a.fileSize === b.fileSize
|
||||||
|
&& a.selection?.startLine === b.selection?.startLine
|
||||||
|
&& a.selection?.endLine === b.selection?.endLine
|
||||||
|
&& a.selection?.text === b.selection?.text
|
||||||
|
}
|
||||||
|
|
||||||
export type SyntheticContextPart = {
|
export type SyntheticContextPart = {
|
||||||
text: string
|
text: string
|
||||||
attachments?: AttachedFile[]
|
attachments?: AttachedFile[]
|
||||||
synthetic?: boolean
|
synthetic?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type VSCodeActiveEditorFile = {
|
||||||
|
filePath: string
|
||||||
|
fileName: string
|
||||||
|
relativePath: string
|
||||||
|
fileSize: number | null
|
||||||
|
selection: { startLine: number; endLine: number; text: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
export type InputState = {
|
export type InputState = {
|
||||||
pendingInputText: string | null
|
pendingInputText: string | null
|
||||||
pendingInputMode: "replace" | "append" | "append-inline"
|
pendingInputMode: "replace" | "append" | "append-inline"
|
||||||
pendingSyntheticParts: SyntheticContextPart[] | null
|
pendingSyntheticParts: SyntheticContextPart[] | null
|
||||||
attachedFiles: AttachedFile[]
|
attachedFiles: AttachedFile[]
|
||||||
|
activeEditorFile: VSCodeActiveEditorFile | null
|
||||||
|
|
||||||
setPendingInputText: (text: string | null, mode?: "replace" | "append" | "append-inline") => void
|
setPendingInputText: (text: string | null, mode?: "replace" | "append" | "append-inline") => void
|
||||||
consumePendingInputText: () => { text: string; mode: "replace" | "append" | "append-inline" } | null
|
consumePendingInputText: () => { text: string; mode: "replace" | "append" | "append-inline" } | null
|
||||||
@@ -25,6 +73,9 @@ export type InputState = {
|
|||||||
addAttachedFile: (file: File) => Promise<void>
|
addAttachedFile: (file: File) => Promise<void>
|
||||||
removeAttachedFile: (id: string) => void
|
removeAttachedFile: (id: string) => void
|
||||||
clearAttachedFiles: () => void
|
clearAttachedFiles: () => void
|
||||||
|
addVSCodeFileAttachment: (path: string, name: string, fileSize: number | null) => void
|
||||||
|
addVSCodeSelectionAttachment: (path: string, file: File) => Promise<void>
|
||||||
|
setActiveEditorFile: (file: VSCodeActiveEditorFile | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useInputStore = create<InputState>()((set, get) => ({
|
export const useInputStore = create<InputState>()((set, get) => ({
|
||||||
@@ -32,6 +83,7 @@ export const useInputStore = create<InputState>()((set, get) => ({
|
|||||||
pendingInputMode: "replace",
|
pendingInputMode: "replace",
|
||||||
pendingSyntheticParts: null,
|
pendingSyntheticParts: null,
|
||||||
attachedFiles: [],
|
attachedFiles: [],
|
||||||
|
activeEditorFile: null,
|
||||||
|
|
||||||
setPendingInputText: (text, mode = "replace") =>
|
setPendingInputText: (text, mode = "replace") =>
|
||||||
set({ pendingInputText: text, pendingInputMode: mode }),
|
set({ pendingInputText: text, pendingInputMode: mode }),
|
||||||
@@ -76,4 +128,65 @@ export const useInputStore = create<InputState>()((set, get) => ({
|
|||||||
set((s) => ({ attachedFiles: s.attachedFiles.filter((f) => f.id !== id) })),
|
set((s) => ({ attachedFiles: s.attachedFiles.filter((f) => f.id !== id) })),
|
||||||
|
|
||||||
clearAttachedFiles: () => set({ attachedFiles: [] }),
|
clearAttachedFiles: () => set({ attachedFiles: [] }),
|
||||||
|
|
||||||
|
addVSCodeFileAttachment: (path: string, name: string, fileSize: number | null) => {
|
||||||
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
|
const isDuplicate = get().attachedFiles.some(
|
||||||
|
(f) => f.source === 'vscode' && f.vscodeSource === 'file' && (f.vscodePath || '') === path
|
||||||
|
)
|
||||||
|
if (isDuplicate) return
|
||||||
|
const dataUrl = toFileUrl(path)
|
||||||
|
// `file://` URLs are the same contract used by server-source attachments.
|
||||||
|
// The submission path passes `dataUrl` as `url` directly to the OpenCode
|
||||||
|
// server, which resolves `file://` paths natively. No base64 encoding needed.
|
||||||
|
const attached: AttachedFile = {
|
||||||
|
id,
|
||||||
|
file: new File([], name, { type: 'text/plain' }),
|
||||||
|
dataUrl,
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
filename: name,
|
||||||
|
size: fileSize || 0,
|
||||||
|
source: 'vscode',
|
||||||
|
vscodePath: path,
|
||||||
|
vscodeSource: 'file',
|
||||||
|
}
|
||||||
|
set((s) => ({ attachedFiles: [...s.attachedFiles, attached] }))
|
||||||
|
},
|
||||||
|
|
||||||
|
addVSCodeSelectionAttachment: async (path: string, file: File) => {
|
||||||
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
|
const selectionKey = getVSCodeSelectionKey(path, file.name)
|
||||||
|
const isDuplicate = get().attachedFiles.some(
|
||||||
|
(f) => f.source === 'vscode' && f.vscodeSource === 'selection' && f.filename === file.name && f.vscodePath === path
|
||||||
|
)
|
||||||
|
if (isDuplicate || pendingVSCodeSelectionKeys.has(selectionKey)) return
|
||||||
|
pendingVSCodeSelectionKeys.add(selectionKey)
|
||||||
|
let dataUrl: string
|
||||||
|
try {
|
||||||
|
dataUrl = await new Promise<string>((resolve) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => resolve(reader.result as string)
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
pendingVSCodeSelectionKeys.delete(selectionKey)
|
||||||
|
}
|
||||||
|
const attached: AttachedFile = {
|
||||||
|
id,
|
||||||
|
file,
|
||||||
|
dataUrl,
|
||||||
|
mimeType: file.type,
|
||||||
|
filename: file.name,
|
||||||
|
size: file.size,
|
||||||
|
source: 'vscode',
|
||||||
|
vscodePath: path,
|
||||||
|
vscodeSource: 'selection',
|
||||||
|
}
|
||||||
|
set((s) => ({ attachedFiles: [...s.attachedFiles, attached] }))
|
||||||
|
},
|
||||||
|
|
||||||
|
setActiveEditorFile: (file) => {
|
||||||
|
if (isSameVSCodeActiveEditorFile(get().activeEditorFile, file)) return
|
||||||
|
set({ activeEditorFile: file })
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -8,6 +8,26 @@ import { openSseProxy } from './sseProxy';
|
|||||||
import { resolveWebviewDevServerUrl } from './webviewDevServer';
|
import { resolveWebviewDevServerUrl } from './webviewDevServer';
|
||||||
import { normalizeWindowsDriveLetter } from './pathUtils';
|
import { normalizeWindowsDriveLetter } from './pathUtils';
|
||||||
|
|
||||||
|
type ActiveEditorFilePayload = {
|
||||||
|
filePath: string;
|
||||||
|
fileName: string;
|
||||||
|
relativePath: string;
|
||||||
|
fileSize: number | null;
|
||||||
|
selection: { startLine: number; endLine: number; text: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSameActiveEditorFilePayload = (a: ActiveEditorFilePayload | null, b: ActiveEditorFilePayload | null): boolean => {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return a.filePath === b.filePath
|
||||||
|
&& a.fileName === b.fileName
|
||||||
|
&& a.relativePath === b.relativePath
|
||||||
|
&& a.fileSize === b.fileSize
|
||||||
|
&& a.selection?.startLine === b.selection?.startLine
|
||||||
|
&& a.selection?.endLine === b.selection?.endLine
|
||||||
|
&& a.selection?.text === b.selection?.text;
|
||||||
|
};
|
||||||
|
|
||||||
export class ChatViewProvider implements vscode.WebviewViewProvider {
|
export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||||
public static readonly viewType = 'openchamber.chatView';
|
public static readonly viewType = 'openchamber.chatView';
|
||||||
|
|
||||||
@@ -23,6 +43,9 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
|||||||
private _sseCounter = 0;
|
private _sseCounter = 0;
|
||||||
private _sseStreams = new Map<string, AbortController>();
|
private _sseStreams = new Map<string, AbortController>();
|
||||||
private readonly _webviewDevServerUrl: string | null;
|
private readonly _webviewDevServerUrl: string | null;
|
||||||
|
private _broadcastSelectionDebounce: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
private _clearActiveEditorFileTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
private _lastActiveEditorFilePayload: ActiveEditorFilePayload | null = null;
|
||||||
|
|
||||||
// Message delivery confirmation and retry
|
// Message delivery confirmation and retry
|
||||||
private readonly _pendingMessages = new Set<string>();
|
private readonly _pendingMessages = new Set<string>();
|
||||||
@@ -48,6 +71,11 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
|||||||
private readonly _openCodeManager?: OpenCodeManager
|
private readonly _openCodeManager?: OpenCodeManager
|
||||||
) {
|
) {
|
||||||
this._webviewDevServerUrl = resolveWebviewDevServerUrl(this._context);
|
this._webviewDevServerUrl = resolveWebviewDevServerUrl(this._context);
|
||||||
|
|
||||||
|
this._context.subscriptions.push(
|
||||||
|
vscode.window.onDidChangeActiveTextEditor(() => void this._broadcastActiveEditorFile()),
|
||||||
|
vscode.window.onDidChangeTextEditorSelection(() => this._scheduleBroadcast()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public resolveWebviewView(
|
public resolveWebviewView(
|
||||||
@@ -70,6 +98,10 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
|||||||
// Send cached connection status and API URL (may have been set before webview was resolved)
|
// Send cached connection status and API URL (may have been set before webview was resolved)
|
||||||
this._sendCachedState();
|
this._sendCachedState();
|
||||||
|
|
||||||
|
// Send current active editor file state to the new webview
|
||||||
|
this._lastActiveEditorFilePayload = null;
|
||||||
|
void this._broadcastActiveEditorFile();
|
||||||
|
|
||||||
webviewView.onDidDispose(() => {
|
webviewView.onDidDispose(() => {
|
||||||
this._clearPendingMessages();
|
this._clearPendingMessages();
|
||||||
});
|
});
|
||||||
@@ -304,6 +336,85 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _scheduleBroadcast(): void {
|
||||||
|
if (this._broadcastSelectionDebounce !== undefined) {
|
||||||
|
clearTimeout(this._broadcastSelectionDebounce);
|
||||||
|
}
|
||||||
|
this._broadcastSelectionDebounce = setTimeout(() => {
|
||||||
|
this._broadcastSelectionDebounce = undefined;
|
||||||
|
void this._broadcastActiveEditorFile();
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _scheduleClearActiveEditorFile(): void {
|
||||||
|
if (this._clearActiveEditorFileTimer !== undefined) {
|
||||||
|
clearTimeout(this._clearActiveEditorFileTimer);
|
||||||
|
}
|
||||||
|
this._clearActiveEditorFileTimer = setTimeout(() => {
|
||||||
|
this._clearActiveEditorFileTimer = undefined;
|
||||||
|
if (!this._view || this._lastActiveEditorFilePayload === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._lastActiveEditorFilePayload = null;
|
||||||
|
this._view.webview.postMessage({
|
||||||
|
type: 'command',
|
||||||
|
command: 'activeEditorFile',
|
||||||
|
payload: null,
|
||||||
|
});
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _broadcastActiveEditorFile() {
|
||||||
|
if (!this._view) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = vscode.window.activeTextEditor;
|
||||||
|
if (!editor || editor.document.uri.scheme !== 'file') {
|
||||||
|
this._scheduleClearActiveEditorFile();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._clearActiveEditorFileTimer !== undefined) {
|
||||||
|
clearTimeout(this._clearActiveEditorFileTimer);
|
||||||
|
this._clearActiveEditorFileTimer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = normalizeWindowsDriveLetter(editor.document.uri.fsPath);
|
||||||
|
const rawFileName = editor.document.uri.fsPath;
|
||||||
|
const fileName = rawFileName.replace(/\\/g, '/').split('/').pop() || '';
|
||||||
|
const relativePath = vscode.workspace.asRelativePath(editor.document.uri, false);
|
||||||
|
|
||||||
|
let fileSize: number | null = null;
|
||||||
|
try {
|
||||||
|
const stat = await vscode.workspace.fs.stat(editor.document.uri);
|
||||||
|
fileSize = stat.size;
|
||||||
|
} catch {
|
||||||
|
// File may not be saved yet or inaccessible
|
||||||
|
}
|
||||||
|
|
||||||
|
let selection: { startLine: number; endLine: number; text: string } | null = null;
|
||||||
|
if (!editor.selection.isEmpty) {
|
||||||
|
selection = {
|
||||||
|
startLine: editor.selection.start.line + 1,
|
||||||
|
endLine: editor.selection.end.line + 1,
|
||||||
|
text: editor.document.getText(editor.selection),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: ActiveEditorFilePayload = { filePath, fileName, relativePath, fileSize, selection };
|
||||||
|
if (isSameActiveEditorFilePayload(this._lastActiveEditorFilePayload, payload)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._lastActiveEditorFilePayload = payload;
|
||||||
|
|
||||||
|
this._view.webview.postMessage({
|
||||||
|
type: 'command',
|
||||||
|
command: 'activeEditorFile',
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private _buildSseHeaders(extra?: Record<string, string>): Record<string, string> {
|
private _buildSseHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||||
return {
|
return {
|
||||||
Accept: 'text/event-stream',
|
Accept: 'text/event-stream',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type VSCodeThemeKind,
|
type VSCodeThemeKind,
|
||||||
type VSCodeThemePayload,
|
type VSCodeThemePayload,
|
||||||
} from '@openchamber/ui/lib/theme/vscode/adapter';
|
} from '@openchamber/ui/lib/theme/vscode/adapter';
|
||||||
|
import type { VSCodeActiveEditorFile } from '@/sync/input-store';
|
||||||
|
|
||||||
type ConnectionStatus = 'connecting' | 'connected' | 'error' | 'disconnected';
|
type ConnectionStatus = 'connecting' | 'connected' | 'error' | 'disconnected';
|
||||||
type PanelType = 'chat' | 'agentManager';
|
type PanelType = 'chat' | 'agentManager';
|
||||||
@@ -1234,6 +1235,13 @@ onCommand('settingsSynced', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen for active editor file changes from the extension
|
||||||
|
onCommand('activeEditorFile', (payload) => {
|
||||||
|
import('@/sync/input-store').then(({ useInputStore }) => {
|
||||||
|
useInputStore.getState().setActiveEditorFile((payload as VSCodeActiveEditorFile | null) ?? null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
import('@/main')
|
import('@/main')
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await waitForUiMount();
|
await waitForUiMount();
|
||||||
|
|||||||
Reference in New Issue
Block a user