authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-27 20:16:04-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-27 21:30:46-08:00
log2b154db9385e505080578903b9aaffac92514f7a
treea84d67c3cb2ffa816c2197c3069e35092ec1f876
parentb9ff7aa5cdf10c15cecb803f6674279c58ccdb4c

Add gallery navigation to expanded chat image previews

- Open preview from the full image set instead of a single image - Add previous/next controls with ArrowLeft/ArrowRight keyboard support - Keep Escape-to-close behavior and show current image position

1 files changed, 118 insertions(+), 22 deletions(-)

apps/web/src/components/ChatView.tsx+118-22
...@@ -74,6 +74,8 @@ import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";...@@ -74,6 +74,8 @@ import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
74import {74import {
75 BotIcon,75 BotIcon,
76 ChevronDownIcon,76 ChevronDownIcon,
77 ChevronLeftIcon,
78 ChevronRightIcon,
77 CircleAlertIcon,79 CircleAlertIcon,
78 FileIcon,80 FileIcon,
79 FolderIcon,81 FolderIcon,
...@@ -166,11 +168,36 @@ interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"...@@ -166,11 +168,36 @@ interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"
166 file: File;168 file: File;
167}169}
168170
169interface ExpandedImagePreview {171interface ExpandedImageItem {
170 src: string;172 src: string;
171 name: string;173 name: string;
172}174}
173175
176interface ExpandedImagePreview {
177 images: ExpandedImageItem[];
178 index: number;
179}
180
181function buildExpandedImagePreview(
182 images: ReadonlyArray<{ id: string; name: string; previewUrl?: string }>,
183 selectedImageId: string,
184): ExpandedImagePreview | null {
185 const previewableImages = images.flatMap((image) =>
186 image.previewUrl ? [{ id: image.id, src: image.previewUrl, name: image.name }] : [],
187 );
188 if (previewableImages.length === 0) {
189 return null;
190 }
191 const selectedIndex = previewableImages.findIndex((image) => image.id === selectedImageId);
192 if (selectedIndex < 0) {
193 return null;
194 }
195 return {
196 images: previewableImages.map((image) => ({ src: image.src, name: image.name })),
197 index: selectedIndex,
198 };
199}
200
174type ComposerCommandItem =201type ComposerCommandItem =
175 | {202 | {
176 id: string;203 id: string;
...@@ -1071,22 +1098,53 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1071,22 +1098,53 @@ export default function ChatView({ threadId }: ChatViewProps) {
1071 };1098 };
1072 }, [revokePreviewUrls]);1099 }, [revokePreviewUrls]);
10731100
1074 /**1101 const closeExpandedImage = useCallback(() => {
1075 * Close expanded image on Escape key1102 setExpandedImage(null);
1076 */1103 }, []);
1104 const navigateExpandedImage = useCallback((direction: -1 | 1) => {
1105 setExpandedImage((existing) => {
1106 if (!existing || existing.images.length <= 1) {
1107 return existing;
1108 }
1109 const nextIndex =
1110 (existing.index + direction + existing.images.length) % existing.images.length;
1111 if (nextIndex === existing.index) {
1112 return existing;
1113 }
1114 return { ...existing, index: nextIndex };
1115 });
1116 }, []);
1117
1077 useEffect(() => {1118 useEffect(() => {
1078 if (!expandedImage) {1119 if (!expandedImage) {
1079 return;1120 return;
1080 }1121 }
10811122
1082 const onKeyDown = (event: globalThis.KeyboardEvent) => {1123 const onKeyDown = (event: globalThis.KeyboardEvent) => {
1083 if (event.key !== "Escape") return;1124 if (event.key === "Escape") {
1084 setExpandedImage(null);1125 event.preventDefault();
1126 event.stopPropagation();
1127 closeExpandedImage();
1128 return;
1129 }
1130 if (expandedImage.images.length <= 1) {
1131 return;
1132 }
1133 if (event.key === "ArrowLeft") {
1134 event.preventDefault();
1135 event.stopPropagation();
1136 navigateExpandedImage(-1);
1137 return;
1138 }
1139 if (event.key !== "ArrowRight") return;
1140 event.preventDefault();
1141 event.stopPropagation();
1142 navigateExpandedImage(1);
1085 };1143 };
10861144
1087 window.addEventListener("keydown", onKeyDown);1145 window.addEventListener("keydown", onKeyDown);
1088 return () => window.removeEventListener("keydown", onKeyDown);1146 return () => window.removeEventListener("keydown", onKeyDown);
1089 }, [expandedImage]);1147 }, [closeExpandedImage, expandedImage, navigateExpandedImage]);
10901148
1091 const activeWorktreePath = activeThread?.worktreePath;1149 const activeWorktreePath = activeThread?.worktreePath;
10921150
...@@ -1723,9 +1781,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1723,9 +1781,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
1723 [groupId]: !existing[groupId],1781 [groupId]: !existing[groupId],
1724 }));1782 }));
1725 }, []);1783 }, []);
1726 const onExpandTimelineImage = useCallback((image: ExpandedImagePreview) => {1784 const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => {
1727 setExpandedImage(image);1785 setExpandedImage(preview);
1728 }, []);1786 }, []);
1787 const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null;
1729 const onOpenTurnDiff = useCallback(1788 const onOpenTurnDiff = useCallback(
1730 (turnId: TurnId, filePath?: string) => {1789 (turnId: TurnId, filePath?: string) => {
1731 void navigate({1790 void navigate({
...@@ -1888,9 +1947,11 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1888,9 +1947,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
1888 src={image.previewUrl}1947 src={image.previewUrl}
1889 alt={image.name}1948 alt={image.name}
1890 className="h-full w-full cursor-zoom-in object-cover"1949 className="h-full w-full cursor-zoom-in object-cover"
1891 onClick={() =>1950 onClick={() => {
1892 setExpandedImage({ src: image.previewUrl, name: image.name })1951 const preview = buildExpandedImagePreview(composerImages, image.id);
1893 }1952 if (!preview) return;
1953 setExpandedImage(preview);
1954 }}
1894 />1955 />
1895 ) : (1956 ) : (
1896 <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70">1957 <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70">
...@@ -2114,14 +2175,29 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2114,14 +2175,29 @@ export default function ChatView({ threadId }: ChatViewProps) {
2114 );2175 );
2115 })()}2176 })()}
21162177
2117 {expandedImage && (2178 {expandedImage && expandedImageItem && (
2118 <div2179 <div
2119 className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 px-4 py-6 [-webkit-app-region:no-drag]"2180 className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 px-4 py-6 [-webkit-app-region:no-drag]"
2120 role="dialog"2181 role="dialog"
2121 aria-modal="true"2182 aria-modal="true"
2122 aria-label="Expanded image preview"2183 aria-label="Expanded image preview"
2123 onClick={() => setExpandedImage(null)}2184 onClick={closeExpandedImage}
2124 >2185 >
2186 {expandedImage.images.length > 1 && (
2187 <Button
2188 type="button"
2189 size="icon"
2190 variant="ghost"
2191 className="absolute left-2 top-1/2 z-10 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:left-6"
2192 aria-label="Previous image"
2193 onClick={(event) => {
2194 event.stopPropagation();
2195 navigateExpandedImage(-1);
2196 }}
2197 >
2198 <ChevronLeftIcon className="size-5" />
2199 </Button>
2200 )}
2125 <div2201 <div
2126 className="relative isolate max-h-[92vh] max-w-[92vw]"2202 className="relative isolate max-h-[92vh] max-w-[92vw]"
2127 onClick={(event) => event.stopPropagation()}2203 onClick={(event) => event.stopPropagation()}
...@@ -2131,21 +2207,39 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2131,21 +2207,39 @@ export default function ChatView({ threadId }: ChatViewProps) {
2131 size="icon-xs"2207 size="icon-xs"
2132 variant="ghost"2208 variant="ghost"
2133 className="absolute right-2 top-2"2209 className="absolute right-2 top-2"
2134 onClick={() => setExpandedImage(null)}2210 onClick={closeExpandedImage}
2135 aria-label="Close image preview"2211 aria-label="Close image preview"
2136 >2212 >
2137 <XIcon />2213 <XIcon />
2138 </Button>2214 </Button>
2139 <img2215 <img
2140 src={expandedImage.src}2216 src={expandedImageItem.src}
2141 alt={expandedImage.name}2217 alt={expandedImageItem.name}
2142 className="max-h-[86vh] max-w-[92vw] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl"2218 className="max-h-[86vh] max-w-[92vw] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl"
2143 draggable={false}2219 draggable={false}
2144 />2220 />
2145 <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80">2221 <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80">
2146 {expandedImage.name}2222 {expandedImageItem.name}
2223 {expandedImage.images.length > 1
2224 ? ` (${expandedImage.index + 1}/${expandedImage.images.length})`
2225 : ""}
2147 </p>2226 </p>
2148 </div>2227 </div>
2228 {expandedImage.images.length > 1 && (
2229 <Button
2230 type="button"
2231 size="icon"
2232 variant="ghost"
2233 className="absolute right-2 top-1/2 z-10 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:right-6"
2234 aria-label="Next image"
2235 onClick={(event) => {
2236 event.stopPropagation();
2237 navigateExpandedImage(1);
2238 }}
2239 >
2240 <ChevronRightIcon className="size-5" />
2241 </Button>
2242 )}
2149 </div>2243 </div>
2150 )}2244 )}
2151 </div>2245 </div>
...@@ -2362,7 +2456,7 @@ interface MessagesTimelineProps {...@@ -2362,7 +2456,7 @@ interface MessagesTimelineProps {
2362 revertTurnCountByUserMessageId: Map<MessageId, number>;2456 revertTurnCountByUserMessageId: Map<MessageId, number>;
2363 onRevertUserMessage: (messageId: MessageId) => void;2457 onRevertUserMessage: (messageId: MessageId) => void;
2364 isRevertingCheckpoint: boolean;2458 isRevertingCheckpoint: boolean;
2365 onImageExpand: (image: ExpandedImagePreview) => void;2459 onImageExpand: (preview: ExpandedImagePreview) => void;
2366 markdownCwd: string | undefined;2460 markdownCwd: string | undefined;
2367}2461}
23682462
...@@ -2603,9 +2697,11 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2603,9 +2697,11 @@ const MessagesTimeline = memo(function MessagesTimeline({
2603 src={image.previewUrl}2697 src={image.previewUrl}
2604 alt={image.name}2698 alt={image.name}
2605 className="h-full max-h-[220px] w-full cursor-zoom-in object-cover"2699 className="h-full max-h-[220px] w-full cursor-zoom-in object-cover"
2606 onClick={() =>2700 onClick={() => {
2607 onImageExpand({ src: image.previewUrl!, name: image.name })2701 const preview = buildExpandedImagePreview(userImages, image.id);
2608 }2702 if (!preview) return;
2703 onImageExpand(preview);
2704 }}
2609 />2705 />
2610 ) : (2706 ) : (
2611 <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70">2707 <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70">