authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-08-30 17:48:54-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-30 17:48:54-04:00
log8f525af5afa93dff58775030d9b503d3bda36b7b
tree0bf2469f69846e4772412c1a956e1431aba006b7
parent12fe2d6d03062c5d7fc3beff168f8c4af1235b50
signature Signed by PGP key B5690EEEBB952194

fix(web): open agent images in expanded preview (#8807)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

2 files changed, 51 insertions(+), 6 deletions(-)

apps/web/src/components/ChatMarkdown.tsx+50-6
...@@ -35,6 +35,7 @@ import React, {...@@ -35,6 +35,7 @@ import React, {
35 Suspense,35 Suspense,
36 type CSSProperties,36 type CSSProperties,
37 type ClipboardEvent as ReactClipboardEvent,37 type ClipboardEvent as ReactClipboardEvent,
38 type KeyboardEvent as ReactKeyboardEvent,
38 type MouseEvent as ReactMouseEvent,39 type MouseEvent as ReactMouseEvent,
39 isValidElement,40 isValidElement,
40 use,41 use,
...@@ -55,6 +56,7 @@ import remarkBreaks from "remark-breaks";...@@ -55,6 +56,7 @@ import remarkBreaks from "remark-breaks";
55import remarkGfm from "remark-gfm";56import remarkGfm from "remark-gfm";
56import { remarkGithubAlerts } from "../markdown-github-alerts";57import { remarkGithubAlerts } from "../markdown-github-alerts";
57import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText";58import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText";
59import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
58import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";60import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";
59import { PierreEntryIcon } from "./chat/PierreEntryIcon";61import { PierreEntryIcon } from "./chat/PierreEntryIcon";
60import {62import {
...@@ -155,6 +157,7 @@ interface ChatMarkdownProps {...@@ -155,6 +157,7 @@ interface ChatMarkdownProps {
155 /** Parse sanitized raw HTML instead of displaying its source text. */157 /** Parse sanitized raw HTML instead of displaying its source text. */
156 parseRawHtml?: boolean;158 parseRawHtml?: boolean;
157 imageBaseDir?: string | undefined;159 imageBaseDir?: string | undefined;
160 onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
158}161}
159162
160export function canUseMarkdownFileShellActions(163export function canUseMarkdownFileShellActions(
...@@ -1089,6 +1092,31 @@ const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn(...@@ -1089,6 +1092,31 @@ const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn(
1089 CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME,1092 CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME,
1090 "rounded-lg border border-border/40",1093 "rounded-lg border border-border/40",
1091);1094);
1095const MarkdownLinkContext = React.createContext(false);
1096
1097function expandableMarkdownImageProps(
1098 onImageExpand: ((preview: ExpandedImagePreview) => void) | undefined,
1099 src: string,
1100 alt: string,
1101) {
1102 if (!onImageExpand) return {};
1103 const previewName = alt.trim() || "image";
1104 const expand = (event: ReactMouseEvent | ReactKeyboardEvent) => {
1105 if (event.currentTarget.closest("a")) return;
1106 event.preventDefault();
1107 event.stopPropagation();
1108 onImageExpand({ images: [{ src, name: previewName }], index: 0 });
1109 };
1110 return {
1111 role: "button" as const,
1112 tabIndex: 0,
1113 "aria-label": `Preview ${previewName}`,
1114 onClick: expand,
1115 onKeyDown: (event: ReactKeyboardEvent) => {
1116 if (event.key === "Enter" || event.key === " ") expand(event);
1117 },
1118 };
1119}
10921120
1093function ChatMarkdownImageFallback(props: {1121function ChatMarkdownImageFallback(props: {
1094 readonly alt: string;1122 readonly alt: string;
...@@ -1118,6 +1146,7 @@ const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(prop...@@ -1118,6 +1146,7 @@ const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(prop
1118 readonly copyMarkdown: string;1146 readonly copyMarkdown: string;
1119 readonly srcFragment: string;1147 readonly srcFragment: string;
1120 readonly style?: CSSProperties | undefined;1148 readonly style?: CSSProperties | undefined;
1149 readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
1121}) {1150}) {
1122 const assetUrl = useAssetUrlState(props.threadRef.environmentId, {1151 const assetUrl = useAssetUrlState(props.threadRef.environmentId, {
1123 _tag: "workspace-file",1152 _tag: "workspace-file",
...@@ -1144,15 +1173,20 @@ const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(prop...@@ -1144,15 +1173,20 @@ const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(prop
1144 />1173 />
1145 );1174 );
1146 }1175 }
1176 const src = assetUrl.url + props.srcFragment;
1147 return (1177 return (
1148 <img1178 <img
1149 src={assetUrl.url + props.srcFragment}1179 src={src}
1150 alt={props.alt}1180 alt={props.alt}
1151 data-markdown-copy={props.copyMarkdown}1181 data-markdown-copy={props.copyMarkdown}
1152 loading="lazy"1182 loading="lazy"
1153 draggable={false}1183 draggable={false}
1154 className={CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME}1184 className={cn(
1185 CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME,
1186 props.onImageExpand && "cursor-zoom-in",
1187 )}
1155 style={props.style}1188 style={props.style}
1189 {...expandableMarkdownImageProps(props.onImageExpand, src, props.alt)}
1156 onError={() => setFailedUrl(assetUrl.url)}1190 onError={() => setFailedUrl(assetUrl.url)}
1157 />1191 />
1158 );1192 );
...@@ -1694,6 +1728,7 @@ function ChatMarkdown({...@@ -1694,6 +1728,7 @@ function ChatMarkdown({
1694 lineBreaks = false,1728 lineBreaks = false,
1695 parseRawHtml = true,1729 parseRawHtml = true,
1696 imageBaseDir,1730 imageBaseDir,
1731 onImageExpand,
1697}: ChatMarkdownProps) {1732}: ChatMarkdownProps) {
1698 const { resolvedTheme } = useTheme();1733 const { resolvedTheme } = useTheme();
1699 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {1734 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
...@@ -2084,6 +2119,7 @@ function ChatMarkdown({...@@ -2084,6 +2119,7 @@ function ChatMarkdown({
2084 const isSameDocumentLink = href?.startsWith("#") ?? false;2119 const isSameDocumentLink = href?.startsWith("#") ?? false;
2085 const onClick = props.onClick;2120 const onClick = props.onClick;
2086 const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime();2121 const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime();
2122 const linkChildren = <MarkdownLinkContext value>{children}</MarkdownLinkContext>;
2087 const link = (2123 const link = (
2088 <a2124 <a
2089 {...props}2125 {...props}
...@@ -2160,10 +2196,10 @@ function ChatMarkdown({...@@ -2160,10 +2196,10 @@ function ChatMarkdown({
2160 >2196 >
2161 {faviconHost && hastHasText(node) ? (2197 {faviconHost && hastHasText(node) ? (
2162 <MarkdownExternalLinkContent host={faviconHost} plainText={plainHastText(node)}>2198 <MarkdownExternalLinkContent host={faviconHost} plainText={plainHastText(node)}>
2163 {children}2199 {linkChildren}
2164 </MarkdownExternalLinkContent>2200 </MarkdownExternalLinkContent>
2165 ) : (2201 ) : (
2166 children2202 linkChildren
2167 )}2203 )}
2168 </a>2204 </a>
2169 );2205 );
...@@ -2205,7 +2241,8 @@ function ChatMarkdown({...@@ -2205,7 +2241,8 @@ function ChatMarkdown({
2205 </code>2241 </code>
2206 );2242 );
2207 },2243 },
2208 img({ node, title, src, alt, ...props }) {2244 img: function MarkdownImage({ node, title, src, alt, ...props }) {
2245 const imageExpand = use(MarkdownLinkContext) ? undefined : onImageExpand;
2209 const localSrc = node?.properties?.dataLocalSrc;2246 const localSrc = node?.properties?.dataLocalSrc;
2210 const markdownTitle = node?.properties?.dataMarkdownTitle;2247 const markdownTitle = node?.properties?.dataMarkdownTitle;
2211 const authoredSrc = typeof localSrc === "string" ? localSrc : src;2248 const authoredSrc = typeof localSrc === "string" ? localSrc : src;
...@@ -2225,8 +2262,13 @@ function ChatMarkdown({...@@ -2225,8 +2262,13 @@ function ChatMarkdown({
2225 src={imageSource.uri}2262 src={imageSource.uri}
2226 alt={altText}2263 alt={altText}
2227 loading="lazy"2264 loading="lazy"
2228 className={cn(props.className, CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME)}2265 className={cn(
2266 props.className,
2267 CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME,
2268 imageExpand && "cursor-zoom-in",
2269 )}
2229 style={authoredSizeStyle}2270 style={authoredSizeStyle}
2271 {...expandableMarkdownImageProps(imageExpand, imageSource.uri, altText)}
2230 />2272 />
2231 );2273 );
2232 }2274 }
...@@ -2239,6 +2281,7 @@ function ChatMarkdown({...@@ -2239,6 +2281,7 @@ function ChatMarkdown({
2239 copyMarkdown={copyMarkdown}2281 copyMarkdown={copyMarkdown}
2240 srcFragment={markdownImageSourceFragment(classifiedSrc)}2282 srcFragment={markdownImageSourceFragment(classifiedSrc)}
2241 style={authoredSizeStyle}2283 style={authoredSizeStyle}
2284 onImageExpand={imageExpand}
2242 />2285 />
2243 );2286 );
2244 }2287 }
...@@ -2289,6 +2332,7 @@ function ChatMarkdown({...@@ -2289,6 +2332,7 @@ function ChatMarkdown({
2289 isStreaming,2332 isStreaming,
2290 markdownFileLinkMetaByHref,2333 markdownFileLinkMetaByHref,
2291 onTaskListChange,2334 onTaskListChange,
2335 onImageExpand,
2292 openFileInPanel,2336 openFileInPanel,
2293 openInPreferredEditor,2337 openInPreferredEditor,
2294 openChangeRequestLink,2338 openChangeRequestLink,
apps/web/src/components/chat/MessagesTimeline.tsx+1
...@@ -1253,6 +1253,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess...@@ -1253,6 +1253,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
1253 isStreaming={Boolean(row.message.streaming)}1253 isStreaming={Boolean(row.message.streaming)}
1254 lineBreaks={shouldPreserveAssistantLineBreaks(messageText)}1254 lineBreaks={shouldPreserveAssistantLineBreaks(messageText)}
1255 skills={ctx.skills}1255 skills={ctx.skills}
1256 onImageExpand={ctx.onImageExpand}
1256 />1257 />
1257 <AssistantChangedFilesSection1258 <AssistantChangedFilesSection
1258 turnSummary={row.assistantTurnDiffSummary}1259 turnSummary={row.assistantTurnDiffSummary}