feat: preview pending image attachments

Open pasted images before sending
Reuse the existing image preview dialog
Support keyboard preview for attachment thumbnails
This commit is contained in:
Bohdan Triapitsyn
2026-05-24 23:18:32 +03:00
parent 71a2e4c0f1
commit f866201b1f
2 changed files with 96 additions and 10 deletions
+27 -2
View File
@@ -16,6 +16,8 @@ import { useSnippetsStore } from '@/stores/useSnippetsStore';
import { appendInlineComments } from '@/lib/messages/inlineComments';
import { renderMagicPrompt } from '@/lib/magicPrompts';
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
import ToolOutputDialog from './message/ToolOutputDialog';
import type { ToolPopupContent } from './message/types';
import { QueuedMessageChips } from './QueuedMessageChips';
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
import { CommandAutocomplete, type CommandAutocompleteHandle, type CommandInfo } from './CommandAutocomplete';
@@ -990,6 +992,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
const agents = getVisibleAgents();
const isMobile = useUIStore((state) => state.isMobile);
const setImagePreviewOpen = useUIStore((state) => state.setImagePreviewOpen);
const inputBarOffset = useUIStore((state) => state.inputBarOffset);
const persistChatDraft = useUIStore((state) => state.persistChatDraft);
const inputSpellcheckEnabled = useUIStore((state) => state.inputSpellcheckEnabled);
@@ -1011,6 +1014,22 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept);
const composerHighlightRef = React.useRef<HTMLDivElement | null>(null);
const [isNarrowComposer, setIsNarrowComposer] = React.useState(false);
const [attachmentPreview, setAttachmentPreview] = React.useState<ToolPopupContent>({
open: false,
title: '',
content: '',
});
const handleShowAttachmentPreview = React.useCallback((content: ToolPopupContent) => {
if (!content.image) return;
setAttachmentPreview(content);
setImagePreviewOpen(true);
}, [setImagePreviewOpen]);
const handleAttachmentPreviewOpenChange = React.useCallback((open: boolean) => {
setAttachmentPreview((prev) => ({ ...prev, open }));
setImagePreviewOpen(open);
}, [setImagePreviewOpen]);
const isDesktopExpanded = isExpandedInput && !isMobile;
const chatInputRadius = 'var(--radius-xl)';
@@ -3787,7 +3806,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
style={isMobile && inputBarOffset > 0 ? { marginBottom: `${inputBarOffset}px` } : undefined}
>
<div className={cn('chat-input-column relative overflow-visible', isDesktopExpanded && 'flex flex-1 min-h-0 flex-col')}>
<AttachedFilesList />
<AttachedFilesList onShowPopup={handleShowAttachmentPreview} />
<QueuedMessageChips
onEditMessage={handleQueuedMessageEdit}
/>
@@ -4167,7 +4186,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
)}
<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 />
<AttachedVSCodeFileChips onShowPopup={handleShowAttachmentPreview} />
<ActiveEditorFileSuggestion />
</div>
<div className={cn("relative overflow-hidden", isDesktopExpanded && 'flex flex-1 min-h-0 flex-col')}>
@@ -4403,6 +4422,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
setLinkedIssue(null);
}}
/>
<ToolOutputDialog
popup={attachmentPreview}
onOpenChange={handleAttachmentPreviewOpenChange}
syntaxTheme={{}}
isMobile={isMobile}
/>
</>
);
};
@@ -124,9 +124,12 @@ FileAttachmentButton.displayName = 'FileAttachmentButton';
interface ImagePreviewProps {
file: AttachedFile;
onRemove: () => void;
onShowPopup?: (content: ToolPopupContent) => void;
gallery?: NonNullable<ToolPopupContent['image']>['gallery'];
index?: number;
}
const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => {
const ImagePreview = memo(({ file, onRemove, onShowPopup, gallery, index = 0 }: ImagePreviewProps) => {
const { t } = useI18n();
const { isMobile, isTablet } = useDeviceInfo();
const alwaysShowActions = isMobile || isTablet;
@@ -151,6 +154,29 @@ const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => {
const displayName = extractFilename(file.filename);
const extension = getFileExtension(file.filename);
const handleOpenPreview = React.useCallback(() => {
if (!onShowPopup || !imageUrl) return;
onShowPopup({
open: true,
title: displayName || 'Image',
content: '',
metadata: {
tool: 'image-preview',
filename: displayName,
mime: file.mimeType,
size: file.size,
},
image: {
url: imageUrl,
mimeType: file.mimeType,
filename: displayName,
size: file.size,
gallery,
index,
},
});
}, [displayName, file.mimeType, file.size, gallery, imageUrl, index, onShowPopup]);
if (!imageUrl) {
// Fallback to text-only for server images without preview
@@ -178,7 +204,20 @@ const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => {
}
return (
<div className="relative h-10 w-10 rounded-lg border border-border/40 bg-muted/10 overflow-hidden flex-shrink-0 group">
<div
role={onShowPopup ? 'button' : undefined}
tabIndex={onShowPopup ? 0 : undefined}
onClick={handleOpenPreview}
onKeyDown={(event) => {
if (!onShowPopup) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleOpenPreview();
}
}}
className="relative h-10 w-10 rounded-lg border border-border/40 bg-muted/10 overflow-hidden flex-shrink-0 group cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={displayName}
>
<img
src={imageUrl}
alt={displayName}
@@ -186,7 +225,10 @@ const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => {
loading="lazy"
/>
<button
onClick={onRemove}
onClick={(event) => {
event.stopPropagation();
onRemove();
}}
className={cn(
"absolute top-0.5 right-0.5 h-4 w-4 rounded-full bg-background/80 text-foreground hover:text-destructive flex items-center justify-center transition-opacity focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
alwaysShowActions ? "opacity-100" : "opacity-0 group-hover:opacity-100"
@@ -313,7 +355,11 @@ const VSCodeFileChip = memo(({ file, onRemove }: FileChipProps) => {
VSCodeFileChip.displayName = 'VSCodeFileChip';
export const AttachedVSCodeFileChips = memo(() => {
interface AttachedFilesListProps {
onShowPopup?: (content: ToolPopupContent) => void;
}
export const AttachedVSCodeFileChips = memo(({ onShowPopup }: AttachedFilesListProps) => {
const attachedFiles = useInputStore((state) => state.attachedFiles);
const removeAttachedFile = useInputStore((state) => state.removeAttachedFile);
@@ -323,11 +369,17 @@ export const AttachedVSCodeFileChips = memo(() => {
const images = vscodeFiles.filter((f) => f.mimeType.startsWith('image/'));
const otherFiles = vscodeFiles.filter((f) => !f.mimeType.startsWith('image/'));
const imageGallery = images.map((file) => ({
url: file.dataUrl || file.serverPath || '',
mimeType: file.mimeType,
filename: file.filename,
size: file.size,
})).filter((image) => image.url);
return (
<div className="flex items-center gap-2 flex-wrap">
{images.map((file) => (
<ImagePreview key={file.id} file={file} onRemove={() => removeAttachedFile(file.id)} />
{images.map((file, index) => (
<ImagePreview key={file.id} file={file} onRemove={() => removeAttachedFile(file.id)} onShowPopup={onShowPopup} gallery={imageGallery} index={index} />
))}
{otherFiles.map((file) => (
<VSCodeFileChip key={file.id} file={file} onRemove={() => removeAttachedFile(file.id)} />
@@ -338,7 +390,7 @@ export const AttachedVSCodeFileChips = memo(() => {
AttachedVSCodeFileChips.displayName = 'AttachedVSCodeFileChips';
export const AttachedFilesList = memo(() => {
export const AttachedFilesList = memo(({ onShowPopup }: AttachedFilesListProps) => {
const attachedFiles = useInputStore((state) => state.attachedFiles);
const removeAttachedFile = useInputStore((state) => state.removeAttachedFile);
@@ -348,17 +400,26 @@ export const AttachedFilesList = memo(() => {
const images = localFiles.filter((f) => f.mimeType.startsWith('image/'));
const otherFiles = localFiles.filter((f) => !f.mimeType.startsWith('image/'));
const imageGallery = images.map((file) => ({
url: file.dataUrl || file.serverPath || '',
mimeType: file.mimeType,
filename: file.filename,
size: file.size,
})).filter((image) => image.url);
return (
<div className="pb-4 w-full px-1 space-y-3">
{/* Images row - inline with previews */}
{images.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
{images.map((file) => (
{images.map((file, index) => (
<ImagePreview
key={file.id}
file={file}
onRemove={() => removeAttachedFile(file.id)}
onShowPopup={onShowPopup}
gallery={imageGallery}
index={index}
/>
))}
</div>