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