feat(ui): open gallery when clicking images in chat messages

Clicking an image in a chat message now opens the same ImageGalleryModal
used by the attachments gallery. Matches by contentPath or assetId.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-08 07:09:01 -05:00
parent c830c64727
commit d0920da459
3 changed files with 45 additions and 5 deletions

View file

@ -80,6 +80,7 @@ interface IssueChatMessageContext {
) => Promise<void>;
onInterruptQueued?: (runId: string) => Promise<void>;
interruptingQueuedRunId?: string | null;
onImageClick?: (src: string) => void;
}
const IssueChatCtx = createContext<IssueChatMessageContext>({
@ -184,6 +185,7 @@ interface IssueChatThreadProps {
includeSucceededRunsWithoutOutput?: boolean;
onInterruptQueued?: (runId: string) => Promise<void>;
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 (
<MarkdownBody className="text-sm leading-6" style={recessed ? { opacity: 0.55 } : undefined}>
<MarkdownBody className="text-sm leading-6" style={recessed ? { opacity: 0.55 } : undefined} onImageClick={onImageClick}>
{text}
</MarkdownBody>
);
@ -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,
],
);

View file

@ -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<typeof import("mermaid").default> | 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 <img {...imgProps} src={resolved ?? src} alt={alt ?? ""} />;
const resolved = resolveImageSrc && src ? resolveImageSrc(src) : null;
const finalSrc = resolved ?? src;
return (
<img
{...imgProps}
src={finalSrc}
alt={alt ?? ""}
onClick={onImageClick && finalSrc ? (e) => { e.preventDefault(); onImageClick(finalSrc); } : undefined}
style={onImageClick ? { cursor: "pointer", ...(imgProps.style as React.CSSProperties | undefined) } : imgProps.style as React.CSSProperties | undefined}
/>
);
};
}

View file

@ -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}
/>
</TabsContent>