diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d66d2487ce3..106d163036d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -147,7 +147,7 @@ import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; -import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; +import { getExpandedImagePreviewKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; @@ -3773,7 +3773,11 @@ export default function ChatView(props: ChatViewProps) { ) : null} {expandedImage && ( - + )} ); diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx index 10031b48cde..26ae8b6fefc 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx @@ -8,27 +8,20 @@ interface ExpandedImageDialogProps { onClose: () => void; } -export const ExpandedImageDialog = memo(function ExpandedImageDialog({ - preview: initialPreview, - onClose, -}: ExpandedImageDialogProps) { - const [preview, setPreview] = useState(initialPreview); - - // Sync when the parent hands us a new preview reference. - useEffect(() => { - setPreview(initialPreview); - }, [initialPreview]); - - const navigateImage = useCallback((direction: -1 | 1) => { - setPreview((existing) => { - if (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 }; - }); - }, []); +function clampImageIndex(index: number, imageCount: number): number { + if (imageCount <= 0) return 0; + return Math.min(Math.max(index, 0), imageCount - 1); +} +function useExpandedImageDialogKeyboardNavigation({ + imageCount, + navigateImage, + onClose, +}: { + imageCount: number; + navigateImage: (direction: -1 | 1) => void; + onClose: () => void; +}) { useEffect(() => { const onKeyDown = (event: globalThis.KeyboardEvent) => { if (event.key === "Escape") { @@ -37,7 +30,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ onClose(); return; } - if (preview.images.length <= 1) return; + if (imageCount <= 1) return; if (event.key === "ArrowLeft") { event.preventDefault(); event.stopPropagation(); @@ -51,9 +44,40 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [navigateImage, onClose, preview.images.length]); + }, [imageCount, navigateImage, onClose]); +} + +export const ExpandedImageDialog = memo(function ExpandedImageDialog({ + preview, + onClose, +}: ExpandedImageDialogProps) { + const imageCount = preview.images.length; + const [imageIndex, setImageIndex] = useState(() => clampImageIndex(preview.index, imageCount)); + const activeImageIndex = clampImageIndex(imageIndex, imageCount); + + const navigateImage = useCallback( + (direction: -1 | 1) => { + setImageIndex((existing) => { + if (imageCount <= 1) return existing; + return (existing + direction + imageCount) % imageCount; + }); + }, + [imageCount], + ); + const navigateToPreviousImage = useCallback(() => { + navigateImage(-1); + }, [navigateImage]); + const navigateToNextImage = useCallback(() => { + navigateImage(1); + }, [navigateImage]); + + useExpandedImageDialogKeyboardNavigation({ + imageCount, + navigateImage, + onClose, + }); - const item = preview.images[preview.index]; + const item = preview.images[activeImageIndex]; if (!item) return null; return ( @@ -69,14 +93,14 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ aria-label="Close image preview" onClick={onClose} /> - {preview.images.length > 1 && ( + {imageCount > 1 && ( @@ -100,17 +124,17 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ />

{item.name} - {preview.images.length > 1 ? ` (${preview.index + 1}/${preview.images.length})` : ""} + {imageCount > 1 ? ` (${activeImageIndex + 1}/${imageCount})` : ""}

- {preview.images.length > 1 && ( + {imageCount > 1 && ( diff --git a/apps/web/src/components/chat/ExpandedImagePreview.tsx b/apps/web/src/components/chat/ExpandedImagePreview.tsx index db5803d4908..9a8276076ec 100644 --- a/apps/web/src/components/chat/ExpandedImagePreview.tsx +++ b/apps/web/src/components/chat/ExpandedImagePreview.tsx @@ -8,6 +8,11 @@ export interface ExpandedImagePreview { index: number; } +export function getExpandedImagePreviewKey(preview: ExpandedImagePreview): string { + const selectedImage = preview.images[preview.index]; + return `${preview.index}:${preview.images.length}:${selectedImage?.src ?? ""}`; +} + export function buildExpandedImagePreview( images: ReadonlyArray<{ id: string; name: string; previewUrl?: string }>, selectedImageId: string, diff --git a/docs/performance/expanded-image-dialog-react-scan-after.webm b/docs/performance/expanded-image-dialog-react-scan-after.webm new file mode 100644 index 00000000000..c8c7fb5d9f7 Binary files /dev/null and b/docs/performance/expanded-image-dialog-react-scan-after.webm differ diff --git a/docs/performance/expanded-image-dialog-react-scan-before.webm b/docs/performance/expanded-image-dialog-react-scan-before.webm new file mode 100644 index 00000000000..a7c3c73de5a Binary files /dev/null and b/docs/performance/expanded-image-dialog-react-scan-before.webm differ