| ... | @@ -74,6 +74,8 @@ import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; | ... | @@ -74,6 +74,8 @@ import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; |
| 74 | import { | 74 | import { |
| 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 | } |
| 168 | | 170 | |
| 169 | interface ExpandedImagePreview { | 171 | interface ExpandedImageItem { |
| 170 | src: string; | 172 | src: string; |
| 171 | name: string; | 173 | name: string; |
| 172 | } | 174 | } |
| 173 | | 175 | |
| | 176 | interface ExpandedImagePreview { |
| | 177 | images: ExpandedImageItem[]; |
| | 178 | index: number; |
| | 179 | } |
| | 180 | |
| | 181 | function 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 | |
| 174 | type ComposerCommandItem = | 201 | type 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]); |
| 1073 | | 1100 | |
| 1074 | /** | 1101 | const closeExpandedImage = useCallback(() => { |
| 1075 | * Close expanded image on Escape key | 1102 | 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 | } |
| 1081 | | 1122 | |
| 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 | }; |
| 1086 | | 1144 | |
| 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]); |
| 1090 | | 1148 | |
| 1091 | const activeWorktreePath = activeThread?.worktreePath; | 1149 | const activeWorktreePath = activeThread?.worktreePath; |
| 1092 | | 1150 | |
| ... | @@ -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 | })()} |
| 2116 | | 2177 | |
| 2117 | {expandedImage && ( | 2178 | {expandedImage && expandedImageItem && ( |
| 2118 | <div | 2179 | <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 | <div | 2201 | <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 | <img | 2215 | <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 | } |
| 2368 | | 2462 | |
| ... | @@ -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"> |