diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6325f5c6b57f20d523739a02892259821d082f45..8a7b9e48bd3b7bd5ff200db9d8d2e43a1869fbe1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -74,6 +74,8 @@ import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { BotIcon, ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, CircleAlertIcon, FileIcon, FolderIcon, @@ -166,11 +168,36 @@ interface ComposerImageAttachment extends Omit, + selectedImageId: string, +): ExpandedImagePreview | null { + const previewableImages = images.flatMap((image) => + image.previewUrl ? [{ id: image.id, src: image.previewUrl, name: image.name }] : [], + ); + if (previewableImages.length === 0) { + return null; + } + const selectedIndex = previewableImages.findIndex((image) => image.id === selectedImageId); + if (selectedIndex < 0) { + return null; + } + return { + images: previewableImages.map((image) => ({ src: image.src, name: image.name })), + index: selectedIndex, + }; +} + type ComposerCommandItem = | { id: string; @@ -1071,22 +1098,53 @@ export default function ChatView({ threadId }: ChatViewProps) { }; }, [revokePreviewUrls]); - /** - * Close expanded image on Escape key - */ + const closeExpandedImage = useCallback(() => { + setExpandedImage(null); + }, []); + const navigateExpandedImage = useCallback((direction: -1 | 1) => { + setExpandedImage((existing) => { + if (!existing || existing.images.length <= 1) { + return existing; + } + const nextIndex = + (existing.index + direction + existing.images.length) % existing.images.length; + if (nextIndex === existing.index) { + return existing; + } + return { ...existing, index: nextIndex }; + }); + }, []); + useEffect(() => { if (!expandedImage) { return; } const onKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.key !== "Escape") return; - setExpandedImage(null); + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + closeExpandedImage(); + return; + } + if (expandedImage.images.length <= 1) { + return; + } + if (event.key === "ArrowLeft") { + event.preventDefault(); + event.stopPropagation(); + navigateExpandedImage(-1); + return; + } + if (event.key !== "ArrowRight") return; + event.preventDefault(); + event.stopPropagation(); + navigateExpandedImage(1); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [expandedImage]); + }, [closeExpandedImage, expandedImage, navigateExpandedImage]); const activeWorktreePath = activeThread?.worktreePath; @@ -1723,9 +1781,10 @@ export default function ChatView({ threadId }: ChatViewProps) { [groupId]: !existing[groupId], })); }, []); - const onExpandTimelineImage = useCallback((image: ExpandedImagePreview) => { - setExpandedImage(image); + const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { + setExpandedImage(preview); }, []); + const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null; const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { void navigate({ @@ -1888,9 +1947,11 @@ export default function ChatView({ threadId }: ChatViewProps) { src={image.previewUrl} alt={image.name} className="h-full w-full cursor-zoom-in object-cover" - onClick={() => - setExpandedImage({ src: image.previewUrl, name: image.name }) - } + onClick={() => { + const preview = buildExpandedImagePreview(composerImages, image.id); + if (!preview) return; + setExpandedImage(preview); + }} /> ) : (
@@ -2114,14 +2175,29 @@ export default function ChatView({ threadId }: ChatViewProps) { ); })()} - {expandedImage && ( + {expandedImage && expandedImageItem && (
setExpandedImage(null)} + onClick={closeExpandedImage} > + {expandedImage.images.length > 1 && ( + + )}
event.stopPropagation()} @@ -2131,21 +2207,39 @@ export default function ChatView({ threadId }: ChatViewProps) { size="icon-xs" variant="ghost" className="absolute right-2 top-2" - onClick={() => setExpandedImage(null)} + onClick={closeExpandedImage} aria-label="Close image preview" > {expandedImage.name}

- {expandedImage.name} + {expandedImageItem.name} + {expandedImage.images.length > 1 + ? ` (${expandedImage.index + 1}/${expandedImage.images.length})` + : ""}

+ {expandedImage.images.length > 1 && ( + + )}
)}
@@ -2362,7 +2456,7 @@ interface MessagesTimelineProps { revertTurnCountByUserMessageId: Map; onRevertUserMessage: (messageId: MessageId) => void; isRevertingCheckpoint: boolean; - onImageExpand: (image: ExpandedImagePreview) => void; + onImageExpand: (preview: ExpandedImagePreview) => void; markdownCwd: string | undefined; } @@ -2603,9 +2697,11 @@ const MessagesTimeline = memo(function MessagesTimeline({ src={image.previewUrl} alt={image.name} className="h-full max-h-[220px] w-full cursor-zoom-in object-cover" - onClick={() => - onImageExpand({ src: image.previewUrl!, name: image.name }) - } + onClick={() => { + const preview = buildExpandedImagePreview(userImages, image.id); + if (!preview) return; + onImageExpand(preview); + }} /> ) : (