forked from farhoodlabs/paperclip
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:
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user