feat: enhance file attachment features performance

This commit is contained in:
Bohdan Triapitsyn
2025-12-15 13:15:45 +02:00
parent b31559be67
commit e064bcf355
8 changed files with 629 additions and 158 deletions
+179 -19
View File
@@ -1,12 +1,19 @@
import React from 'react';
import { Textarea } from '@/components/ui/textarea';
import { RiAiAgentLine, RiCloseCircleLine, RiFileUploadLine, RiSendPlane2Line } from '@remixicon/react';
import {
RiAddCircleLine,
RiAiAgentLine,
RiAttachment2,
RiCloseCircleLine,
RiFileUploadLine,
RiSendPlane2Line,
} from '@remixicon/react';
import { useSessionStore } from '@/stores/useSessionStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import type { EditPermissionMode } from '@/stores/types/sessionTypes';
import { getEditModeColors } from '@/lib/permissions/editModeColors';
import { FileAttachmentButton, AttachedFilesList } from './FileAttachment';
import { AttachedFilesList } from './FileAttachment';
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete';
import { AgentMentionAutocomplete, type AgentMentionAutocompleteHandle } from './AgentMentionAutocomplete';
@@ -20,6 +27,12 @@ import { toast } from 'sonner';
import { useFileStore } from '@/stores/fileStore';
import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults';
import { isVSCodeRuntime } from '@/lib/desktop';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
const MAX_VISIBLE_TEXTAREA_LINES = 8;
@@ -643,10 +656,96 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}
}, [addServerFile]);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const [projectFilePickerOpen, setProjectFilePickerOpen] = React.useState(false);
const attachFiles = React.useCallback(async (files: FileList | File[]) => {
let attachedCount = 0;
const list = Array.isArray(files) ? files : Array.from(files);
for (const file of list) {
const sizeBefore = useSessionStore.getState().attachedFiles.length;
try {
await addAttachedFile(file);
const sizeAfter = useSessionStore.getState().attachedFiles.length;
if (sizeAfter > sizeBefore) {
attachedCount += 1;
}
} catch (error) {
console.error('File attach failed', error);
toast.error(error instanceof Error ? error.message : 'Failed to attach file');
}
}
if (attachedCount > 0) {
toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
}
}, [addAttachedFile]);
const handleVSCodePickFiles = React.useCallback(async () => {
try {
const response = await fetch('/api/vscode/pick-files');
const data = await response.json();
const picked = Array.isArray(data?.files) ? data.files : [];
const skipped = Array.isArray(data?.skipped) ? data.skipped : [];
if (skipped.length > 0) {
const summary = skipped
.map((s: { name?: string; reason?: string }) => `${s?.name || 'file'}: ${s?.reason || 'skipped'}`)
.join('\n');
toast.error(`Some files were skipped:\n${summary}`);
}
const asFiles = picked
.map((file: { name: string; mimeType?: string; dataUrl?: string }) => {
if (!file?.dataUrl) return null;
try {
const [meta, base64] = file.dataUrl.split(',');
const mime = file.mimeType || (meta?.match(/data:(.*);base64/)?.[1] || 'application/octet-stream');
if (!base64) return null;
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
const blob = new Blob([bytes], { type: mime });
return new File([blob], file.name || 'file', { type: mime });
} catch (err) {
console.error('Failed to decode VS Code picked file', err);
return null;
}
})
.filter(Boolean) as File[];
if (asFiles.length > 0) {
await attachFiles(asFiles);
}
} catch (error) {
console.error('VS Code file pick failed', error);
toast.error(error instanceof Error ? error.message : 'Failed to pick files in VS Code');
}
}, [attachFiles]);
const handlePickLocalFiles = React.useCallback(() => {
if (isVSCodeRuntime()) {
void handleVSCodePickFiles();
return;
}
fileInputRef.current?.click();
}, [handleVSCodePickFiles]);
const handleLocalFileSelect = React.useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files) return;
await attachFiles(files);
event.target.value = '';
}, [attachFiles]);
const footerGapClass = 'gap-x-1.5 gap-y-0';
const footerPaddingClass = isMobile ? 'px-1.5 py-1.5' : 'px-2.5 py-1.5';
const footerHeightClass = isMobile ? 'h-9 w-9' : 'h-7 w-7';
const iconSizeClass = isMobile ? 'h-5 w-5' : 'h-[18px] w-[18px]';
const isVSCode = isVSCodeRuntime();
const footerPaddingClass = isMobile ? 'px-1.5 py-1.5' : (isVSCode ? 'px-1.5 py-1' : 'px-2.5 py-1.5');
const footerHeightClass = isMobile ? 'h-9 w-9' : (isVSCode ? 'h-[22px] w-[22px]' : 'h-7 w-7');
const iconSizeClass = isMobile ? 'h-5 w-5' : (isVSCode ? 'h-4 w-4' : 'h-[18px] w-[18px]');
const iconButtonBaseClass = cn(
footerHeightClass,
@@ -669,17 +768,69 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
</button>
);
const projectFileButton = (
<ServerFilePicker onFilesSelected={handleServerFilesSelected} multiSelect>
<button
type='button'
className={iconButtonBaseClass}
title='Attach files from project'
aria-label='Attach files from project'
>
<RiFileUploadLine className={cn(iconSizeClass, 'text-current')} />
</button>
</ServerFilePicker>
const attachmentMenu = (
<>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleLocalFileSelect}
accept="*/*"
/>
<div className="relative inline-flex">
<ServerFilePicker
onFilesSelected={handleServerFilesSelected}
multiSelect
presentation={isMobile || isVSCode ? 'modal' : 'dropdown'}
open={projectFilePickerOpen}
onOpenChange={setProjectFilePickerOpen}
>
{isMobile || isVSCode ? null : (
<button
type="button"
tabIndex={-1}
aria-hidden="true"
className="absolute inset-0 opacity-0 pointer-events-none"
/>
)}
</ServerFilePicker>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={iconButtonBaseClass}
title="Add attachment"
aria-label="Add attachment"
>
<RiAddCircleLine className={cn(iconSizeClass, 'text-current')} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onSelect={() => {
requestAnimationFrame(() => handlePickLocalFiles());
}}
>
<RiAttachment2 />
Attach files
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
requestAnimationFrame(() => {
setProjectFilePickerOpen(true);
});
}}
>
<RiFileUploadLine />
Attach from project
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
);
const settingsButton = onOpenSettings ? (
@@ -696,8 +847,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const attachmentsControls = (
<>
<FileAttachmentButton />
{projectFileButton}
{attachmentMenu}
{settingsButton}
</>
);
@@ -795,7 +945,17 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
{isDragging && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-xl">
<div className="text-center">
<FileAttachmentButton />
<div className="inline-flex justify-center">
<button
type="button"
className={iconButtonBaseClass}
onClick={() => handlePickLocalFiles()}
title="Attach files"
aria-label="Attach files"
>
<RiAttachment2 className={cn(iconSizeClass, 'text-current')} />
</button>
</div>
<p className="mt-2 typography-ui-label text-muted-foreground">Drop files here to attach</p>
</div>
</div>
@@ -37,6 +37,56 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const containerRef = React.useRef<HTMLDivElement | null>(null);
const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => {
const q = query.trim().toLowerCase();
if (!q) {
return 0;
}
const c = candidate.toLowerCase();
let score = 0;
let lastIndex = -1;
let consecutive = 0;
for (let i = 0; i < q.length; i += 1) {
const ch = q[i];
if (!ch || ch === ' ') {
continue;
}
const idx = c.indexOf(ch, lastIndex + 1);
if (idx === -1) {
return null;
}
const gap = idx - lastIndex - 1;
if (gap === 0) {
consecutive += 1;
} else {
consecutive = 0;
}
score += 10;
score += Math.max(0, 18 - idx);
score -= Math.max(0, gap);
if (idx === 0) {
score += 12;
} else {
const prev = c[idx - 1];
if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') {
score += 10;
}
}
score += consecutive > 0 ? 12 : 0;
lastIndex = idx;
}
score += Math.max(0, 24 - Math.round(c.length / 3));
return score;
}, []);
React.useEffect(() => {
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
const target = event.target as Node | null;
@@ -61,15 +111,35 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
return;
}
const normalizedQuery = (debouncedQuery ?? '').trim();
const normalizedQueryLower = normalizedQuery.toLowerCase();
let cancelled = false;
setLoading(true);
searchFiles(currentDirectory, debouncedQuery ?? '', 40)
searchFiles(currentDirectory, normalizedQueryLower, 80)
.then((hits) => {
if (cancelled) {
return;
}
setFiles(hits.slice(0, 15));
const ranked = normalizedQueryLower
? hits
.map((file) => {
const label = file.relativePath || file.name || file.path;
const score = fuzzyScore(normalizedQueryLower, label);
return score === null ? null : { file, score, labelLength: label.length };
})
.filter(Boolean) as Array<{ file: FileInfo; score: number; labelLength: number }>
: hits.map((file) => ({ file, score: 0, labelLength: (file.relativePath || file.name || file.path).length }));
ranked.sort((a, b) => (
b.score - a.score
|| a.labelLength - b.labelLength
|| a.file.path.localeCompare(b.file.path)
));
setFiles(ranked.slice(0, 15).map((entry) => entry.file));
})
.catch(() => {
if (!cancelled) {
@@ -85,7 +155,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
return () => {
cancelled = true;
};
}, [currentDirectory, debouncedQuery, searchFiles]);
}, [currentDirectory, debouncedQuery, fuzzyScore, searchFiles]);
React.useEffect(() => {
setSelectedIndex(0);
@@ -345,11 +345,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
}
}, [editToggleDisabled]);
const buttonHeight = isCompact ? 'h-9' : 'h-8';
const editToggleIconClass = isCompact ? 'h-5 w-5' : 'h-4 w-4';
const controlIconSize = isCompact ? 'h-5 w-5' : 'h-4 w-4';
const sizeVariant: 'mobile' | 'vscode' | 'default' = isMobile ? 'mobile' : isVSCodeRuntime ? 'vscode' : 'default';
const buttonHeight = sizeVariant === 'mobile' ? 'h-9' : sizeVariant === 'vscode' ? 'h-6' : 'h-8';
const editToggleIconClass = sizeVariant === 'mobile' ? 'h-5 w-5' : sizeVariant === 'vscode' ? 'h-4 w-4' : 'h-4 w-4';
const controlIconSize = sizeVariant === 'mobile' ? 'h-5 w-5' : sizeVariant === 'vscode' ? 'h-4 w-4' : 'h-4 w-4';
const controlTextSize = isCompact ? 'typography-micro' : 'typography-meta';
const inlineGapClass = isCompact ? 'gap-x-2' : 'gap-x-3';
const inlineGapClass = sizeVariant === 'mobile' ? 'gap-x-2' : sizeVariant === 'vscode' ? 'gap-x-1' : 'gap-x-3';
const editPermissionMenuLabel = editModeShortLabels[effectiveEditMode];
const renderEditModeIcon = React.useCallback((mode: EditPermissionMode, iconClass = editToggleIconClass) => {
@@ -32,78 +32,140 @@ interface ServerFilePickerProps {
onFilesSelected: (files: FileInfo[]) => void;
multiSelect?: boolean;
children: React.ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
presentation?: 'dropdown' | 'modal';
}
export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
onFilesSelected,
multiSelect = false,
children
children,
open: controlledOpen,
onOpenChange,
presentation = 'dropdown',
}) => {
const { isMobile } = useDeviceInfo();
const isVSCodeRuntime = useIsVSCodeRuntime();
const isCompact = isMobile || isVSCodeRuntime;
const { currentDirectory } = useDirectoryStore();
const searchFiles = useFileSearchStore((state) => state.searchFiles);
const [open, setOpen] = React.useState(false);
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false);
const [mobileOpen, setMobileOpen] = React.useState(false);
const [searchQuery, setSearchQuery] = React.useState('');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 200);
const [selectedFiles, setSelectedFiles] = React.useState<Set<string>>(new Set());
const [expandedDirs, setExpandedDirs] = React.useState<Set<string>>(new Set());
const [fileTree, setFileTree] = React.useState<FileInfo[]>([]);
const [childrenByDir, setChildrenByDir] = React.useState<Record<string, FileInfo[]>>({});
const loadedDirsRef = React.useRef<Set<string>>(new Set());
const inFlightDirsRef = React.useRef<Set<string>>(new Set());
const [searchResults, setSearchResults] = React.useState<FileInfo[]>([]);
const [searching, setSearching] = React.useState(false);
const [loading, setLoading] = React.useState(false);
const [attaching, setAttaching] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const open = controlledOpen ?? uncontrolledOpen;
const setOpen = onOpenChange ?? setUncontrolledOpen;
const sortDirectoryItems = React.useCallback((items: FileInfo[]) => (
items.slice().sort((a, b) => {
if (a.type !== b.type) {
return a.type === 'directory' ? -1 : 1;
}
return a.name.localeCompare(b.name);
})
), []);
const mapFilesystemEntries = React.useCallback((dirPath: string, entries: Array<{ name: string; path: string; isDirectory: boolean }>): FileInfo[] => (
sortDirectoryItems(entries
.filter((item) => !item.name.startsWith('.'))
.map((item) => {
const name = item.name;
const extension = !item.isDirectory && name.includes('.')
? name.split('.').pop()?.toLowerCase()
: undefined;
return {
name,
path: item.path || `${dirPath}/${name}`,
type: item.isDirectory ? 'directory' : 'file',
size: 0,
extension,
};
}))
), [sortDirectoryItems]);
const loadDirectory = React.useCallback(async (dirPath: string) => {
setLoading(true);
setError(null);
try {
const tempClient = opencodeClient.getApiClient();
const response = await tempClient.file.list({
query: {
path: '.',
directory: dirPath
}
});
const entries = await opencodeClient.listLocalDirectory(dirPath);
const items = mapFilesystemEntries(dirPath, entries.map((entry) => ({
name: entry.name,
path: entry.path,
isDirectory: entry.isDirectory,
})));
if (!response.data) {
setFileTree([]);
return;
}
const items = response.data
.filter((item: { name: string; type: string; size?: number; absolute?: string }) => !item.name.startsWith('.'))
.map((item: { name: string; type: string; size?: number; absolute?: string }) => {
const extension = item.type === 'file'
? item.name.split('.').pop()?.toLowerCase()
: undefined;
return {
name: item.name,
path: item.absolute || `${dirPath}/${item.name}`,
type: item.type as 'file' | 'directory',
size: item.size || 0,
extension
};
})
.sort((a: FileInfo, b: FileInfo) => {
if (a.type !== b.type) {
return a.type === 'directory' ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
setFileTree(items);
loadedDirsRef.current = new Set([dirPath]);
inFlightDirsRef.current = new Set();
setChildrenByDir({ [dirPath]: items });
setExpandedDirs(new Set());
} catch {
setError('Failed to load directory contents');
setFileTree([]);
loadedDirsRef.current = new Set([dirPath]);
inFlightDirsRef.current = new Set();
setChildrenByDir({ [dirPath]: [] });
setExpandedDirs(new Set());
} finally {
setLoading(false);
}
}, []);
}, [mapFilesystemEntries]);
const loadDirectoryChildren = React.useCallback(async (dirPath: string) => {
const normalizedDir = dirPath.trim();
if (!normalizedDir) {
return;
}
if (loadedDirsRef.current.has(normalizedDir)) {
return;
}
if (inFlightDirsRef.current.has(normalizedDir)) {
return;
}
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
inFlightDirsRef.current.add(normalizedDir);
try {
const entries = await opencodeClient.listLocalDirectory(normalizedDir);
const items = mapFilesystemEntries(normalizedDir, entries.map((entry) => ({
name: entry.name,
path: entry.path,
isDirectory: entry.isDirectory,
})));
loadedDirsRef.current = new Set(loadedDirsRef.current);
loadedDirsRef.current.add(normalizedDir);
setChildrenByDir((prev) => ({
...prev,
[normalizedDir]: items,
}));
} catch {
// Keep it unloadded so the user can retry expanding the directory.
setChildrenByDir((prev) => {
if (prev[normalizedDir]) {
return prev;
}
return {
...prev,
[normalizedDir]: [],
};
});
} finally {
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
inFlightDirsRef.current.delete(normalizedDir);
}
}, [mapFilesystemEntries]);
React.useEffect(() => {
if ((open || mobileOpen) && currentDirectory) {
@@ -214,50 +276,13 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
return next;
});
} else {
setExpandedDirs(prev => {
setExpandedDirs((prev) => {
const next = new Set(prev);
next.add(dirPath);
return next;
});
try {
const tempClient = opencodeClient.getApiClient();
const response = await tempClient.file.list({
query: {
path: '.',
directory: dirPath
}
});
if (response.data) {
const subItems = response.data
.filter((item: { name: string; type: string; size?: number; absolute?: string }) => !item.name.startsWith('.'))
.map((item: { name: string; type: string; size?: number; absolute?: string }) => {
const extension = item.type === 'file'
? item.name.split('.').pop()?.toLowerCase()
: undefined;
return {
name: item.name,
path: item.absolute || `${dirPath}/${item.name}`,
type: item.type as 'file' | 'directory',
size: 0,
extension
};
});
setFileTree(prev => {
const filtered = prev.filter(item => !item.path.startsWith(dirPath + '/'));
return [...filtered, ...subItems].sort((a, b) => {
const aDepth = a.path.split('/').length;
const bDepth = b.path.split('/').length;
if (aDepth !== bDepth) return aDepth - bDepth;
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
return a.name.localeCompare(b.name);
});
});
}
} catch { /* ignored */ }
await loadDirectoryChildren(dirPath);
}
};
@@ -278,11 +303,14 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
};
const handleConfirm = async () => {
const treeFileMap = new Map(
fileTree
.filter((file) => file.type === 'file')
.map((file) => [file.path, file])
);
const treeFileMap = new Map<string, FileInfo>();
Object.values(childrenByDir).forEach((items) => {
items.forEach((file) => {
if (file.type === 'file') {
treeFileMap.set(file.path, file);
}
});
});
const searchFileMap = new Map(searchResults.map((file) => [file.path, file]));
const selected = Array.from(selectedFiles)
@@ -304,20 +332,13 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
if (!currentDirectory) {
return [];
}
return fileTree.filter((item) => {
const itemDir = item.path.substring(0, item.path.lastIndexOf('/'));
return itemDir === currentDirectory;
});
}, [fileTree, currentDirectory]);
return childrenByDir[currentDirectory] ?? [];
}, [childrenByDir, currentDirectory]);
const isSearchActive = searchQuery.trim().length > 0;
const getChildItems = (parentPath: string) => {
return fileTree.filter(item => {
const itemDir = item.path.substring(0, item.path.lastIndexOf('/'));
return itemDir === parentPath;
});
return childrenByDir[parentPath] ?? [];
};
const getRelativePath = (fullPath: string) => {
@@ -384,6 +405,7 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
const isDirectory = file.type === 'directory';
const children = isDirectory ? getChildItems(file.path) : [];
const isExpanded = expandedDirs.has(file.path);
const isLoadingChildren = isDirectory && isExpanded && inFlightDirsRef.current.has(file.path) && children.length === 0;
return (
<div key={file.path}>
@@ -398,7 +420,7 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
e.preventDefault();
e.stopPropagation();
if (isDirectory) {
toggleDirectory(file.path);
void toggleDirectory(file.path);
} else {
toggleFileSelection(file.path);
}
@@ -418,6 +440,15 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
{children.map((child) => renderFileTree(child, level + 1))}
</div>
)}
{isDirectory && isExpanded && isLoadingChildren && (
<div
className="px-2 py-1.5 typography-ui-label text-muted-foreground"
style={{ paddingLeft: `${(level + 1) * 12}px` }}
>
Loading
</div>
)}
</div>
);
};
@@ -527,6 +558,34 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
</span>
);
if (presentation === 'modal') {
return (
<>
{children ? (
<span
className="inline-flex cursor-pointer"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setOpen(true);
}}
>
{children}
</span>
) : null}
<MobileOverlayPanel
open={open}
onClose={() => setOpen(false)}
title="Select Project Files"
footer={summarySection}
>
<div className="flex flex-col gap-0">{pickerBody}</div>
</MobileOverlayPanel>
</>
);
}
if (isCompact) {
return (
<>
@@ -549,9 +608,13 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
{children}
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[520px] p-0 overflow-hidden flex flex-col ml-16"
align="center"
className={cn(
'p-0 overflow-hidden flex flex-col',
'w-[min(520px,calc(100vw-24px))]'
)}
align="start"
sideOffset={5}
collisionPadding={12}
>
{pickerBody}
<DropdownMenuSeparator />