From f866201b1f5d6a9fc6a2791ae3f6e06222f05e38 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 24 May 2026 23:18:32 +0300 Subject: [PATCH] feat: preview pending image attachments Open pasted images before sending Reuse the existing image preview dialog Support keyboard preview for attachment thumbnails --- packages/ui/src/components/chat/ChatInput.tsx | 29 ++++++- .../ui/src/components/chat/FileAttachment.tsx | 77 +++++++++++++++++-- 2 files changed, 96 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index c5578d7c..4f632af4 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -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 = ({ 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 = ({ onOpenSettings, scrollTo const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept); const composerHighlightRef = React.useRef(null); const [isNarrowComposer, setIsNarrowComposer] = React.useState(false); + const [attachmentPreview, setAttachmentPreview] = React.useState({ + 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 = ({ onOpenSettings, scrollTo style={isMobile && inputBarOffset > 0 ? { marginBottom: `${inputBarOffset}px` } : undefined} >
- + @@ -4167,7 +4186,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo )}
- +
@@ -4403,6 +4422,12 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo setLinkedIssue(null); }} /> + ); }; diff --git a/packages/ui/src/components/chat/FileAttachment.tsx b/packages/ui/src/components/chat/FileAttachment.tsx index 55f9ff9e..b776f652 100644 --- a/packages/ui/src/components/chat/FileAttachment.tsx +++ b/packages/ui/src/components/chat/FileAttachment.tsx @@ -124,9 +124,12 @@ FileAttachmentButton.displayName = 'FileAttachmentButton'; interface ImagePreviewProps { file: AttachedFile; onRemove: () => void; + onShowPopup?: (content: ToolPopupContent) => void; + gallery?: NonNullable['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 ( -
+
{ + 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} + > {displayName} { loading="lazy" />