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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user