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";
7474import {
7575 BotIcon,
7676 ChevronDownIcon,
77 ChevronLeftIcon,
78 ChevronRightIcon,
7779 CircleAlertIcon,
7880 FileIcon,
7981 FolderIcon,
......@@ -166,11 +168,36 @@ interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"
166168 file: File;
167169}
168170
169interface ExpandedImagePreview {
171interface ExpandedImageItem {
170172 src: string;
171173 name: string;
172174}
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
174201type ComposerCommandItem =
175202 | {
176203 id: string;
......@@ -1071,22 +1098,53 @@ export default function ChatView({ threadId }: ChatViewProps) {
10711098 };
10721099 }, [revokePreviewUrls]);
10731100
1074 /**
1075 * Close expanded image on Escape key
1076 */
1101 const closeExpandedImage = useCallback(() => {
1102 setExpandedImage(null);
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
10771118 useEffect(() => {
10781119 if (!expandedImage) {
10791120 return;
10801121 }
10811122
10821123 const onKeyDown = (event: globalThis.KeyboardEvent) => {
1083 if (event.key !== "Escape") return;
1084 setExpandedImage(null);
1124 if (event.key === "Escape") {
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);
10851143 };
10861144
10871145 window.addEventListener("keydown", onKeyDown);
10881146 return () => window.removeEventListener("keydown", onKeyDown);
1089 }, [expandedImage]);
1147 }, [closeExpandedImage, expandedImage, navigateExpandedImage]);
10901148
10911149 const activeWorktreePath = activeThread?.worktreePath;
10921150
......@@ -1723,9 +1781,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
17231781 [groupId]: !existing[groupId],
17241782 }));
17251783 }, []);
1726 const onExpandTimelineImage = useCallback((image: ExpandedImagePreview) => {
1727 setExpandedImage(image);
1784 const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => {
1785 setExpandedImage(preview);
17281786 }, []);
1787 const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null;
17291788 const onOpenTurnDiff = useCallback(
17301789 (turnId: TurnId, filePath?: string) => {
17311790 void navigate({
......@@ -1888,9 +1947,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
18881947 src={image.previewUrl}
18891948 alt={image.name}
18901949 className="h-full w-full cursor-zoom-in object-cover"
1891 onClick={() =>
1892 setExpandedImage({ src: image.previewUrl, name: image.name })
1893 }
1950 onClick={() => {
1951 const preview = buildExpandedImagePreview(composerImages, image.id);
1952 if (!preview) return;
1953 setExpandedImage(preview);
1954 }}
18941955 />
18951956 ) : (
18961957 <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) {
21142175 );
21152176 })()}
21162177
2117 {expandedImage && (
2178 {expandedImage && expandedImageItem && (
21182179 <div
21192180 className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 px-4 py-6 [-webkit-app-region:no-drag]"
21202181 role="dialog"
21212182 aria-modal="true"
21222183 aria-label="Expanded image preview"
2123 onClick={() => setExpandedImage(null)}
2184 onClick={closeExpandedImage}
21242185 >
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 )}
21252201 <div
21262202 className="relative isolate max-h-[92vh] max-w-[92vw]"
21272203 onClick={(event) => event.stopPropagation()}
......@@ -2131,21 +2207,39 @@ export default function ChatView({ threadId }: ChatViewProps) {
21312207 size="icon-xs"
21322208 variant="ghost"
21332209 className="absolute right-2 top-2"
2134 onClick={() => setExpandedImage(null)}
2210 onClick={closeExpandedImage}
21352211 aria-label="Close image preview"
21362212 >
21372213 <XIcon />
21382214 </Button>
21392215 <img
2140 src={expandedImage.src}
2141 alt={expandedImage.name}
2216 src={expandedImageItem.src}
2217 alt={expandedImageItem.name}
21422218 className="max-h-[86vh] max-w-[92vw] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl"
21432219 draggable={false}
21442220 />
21452221 <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 : ""}
21472226 </p>
21482227 </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 )}
21492243 </div>
21502244 )}
21512245 </div>
......@@ -2362,7 +2456,7 @@ interface MessagesTimelineProps {
23622456 revertTurnCountByUserMessageId: Map<MessageId, number>;
23632457 onRevertUserMessage: (messageId: MessageId) => void;
23642458 isRevertingCheckpoint: boolean;
2365 onImageExpand: (image: ExpandedImagePreview) => void;
2459 onImageExpand: (preview: ExpandedImagePreview) => void;
23662460 markdownCwd: string | undefined;
23672461}
23682462
......@@ -2603,9 +2697,11 @@ const MessagesTimeline = memo(function MessagesTimeline({
26032697 src={image.previewUrl}
26042698 alt={image.name}
26052699 className="h-full max-h-[220px] w-full cursor-zoom-in object-cover"
2606 onClick={() =>
2607 onImageExpand({ src: image.previewUrl!, name: image.name })
2608 }
2700 onClick={() => {
2701 const preview = buildExpandedImagePreview(userImages, image.id);
2702 if (!preview) return;
2703 onImageExpand(preview);
2704 }}
26092705 />
26102706 ) : (
26112707 <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70">