Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions components/ImagePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ interface ImagePreviewProps {

export function ImagePreview({ src, alt = "", children, className, style }: ImagePreviewProps) {
const { t } = useI18n();
const previewLabel = t("chat.previewImage");
const triggerLabel = alt.trim() ? `${previewLabel}: ${alt}` : previewLabel;
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const dialogRef = useRef<HTMLDialogElement>(null);
Expand Down Expand Up @@ -59,10 +61,10 @@ export function ImagePreview({ src, alt = "", children, className, style }: Imag
...style,
}}
onClick={() => setOpen(true)}
aria-label={t("chat.previewImage")}
aria-label={triggerLabel}
aria-haspopup="dialog"
aria-expanded={open}
title={t("chat.previewImage")}
title={previewLabel}
>
{children}
</button>
Expand Down
30 changes: 30 additions & 0 deletions components/MarkdownBody.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,33 @@ test("keeps Mermaid source visible while the response is streaming", () => {
assert.match(html, />Preview</);
assert.match(html, /A --&gt; B/);
});

test("opens markdown images in the shared image preview", () => {
const localHtml = renderMarkdown("![chart](docs/tmp/chart.png)");
const remoteHtml = renderMarkdown("![logo](https://example.com/logo.png)");

assert.match(localHtml, /<button[^>]+aria-label="Preview image: chart"[^>]*>/);
assert.match(localHtml, /<img[^>]+src="\/api\/files\/home\/me\/project\/docs\/tmp\/chart\.png\?type=read"/);
assert.match(localHtml, /<img[^>]+alt="chart"/);
assert.match(remoteHtml, /<button[^>]+aria-label="Preview image: logo"[^>]*>/);
assert.match(remoteHtml, /<img[^>]+src="https:\/\/example\.com\/logo\.png"/);
});

test("keeps linked markdown images as links instead of nested preview buttons", () => {
const html = renderMarkdown("[![diagram](docs/tmp/diagram.png)](https://example.com/docs)");

assert.match(
html,
/<a (?=[^>]*href="https:\/\/example\.com\/docs")(?=[^>]*target="_blank")[^>]*>/,
);
assert.match(html, /<img[^>]+alt="diagram"/);
assert.doesNotMatch(html, /<a[^>]*>[\s\S]*<button/);
assert.doesNotMatch(html, /<button[^>]*>[\s\S]*<\/a>/);
});

test("uses a generic preview label when a markdown image has no alt text", () => {
const html = renderMarkdown("![](https://example.com/shot.png)");

assert.match(html, /<button[^>]+aria-label="Preview image"[^>]*>/);
assert.doesNotMatch(html, /Preview image:/);
});
58 changes: 41 additions & 17 deletions components/MarkdownBody.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
"use client";

import { useMemo, type MouseEvent } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import { createContext, useContext, useMemo, type ComponentProps, type MouseEvent } from "react";
import ReactMarkdown, { type Components, type ExtraProps } from "react-markdown";
import { resolveLocalFileHref, shouldOpenLocalFileInApp } from "@/lib/file-links";
import { encodeFilePathForApi } from "@/lib/file-paths";
import { markdownRehypePlugins, markdownRemarkPlugins, markdownUrlTransform, normalizeDisplayMath } from "@/lib/markdown";
import { ImagePreview } from "./ImagePreview";
import { MermaidBlock, CodeBlock } from "./MermaidBlock";

const MarkdownLinkContext = createContext(false);

interface MarkdownBodyProps {
children: string;
className?: string;
Expand All @@ -15,6 +18,30 @@ interface MarkdownBodyProps {
onOpenFile?: (filePath: string) => void;
}

function MarkdownImage({
src,
alt,
cwd,
...props
}: ComponentProps<"img"> & ExtraProps & { cwd?: string }) {
const insideLink = useContext(MarkdownLinkContext);
delete props.node;
const href = typeof src === "string" ? src : undefined;
const filePath = href ? resolveLocalFileHref(href, cwd) : null;
const imageSrc = filePath
? `/api/files/${encodeFilePathForApi(filePath)}?type=read`
: href;
// Dynamic local paths are served directly by the file API.
// eslint-disable-next-line @next/next/no-img-element
const image = <img src={imageSrc} alt={alt ?? ""} loading="lazy" {...props} />;
if (!imageSrc || insideLink) return image;
return (
<ImagePreview src={imageSrc} alt={alt ?? ""} className="markdown-image">
{image}
</ImagePreview>
);
}

export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile }: MarkdownBodyProps) {
const normalizedMarkdown = useMemo(() => normalizeDisplayMath(children), [children]);
// Stable renderer identities keep stateful blocks mounted across message hover updates.
Expand Down Expand Up @@ -54,9 +81,11 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile
const openFile = onOpenFile;
if (!filePath || !openFile) {
return (
<a href={href} {...props} target="_blank" rel="noopener noreferrer">
{children}
</a>
<MarkdownLinkContext.Provider value={true}>
<a href={href} {...props} target="_blank" rel="noopener noreferrer">
{children}
</a>
</MarkdownLinkContext.Provider>
);
}

Expand All @@ -69,20 +98,15 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile
};

