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 { appendInlineComments } from '@/lib/messages/inlineComments';
|
||||||
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||||||
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
|
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
|
||||||
|
import ToolOutputDialog from './message/ToolOutputDialog';
|
||||||
|
import type { ToolPopupContent } from './message/types';
|
||||||
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';
|
||||||
@@ -990,6 +992,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
|
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
|
||||||
const agents = getVisibleAgents();
|
const agents = getVisibleAgents();
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
|
const setImagePreviewOpen = useUIStore((state) => state.setImagePreviewOpen);
|
||||||
const inputBarOffset = useUIStore((state) => state.inputBarOffset);
|
const inputBarOffset = useUIStore((state) => state.inputBarOffset);
|
||||||
const persistChatDraft = useUIStore((state) => state.persistChatDraft);
|
const persistChatDraft = useUIStore((state) => state.persistChatDraft);
|
||||||
const inputSpellcheckEnabled = useUIStore((state) => state.inputSpellcheckEnabled);
|
const inputSpellcheckEnabled = useUIStore((state) => state.inputSpellcheckEnabled);
|
||||||
@@ -1011,6 +1014,22 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept);
|
const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept);
|
||||||
const composerHighlightRef = React.useRef<HTMLDivElement | null>(null);
|
const composerHighlightRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const [isNarrowComposer, setIsNarrowComposer] = React.useState(false);
|
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 isDesktopExpanded = isExpandedInput && !isMobile;
|
||||||
const chatInputRadius = 'var(--radius-xl)';
|
const chatInputRadius = 'var(--radius-xl)';
|
||||||
@@ -3787,7 +3806,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
style={isMobile && inputBarOffset > 0 ? { marginBottom: `${inputBarOffset}px` } : undefined}
|
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')}>
|
<div className={cn('chat-input-column relative overflow-visible', isDesktopExpanded && 'flex flex-1 min-h-0 flex-col')}>
|
||||||
<AttachedFilesList />
|
<AttachedFilesList onShowPopup={handleShowAttachmentPreview} />
|
||||||
<QueuedMessageChips
|
<QueuedMessageChips
|
||||||
onEditMessage={handleQueuedMessageEdit}
|
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={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">
|
<div className="flex items-center gap-1 px-3 pt-1 flex-wrap relative z-10">
|
||||||
<AttachedVSCodeFileChips />
|
<AttachedVSCodeFileChips onShowPopup={handleShowAttachmentPreview} />
|
||||||
<ActiveEditorFileSuggestion />
|
<ActiveEditorFileSuggestion />
|
||||||
</div>
|
</div>
|
||||||
<div className={cn("relative overflow-hidden", isDesktopExpanded && 'flex flex-1 min-h-0 flex-col')}>
|
<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);
|
setLinkedIssue(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<ToolOutputDialog
|
||||||
|
popup={attachmentPreview}
|
||||||
|
onOpenChange={handleAttachmentPreviewOpenChange}
|
||||||
|
syntaxTheme={{}}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -124,9 +124,12 @@ FileAttachmentButton.displayName = 'FileAttachmentButton';
|
|||||||
interface ImagePreviewProps {
|
interface ImagePreviewProps {
|
||||||
file: AttachedFile;
|
file: AttachedFile;
|
||||||
onRemove: () => void;
|
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 { t } = useI18n();
|
||||||
const { isMobile, isTablet } = useDeviceInfo();
|
const { isMobile, isTablet } = useDeviceInfo();
|
||||||
const alwaysShowActions = isMobile || isTablet;
|
const alwaysShowActions = isMobile || isTablet;
|
||||||
@@ -151,6 +154,29 @@ const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => {
|
|||||||
|
|
||||||
const displayName = extractFilename(file.filename);
|
const displayName = extractFilename(file.filename);
|
||||||
const extension = getFileExtension(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) {
|
if (!imageUrl) {
|
||||||
// Fallback to text-only for server images without preview
|
// Fallback to text-only for server images without preview
|
||||||
@@ -178,7 +204,20 @@ const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<img
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
alt={displayName}
|
alt={displayName}
|
||||||
@@ -186,7 +225,10 @@ const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => {
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={onRemove}
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onRemove();
|
||||||
|
}}
|
||||||
className={cn(
|
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",
|
"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"
|
alwaysShowActions ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
||||||
@@ -313,7 +355,11 @@ const VSCodeFileChip = memo(({ file, onRemove }: FileChipProps) => {
|
|||||||
|
|
||||||
VSCodeFileChip.displayName = 'VSCodeFileChip';
|
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 attachedFiles = useInputStore((state) => state.attachedFiles);
|
||||||
const removeAttachedFile = useInputStore((state) => state.removeAttachedFile);
|
const removeAttachedFile = useInputStore((state) => state.removeAttachedFile);
|
||||||
|
|
||||||
@@ -323,11 +369,17 @@ export const AttachedVSCodeFileChips = memo(() => {
|
|||||||
|
|
||||||
const images = vscodeFiles.filter((f) => f.mimeType.startsWith('image/'));
|
const images = vscodeFiles.filter((f) => f.mimeType.startsWith('image/'));
|
||||||
const otherFiles = 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 (
|
return (
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{images.map((file) => (
|
{images.map((file, index) => (
|
||||||
<ImagePreview key={file.id} file={file} onRemove={() => removeAttachedFile(file.id)} />
|
<ImagePreview key={file.id} file={file} onRemove={() => removeAttachedFile(file.id)} onShowPopup={onShowPopup} gallery={imageGallery} index={index} />
|
||||||
))}
|
))}
|
||||||
{otherFiles.map((file) => (
|
{otherFiles.map((file) => (
|
||||||
<VSCodeFileChip key={file.id} file={file} onRemove={() => removeAttachedFile(file.id)} />
|
<VSCodeFileChip key={file.id} file={file} onRemove={() => removeAttachedFile(file.id)} />
|
||||||
@@ -338,7 +390,7 @@ export const AttachedVSCodeFileChips = memo(() => {
|
|||||||
|
|
||||||
AttachedVSCodeFileChips.displayName = 'AttachedVSCodeFileChips';
|
AttachedVSCodeFileChips.displayName = 'AttachedVSCodeFileChips';
|
||||||
|
|
||||||
export const AttachedFilesList = memo(() => {
|
export const AttachedFilesList = memo(({ onShowPopup }: AttachedFilesListProps) => {
|
||||||
const attachedFiles = useInputStore((state) => state.attachedFiles);
|
const attachedFiles = useInputStore((state) => state.attachedFiles);
|
||||||
const removeAttachedFile = useInputStore((state) => state.removeAttachedFile);
|
const removeAttachedFile = useInputStore((state) => state.removeAttachedFile);
|
||||||
|
|
||||||
@@ -348,17 +400,26 @@ export const AttachedFilesList = memo(() => {
|
|||||||
|
|
||||||
const images = localFiles.filter((f) => f.mimeType.startsWith('image/'));
|
const images = localFiles.filter((f) => f.mimeType.startsWith('image/'));
|
||||||
const otherFiles = 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 (
|
return (
|
||||||
<div className="pb-4 w-full px-1 space-y-3">
|
<div className="pb-4 w-full px-1 space-y-3">
|
||||||
{/* Images row - inline with previews */}
|
{/* Images row - inline with previews */}
|
||||||
{images.length > 0 && (
|
{images.length > 0 && (
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
{images.map((file) => (
|
{images.map((file, index) => (
|
||||||
<ImagePreview
|
<ImagePreview
|
||||||
key={file.id}
|
key={file.id}
|
||||||
file={file}
|
file={file}
|
||||||
onRemove={() => removeAttachedFile(file.id)}
|
onRemove={() => removeAttachedFile(file.id)}
|
||||||
|
onShowPopup={onShowPopup}
|
||||||
|
gallery={imageGallery}
|
||||||
|
index={index}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user