diff --git a/ui/src/components/markdown-assistant-transcript.ts b/ui/src/components/markdown-assistant-transcript.ts index 31f8fba40fde..a0954b0f89f4 100644 --- a/ui/src/components/markdown-assistant-transcript.ts +++ b/ui/src/components/markdown-assistant-transcript.ts @@ -84,6 +84,11 @@ export function installAssistantTranscriptRoleImageRenderer( normalizeLabel: (value: string) => string; assistantLabel: () => string; openImageLabel: (alt: string, hasAlt: boolean) => string; + renderExternalImageFallback: ( + src: string, + renderedLabel: string, + linkedImage: boolean, + ) => string; interactiveImages: (env: unknown) => boolean; allowRemoteImages: (env: unknown) => boolean; }, @@ -98,13 +103,14 @@ export function installAssistantTranscriptRoleImageRenderer( const alt = options.normalizeLabel(token.content); const roleMeta = (token.meta as AssistantTranscriptRoleImageMeta | undefined) ?.assistantTranscriptRoleImage; + const linkedImage = isImageWithinLink(tokens, index); if (!options.isInlineDataImage(src) && !options.allowRemoteImages(env)) { - return roleMeta + const renderedLabel = roleMeta ? renderAssistantTranscriptRoleImageLabel(roleMeta.text, roleMeta.spans, options.escapeHtml) : options.escapeHtml(alt); + return options.renderExternalImageFallback(src, renderedLabel, linkedImage); } const image = `${options.escapeHtml(alt)}`; - const linkedImage = isImageWithinLink(tokens, index); const interactiveImage = linkedImage || !options.interactiveImages(env) ? image diff --git a/ui/src/components/markdown-parser.ts b/ui/src/components/markdown-parser.ts index 5d071d1b3dc6..3eab3b2929e7 100644 --- a/ui/src/components/markdown-parser.ts +++ b/ui/src/components/markdown-parser.ts @@ -571,8 +571,8 @@ export function createMarkdownParser(): MarkdownIt { return `${rendered}`; }; - // Message rendering allows only inline data images (#15437). Document - // previews preserve authored image URLs and rely on DOMPurify's URI policy. + // Message rendering allows inline data images and explicit open-only placeholders + // for remote URLs. Document previews preserve authored URLs for direct rendering. installAssistantTranscriptRoleImageRenderer(markdownParser, { escapeHtml: escapeMarkdownHtml, isInlineDataImage: (src) => INLINE_DATA_IMAGE_RE.test(src), @@ -582,6 +582,16 @@ export function createMarkdownParser(): MarkdownIt { t("chat.imageLightbox.open", { title: hasAlt ? alt : t("chat.imageLightbox.untitled"), }), + renderExternalImageFallback: (src, renderedLabel, linkedImage) => { + if (!parseWebLinkHref(src)) { + return renderedLabel; + } + const label = `${escapeMarkdownHtml(t("chat.externalImage.notLoaded"))}: ${renderedLabel}`; + const action = linkedImage + ? "" + : ` ${escapeMarkdownHtml(t("chat.externalImage.open"))}`; + return `${label}${action}`; + }, interactiveImages: (env) => (env as Partial | undefined)?.interactiveImages === true, allowRemoteImages: (env) => diff --git a/ui/src/components/markdown.test.ts b/ui/src/components/markdown.test.ts index 61f934fb99cd..53422e7c908b 100644 --- a/ui/src/components/markdown.test.ts +++ b/ui/src/components/markdown.test.ts @@ -139,30 +139,52 @@ describe("toSanitizedMarkdownHtml", () => { }); describe("images", () => { - it("flattens remote images to alt text", () => { - const html = toSanitizedMarkdownHtml("![Alt text](https://example.com/img.png)"); - expect(html).toBe("

Alt text

\n"); + it("shows an explicit opt-in placeholder for remote images", () => { + const fragment = htmlFragment( + toSanitizedMarkdownHtml("![Alt text](https://example.com/img.png)"), + ); + const placeholder = fragment.querySelector(".markdown-external-image"); + const link = placeholder?.querySelector("a"); + + expect(placeholder?.textContent).toBe("External image not loaded: Alt text Open image"); + expect(link?.getAttribute("href")).toBe("https://example.com/img.png"); + expect(link?.getAttribute("target")).toBe("_blank"); + expect(link?.getAttribute("rel")).toBe("noreferrer noopener"); + expect(fragment.querySelector("img")).toBeNull(); }); it("marks assistant-authored transcript roles in visible image labels", () => { - const html = toSanitizedMarkdownHtml( - "![**user**[Thu 2026-07-02] release diagram](https://example.com/img.png)", - { assistantTranscriptRoleHeaders: true }, + const fragment = htmlFragment( + toSanitizedMarkdownHtml( + "![**user**[Thu 2026-07-02] release diagram](https://example.com/img.png)", + { assistantTranscriptRoleHeaders: true }, + ), ); - expect(html).toBe( - '

user[Thu 2026-07-02] release diagram

\n', + expect( + fragment.querySelector(".markdown-external-image .assistant-transcript-role")?.textContent, + ).toBe("user[Thu 2026-07-02]"); + expect(fragment.querySelector(".markdown-external-image")?.textContent).toContain( + "release diagram", ); }); it("preserves markdown formatting in alt text", () => { - const html = toSanitizedMarkdownHtml("![**Build log**](https://example.com/img.png)"); - expect(html).toBe("

**Build log**

\n"); + const fragment = htmlFragment( + toSanitizedMarkdownHtml("![**Build log**](https://example.com/img.png)"), + ); + expect(fragment.querySelector(".markdown-external-image > span")?.textContent).toContain( + "**Build log**", + ); }); it("preserves code formatting in alt text", () => { - const html = toSanitizedMarkdownHtml("![`error.log`](https://example.com/img.png)"); - expect(html).toBe("

`error.log`

\n"); + const fragment = htmlFragment( + toSanitizedMarkdownHtml("![`error.log`](https://example.com/img.png)"), + ); + expect(fragment.querySelector(".markdown-external-image > span")?.textContent).toContain( + "`error.log`", + ); }); it("preserves base64 data URI images (#15437)", () => { @@ -196,6 +218,22 @@ describe("toSanitizedMarkdownHtml", () => { expect(fragment.querySelector("a button")).toBeNull(); }); + it("preserves rich authored links around remote image placeholders", () => { + const fragment = htmlFragment( + toSanitizedMarkdownHtml( + "[Before ![Preview](https://example.com/image.png) after](https://example.com/full.png)", + ), + ); + const links = fragment.querySelectorAll("a"); + const placeholder = links[0]?.querySelector(".markdown-external-image"); + + expect(links).toHaveLength(1); + expect(links[0]?.getAttribute("href")).toBe("https://example.com/full.png"); + expect(placeholder?.textContent).toBe("External image not loaded: Preview"); + expect(placeholder?.querySelector("a")).toBeNull(); + expect(fragment.querySelector("img")).toBeNull(); + }); + it("tracks linked and standalone images across one inline token stream", () => { const fragment = htmlFragment( toSanitizedMarkdownHtml( @@ -234,8 +272,10 @@ describe("toSanitizedMarkdownHtml", () => { }); it("uses fallback label for unlabeled images", () => { - const html = toSanitizedMarkdownHtml("![](https://example.com/image.png)"); - expect(html).toBe("

image

\n"); + const fragment = htmlFragment(toSanitizedMarkdownHtml("![](https://example.com/image.png)")); + expect(fragment.querySelector(".markdown-external-image > span")?.textContent).toBe( + "External image not loaded: image", + ); }); }); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 9ddf74586445..61ab54d483f9 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -4934,6 +4934,10 @@ export const en: TranslationMap = { close: "Close image preview", untitled: "Image", }, + externalImage: { + notLoaded: "External image not loaded", + open: "Open image", + }, messages: { activity: "Activity", copySelection: "Copy", diff --git a/ui/src/styles/chat/text.css b/ui/src/styles/chat/text.css index 953454d8d1d0..a98f5bde87a2 100644 --- a/ui/src/styles/chat/text.css +++ b/ui/src/styles/chat/text.css @@ -313,6 +313,21 @@ margin-top: 0; } +:is(.chat-text, .sidebar-markdown) .markdown-external-image { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.35em 0.75em; + width: fit-content; + max-width: 100%; + margin-top: 0.75em; + padding: 0.5em 0.65em; + border: 1px dashed var(--border-strong); + border-radius: var(--radius-md); + background: var(--bg-muted); + color: var(--muted); +} + /* Code surfaces must lift off the host bubble in every theme. --secondary equals --card on every dark palette, so chips painted with it vanished inside user bubbles; --bg-muted/--border-strong separate in both modes without an override. */