return (
<a href={href} {...props} onClick={handleClick}>
{children}
</a>
<MarkdownLinkContext.Provider value={true}>
<a href={href} {...props} onClick={handleClick}>
{children}
</a>
</MarkdownLinkContext.Provider>
);
},
img({ src, alt, ...props }) {
delete props.node;
const filePath = typeof src === "string" ? resolveLocalFileHref(src, cwd) : null;
const imageSrc = filePath
? `/api/files/${encodeFilePathForApi(filePath)}?type=read`
: src;
// Dynamic local paths are served directly by the file API.
// eslint-disable-next-line @next/next/no-img-element
return <img src={imageSrc} alt={alt ?? ""} loading="lazy" {...props} />;
img(props) {
return <MarkdownImage cwd={cwd} {...props} />;
},
table({ children }) {
return (
Expand Down
28 changes: 28 additions & 0 deletions components/MessageView.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -294,3 +294,31 @@ test("renders custom-message images as buttons that open a larger preview", () =
assert.match(html, /<button[^>]+aria-label="Preview image"[^>]*>/);
assert.match(html, /<img[^>]+src="data:image\/png;base64,YWJj"/);
});

test("shows tool-result images while the tool details stay collapsed", () => {
const block = {
type: "toolCall",
toolCallId: "call-shot-1",
toolName: "page_screenshot",
input: { tabId: 7 },
};
const result = {
role: "toolResult",
toolCallId: block.toolCallId,
content: [
{ type: "text", text: "captured-1280x720" },
{ type: "image", data: "YWJj", mimeType: "image/png" },
],
};
const html = renderMessage({
role: "assistant",
provider: "anthropic",
model: "claude-test",
content: [block],
}, { toolResults: new Map([[block.toolCallId, result]]) });

assert.match(html, /<button[^>]+aria-label="Preview image"[^>]*>/);
assert.match(html, /<img[^>]+src="data:image\/png;base64,YWJj"/);
assert.doesNotMatch(html, /captured-1280x720/);
assert.doesNotMatch(html, /"tabId"/);
});
120 changes: 65 additions & 55 deletions components/MessageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1111,16 +1111,18 @@ function ToolCallBlock({ block, result, duration, onOpenSession }: { block: Tool
</pre>
)}

{/* ── Result images — always visible, independent of the collapsed details ── */}
{resultImages.length > 0 && <ResultImages images={resultImages} isError={isError} />}

{/* ── Paired result — only shown when expanded ── */}
{expanded && result && (
resultDiff ? (
<PairedDiffResult
diff={resultDiff}
/>
) : (
) : (!resultIsEmpty || resultImages.length === 0) && (
<PairedResult
text={resultText ?? ""}
images={resultImages}
isEmpty={resultIsEmpty}
isError={isError}
/>
Expand Down Expand Up @@ -1366,71 +1368,79 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function PairedResult({ text, images, isEmpty, isError }: {
function ResultImages({ images, isError }: { images: ImageContent[]; isError: boolean }) {
return (
<div
style={{
display: "flex",
gap: 8,
flexWrap: "wrap",
padding: "10px",
background: "var(--bg)",
borderTop: `1px solid ${isError ? "rgba(248,113,113,0.3)" : "rgba(34,197,94,0.15)"}`,
}}
>
{images.map((image, index) => {
const src = imageSource(image);
if (!src) return null;
return (
<ImagePreview
key={`${src}-${index}`}
src={src}
style={{ maxWidth: "100%" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt=""
loading="lazy"
style={{
display: "block",
maxWidth: "min(100%, 720px)",
maxHeight: 520,
borderRadius: 6,
objectFit: "contain",
border: "1px solid var(--border)",
}}
/>
</ImagePreview>
);
})}
</div>
);
}

function PairedResult({ text, isEmpty, isError }: {
text: string;
images: ImageContent[];
isEmpty: boolean;
isError: boolean;
}) {
const { t } = useI18n();
const showText = !isEmpty || images.length === 0;
return (
<div
style={{
borderTop: `1px solid ${isError ? "rgba(248,113,113,0.3)" : "rgba(34,197,94,0.15)"}`,
background: isError ? "rgba(248,113,113,0.04)" : "var(--bg-subtle)",
}}
>
{images.length > 0 && (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", padding: "10px", background: "var(--bg)" }}>
{images.map((image, index) => {
const src = imageSource(image);
if (!src) return null;
return (
<ImagePreview
key={`${src}-${index}`}
src={src}
style={{ maxWidth: "100%" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt=""
loading="lazy"
style={{
display: "block",
maxWidth: "min(100%, 720px)",
maxHeight: 520,
borderRadius: 6,
objectFit: "contain",
border: "1px solid var(--border)",
}}
/>
</ImagePreview>
);
})}
</div>
)}
{showText && (
<pre
style={{
margin: 0,
padding: "8px 10px",
color: isError ? "#f87171" : (isEmpty ? "var(--text-dim)" : "var(--text-muted)"),
fontSize: "calc(12px + var(--chat-font-size-offset, 0px))",
lineHeight: 1.5,
overflow: "auto",
maxHeight: 400,
background: "var(--bg)",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
fontStyle: isEmpty ? "italic" : "normal",
opacity: isEmpty ? 0.6 : 1,
}}
>
{isEmpty ? t("i18n.noOutput") : text}
</pre>
)}
<pre
style={{
margin: 0,
padding: "8px 10px",
color: isError ? "#f87171" : (isEmpty ? "var(--text-dim)" : "var(--text-muted)"),
fontSize: "calc(12px + var(--chat-font-size-offset, 0px))",
lineHeight: 1.5,
overflow: "auto",
maxHeight: 400,
background: "var(--bg)",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
fontStyle: isEmpty ? "italic" : "normal",
opacity: isEmpty ? 0.6 : 1,
}}
>
{isEmpty ? t("i18n.noOutput") : text}
</pre>
</div>
);
}
Expand Down
Loading