feat(ui): enable drag-and-drop attachments and image previews in chat (#390)
* feat(BottomTerminalDock): add close button next to the fullscreen toggle in the dock * style: replace hardcoded gradient with theme value in shine text variant * fix(header): adapt instance button for desktop only * refactor(chat): polish sticky turn UX and message action rows for better readability Switch to stable sticky-only turn behavior and redesign user/assistant action controls (placement, hover rules, ordering, spacing, selection-safe clamp) to reduce visual noise and improve interaction flow. * feat(chat/message): refactor buttons in messages footer * feat: enhance image preview functionality in chat messages - Added a new ImagePreviewDialog component to handle image previews with navigation support. - Updated ToolOutputDialog to utilize the new ImagePreviewDialog for displaying images. - Modified the ToolPopupContent type to include a gallery of images and an index for the current image. - Removed the old inline image display logic from ToolOutputDialog. - Improved file handling in the file store, including better MIME type guessing and handling of server paths. - Introduced a new API endpoint for handling large session message payloads, allowing for better management of multi-file attachments. - Updated the VSCode bridge to support session message requests with appropriate headers and body handling. * feat(proxy): implement SSE forwarding and enhance generic API request handling * feat(chat): support submitting only queued messages * feat: add image preview transition state * fix: default VSCode view to draft and fixed sessions list regression
This commit is contained in:
committed by
GitHub
parent
39e625d8ec
commit
844562749d
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import { RiBrainAi3Line, RiFileImageLine, RiFileList2Line, RiFilePdfLine, RiFileSearchLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiPencilAiLine, RiSearchLine, RiTaskLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine, RiBrainAi3Line, RiCloseLine, RiFileImageLine, RiFileList2Line, RiFilePdfLine, RiFileSearchLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiPencilAiLine, RiSearchLine, RiTaskLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SimpleMarkdownRenderer } from '../MarkdownRenderer';
|
||||
@@ -81,9 +82,285 @@ const getToolIcon = (toolName: string) => {
|
||||
return <RiToolsLine className={iconClass} />;
|
||||
};
|
||||
|
||||
const IMAGE_PREVIEW_ANIMATION_MS = 150;
|
||||
|
||||
const ImagePreviewDialog: React.FC<{
|
||||
popup: ToolPopupContent;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
}> = ({ popup, onOpenChange, isMobile }) => {
|
||||
const gallery = React.useMemo(() => {
|
||||
const baseImage = popup.image;
|
||||
if (!baseImage) return [] as Array<{ url: string; mimeType?: string; filename?: string; size?: number }>;
|
||||
const fromPopup = Array.isArray(baseImage.gallery)
|
||||
? baseImage.gallery.filter((item): item is { url: string; mimeType?: string; filename?: string; size?: number } => Boolean(item?.url))
|
||||
: [];
|
||||
|
||||
if (fromPopup.length > 0) {
|
||||
return fromPopup;
|
||||
}
|
||||
|
||||
return [{
|
||||
url: baseImage.url,
|
||||
mimeType: baseImage.mimeType,
|
||||
filename: baseImage.filename,
|
||||
size: baseImage.size,
|
||||
}];
|
||||
}, [popup.image]);
|
||||
|
||||
const [currentIndex, setCurrentIndex] = React.useState(0);
|
||||
const [imageNaturalSize, setImageNaturalSize] = React.useState<{ width: number; height: number } | null>(null);
|
||||
const [isRendered, setIsRendered] = React.useState(popup.open);
|
||||
const [isVisible, setIsVisible] = React.useState(popup.open);
|
||||
const [isTransitioning, setIsTransitioning] = React.useState(false);
|
||||
const [viewport, setViewport] = React.useState<{ width: number; height: number }>({
|
||||
width: typeof window !== 'undefined' ? window.innerWidth : 0,
|
||||
height: typeof window !== 'undefined' ? window.innerHeight : 0,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!popup.open || gallery.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedIndex = typeof popup.image?.index === 'number' ? popup.image.index : -1;
|
||||
if (requestedIndex >= 0 && requestedIndex < gallery.length) {
|
||||
setCurrentIndex(requestedIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
const matchingIndex = popup.image?.url
|
||||
? gallery.findIndex((item) => item.url === popup.image?.url)
|
||||
: -1;
|
||||
setCurrentIndex(matchingIndex >= 0 ? matchingIndex : 0);
|
||||
}, [gallery, popup.image?.index, popup.image?.url, popup.open]);
|
||||
|
||||
const currentImage = gallery[currentIndex] ?? gallery[0] ?? popup.image;
|
||||
const imageTitle = currentImage?.filename || popup.title || 'Image preview';
|
||||
const hasMultipleImages = gallery.length > 1;
|
||||
|
||||
const showPrevious = React.useCallback(() => {
|
||||
if (gallery.length <= 1) return;
|
||||
setCurrentIndex((prev) => (prev - 1 + gallery.length) % gallery.length);
|
||||
}, [gallery.length]);
|
||||
|
||||
const showNext = React.useCallback(() => {
|
||||
if (gallery.length <= 1) return;
|
||||
setCurrentIndex((prev) => (prev + 1) % gallery.length);
|
||||
}, [gallery.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (popup.open) {
|
||||
setIsRendered(true);
|
||||
setIsTransitioning(true);
|
||||
if (typeof window === 'undefined') {
|
||||
setIsVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const raf = window.requestAnimationFrame(() => {
|
||||
setIsVisible(true);
|
||||
});
|
||||
|
||||
const doneId = window.setTimeout(() => {
|
||||
setIsTransitioning(false);
|
||||
}, IMAGE_PREVIEW_ANIMATION_MS);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(raf);
|
||||
window.clearTimeout(doneId);
|
||||
};
|
||||
}
|
||||
|
||||
setIsVisible(false);
|
||||
setIsTransitioning(true);
|
||||
if (typeof window === 'undefined') {
|
||||
setIsRendered(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setIsRendered(false);
|
||||
setIsTransitioning(false);
|
||||
}, IMAGE_PREVIEW_ANIMATION_MS);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [popup.open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!popup.open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowLeft' && hasMultipleImages) {
|
||||
event.preventDefault();
|
||||
showPrevious();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowRight' && hasMultipleImages) {
|
||||
event.preventDefault();
|
||||
showNext();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [hasMultipleImages, onOpenChange, popup.open, showNext, showPrevious]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!popup.open || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const onResize = () => {
|
||||
setViewport({ width: window.innerWidth, height: window.innerHeight });
|
||||
};
|
||||
|
||||
onResize();
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
}, [popup.open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setImageNaturalSize(null);
|
||||
}, [currentImage?.url]);
|
||||
|
||||
const imageDisplaySize = React.useMemo(() => {
|
||||
const maxWidth = Math.max(160, viewport.width * (isMobile ? 0.86 : 0.75));
|
||||
const maxHeight = Math.max(160, viewport.height * (isMobile ? 0.72 : 0.75));
|
||||
|
||||
if (!imageNaturalSize) {
|
||||
return {
|
||||
width: Math.round(maxWidth),
|
||||
height: Math.round(maxHeight),
|
||||
};
|
||||
}
|
||||
|
||||
const widthScale = maxWidth / imageNaturalSize.width;
|
||||
const heightScale = maxHeight / imageNaturalSize.height;
|
||||
const scale = Math.min(widthScale, heightScale);
|
||||
|
||||
return {
|
||||
width: Math.max(1, Math.round(imageNaturalSize.width * scale)),
|
||||
height: Math.max(1, Math.round(imageNaturalSize.height * scale)),
|
||||
};
|
||||
}, [imageNaturalSize, isMobile, viewport.height, viewport.width]);
|
||||
|
||||
if (!isRendered || !currentImage || typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = (
|
||||
<div className={cn('fixed inset-0 z-50', popup.open ? 'pointer-events-auto' : 'pointer-events-none')}>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'absolute inset-0 bg-black/25 backdrop-blur-md',
|
||||
isTransitioning && 'transition-opacity duration-150 ease-out',
|
||||
isVisible ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
onMouseDown={() => onOpenChange(false)}
|
||||
/>
|
||||
|
||||
{hasMultipleImages && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={showPrevious}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 h-10 w-10 flex items-center justify-center rounded-full bg-black/25 text-foreground/90 backdrop-blur-sm hover:bg-black/35 focus:outline-none focus:ring-2 focus:ring-primary/60"
|
||||
aria-label="Previous image"
|
||||
>
|
||||
<RiArrowLeftSLine className="h-6 w-6" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={showNext}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 h-10 w-10 flex items-center justify-center rounded-full bg-black/25 text-foreground/90 backdrop-blur-sm hover:bg-black/35 focus:outline-none focus:ring-2 focus:ring-primary/60"
|
||||
aria-label="Next image"
|
||||
>
|
||||
<RiArrowRightSLine className="h-6 w-6" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center justify-center pointer-events-none',
|
||||
isMobile ? 'p-2.5' : 'p-4'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-auto flex flex-col gap-2',
|
||||
isTransitioning && 'transition-opacity duration-150 ease-out',
|
||||
isVisible ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
style={{ width: `${imageDisplaySize.width}px` }}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1 text-foreground typography-ui-header font-semibold truncate" title={imageTitle}>
|
||||
{imageTitle}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 w-8 flex items-center justify-center rounded-lg text-muted-foreground/80 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-primary/60"
|
||||
onClick={() => onOpenChange(false)}
|
||||
aria-label="Close image preview"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<img
|
||||
src={currentImage.url}
|
||||
alt={imageTitle}
|
||||
className="block object-contain"
|
||||
style={{ width: `${imageDisplaySize.width}px`, height: `${imageDisplaySize.height}px` }}
|
||||
loading="lazy"
|
||||
onLoad={(event) => {
|
||||
const element = event.currentTarget;
|
||||
const width = element.naturalWidth;
|
||||
const height = element.naturalHeight;
|
||||
if (width > 0 && height > 0) {
|
||||
setImageNaturalSize((previous) => {
|
||||
if (previous && previous.width === width && previous.height === height) {
|
||||
return previous;
|
||||
}
|
||||
return { width, height };
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(content, document.body);
|
||||
};
|
||||
|
||||
const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange, syntaxTheme, isMobile }) => {
|
||||
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>(isMobile ? 'unified' : 'side-by-side');
|
||||
|
||||
if (popup.image) {
|
||||
return <ImagePreviewDialog popup={popup} onOpenChange={onOpenChange} isMobile={isMobile} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={popup.open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
@@ -385,24 +662,6 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
) : popup.image ? (
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="max-h-[70vh] overflow-hidden rounded-2xl border border-border/40 bg-muted/10">
|
||||
<img
|
||||
src={popup.image.url}
|
||||
alt={popup.image.filename || popup.title || 'Image preview'}
|
||||
className="block h-full max-h-[70vh] w-auto max-w-full object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
{popup.image.filename && (
|
||||
<span className="typography-meta text-muted-foreground text-center">
|
||||
{popup.image.filename}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : popup.content ? (
|
||||
<div className="p-4">
|
||||
{(() => {
|
||||
|
||||
Reference in New Issue
Block a user