diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 3650aa81..ed409314 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -80,6 +80,7 @@ interface IssueChatMessageContext { ) => Promise; onInterruptQueued?: (runId: string) => Promise; interruptingQueuedRunId?: string | null; + onImageClick?: (src: string) => void; } const IssueChatCtx = createContext({ @@ -184,6 +185,7 @@ interface IssueChatThreadProps { includeSucceededRunsWithoutOutput?: boolean; onInterruptQueued?: (runId: string) => Promise; interruptingQueuedRunId?: string | null; + onImageClick?: (src: string) => void; } const DRAFT_DEBOUNCE_MS = 800; @@ -246,8 +248,9 @@ function commentDateLabel(date: Date | string | undefined): string { } function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) { + const { onImageClick } = useContext(IssueChatCtx); return ( - + {text} ); @@ -1604,6 +1607,7 @@ export function IssueChatThread({ includeSucceededRunsWithoutOutput = false, onInterruptQueued, interruptingQueuedRunId = null, + onImageClick, }: IssueChatThreadProps) { const location = useLocation(); const hasScrolledRef = useRef(false); @@ -1731,6 +1735,7 @@ export function IssueChatThread({ onVote, onInterruptQueued, interruptingQueuedRunId, + onImageClick, }), [ feedbackVoteByTargetId, @@ -1741,6 +1746,7 @@ export function IssueChatThread({ onVote, onInterruptQueued, interruptingQueuedRunId, + onImageClick, ], ); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index b5cc12d7..a4542607 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -11,6 +11,8 @@ interface MarkdownBodyProps { style?: React.CSSProperties; /** Optional resolver for relative image paths (e.g. within export packages) */ resolveImageSrc?: (src: string) => string | null; + /** Called when a user clicks an inline image */ + onImageClick?: (src: string) => void; } let mermaidLoaderPromise: Promise | null = null; @@ -92,7 +94,7 @@ function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: b ); } -export function MarkdownBody({ children, className, style, resolveImageSrc }: MarkdownBodyProps) { +export function MarkdownBody({ children, className, style, resolveImageSrc, onImageClick }: MarkdownBodyProps) { const { theme } = useTheme(); const components: Components = { pre: ({ node: _node, children: preChildren, ...preProps }) => { @@ -132,10 +134,19 @@ export function MarkdownBody({ children, className, style, resolveImageSrc }: Ma ); }, }; - if (resolveImageSrc) { + if (resolveImageSrc || onImageClick) { components.img = ({ node: _node, src, alt, ...imgProps }) => { - const resolved = src ? resolveImageSrc(src) : null; - return {alt; + const resolved = resolveImageSrc && src ? resolveImageSrc(src) : null; + const finalSrc = resolved ?? src; + return ( + {alt { e.preventDefault(); onImageClick(finalSrc); } : undefined} + style={onImageClick ? { cursor: "pointer", ...(imgProps.style as React.CSSProperties | undefined) } : imgProps.style as React.CSSProperties | undefined} + /> + ); }; } diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 60b0a9db..066f455b 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1356,6 +1356,28 @@ export function IssueDetail() { const attachmentList = attachments ?? []; const imageAttachments = attachmentList.filter(isImageAttachment); const nonImageAttachments = attachmentList.filter((a) => !isImageAttachment(a)); + + const handleChatImageClick = useCallback( + (src: string) => { + // Try exact contentPath match first + let idx = imageAttachments.findIndex((a) => a.contentPath === src); + if (idx < 0) { + // Try matching by asset ID extracted from /api/assets/{assetId}/content URLs + const assetMatch = src.match(/\/api\/assets\/([^/]+)\/content/); + if (assetMatch) { + idx = imageAttachments.findIndex((a) => a.assetId === assetMatch[1]); + } + } + if (idx >= 0) { + setGalleryIndex(idx); + setGalleryOpen(true); + } else { + // Image not in attachment list — open in new tab + window.open(src, "_blank"); + } + }, + [imageAttachments], + ); const hasAttachments = attachmentList.length > 0; const attachmentUploadButton = ( <> @@ -1897,6 +1919,7 @@ export function IssueDetail() { await interruptQueuedComment.mutateAsync(runningIssueRun.id); } : undefined} + onImageClick={handleChatImageClick} />