fix(ui): show a placeholder for remote markdown images (#122312)

* fix(ui): show remote image placeholders in chat

Keep remote Markdown images fetch-blocked while giving users a visible outcome and an explicit HTTPS open action.

* fix(ui): preserve links around image placeholders
This commit is contained in:
Vyctor H. Brzezowski
2026-08-11 22:48:20 -03:00
committed by GitHub
parent 89bae11799
commit 7c83fc1729
5 changed files with 93 additions and 18 deletions
@@ -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 = `<img class="markdown-inline-image" src="${options.escapeHtml(src)}" alt="${options.escapeHtml(alt)}">`;
const linkedImage = isImageWithinLink(tokens, index);
const interactiveImage =
linkedImage || !options.interactiveImages(env)
? image
+12 -2
View File
@@ -571,8 +571,8 @@ export function createMarkdownParser(): MarkdownIt {
return `<a class="markdown-file-link" role="button" tabindex="0" data-file-path="${escapeMarkdownHtml(target.path)}" data-file-kind="${fileKindForPath(target.path)}"${lineAttribute}${titleAttribute}>${rendered}</a>`;
};
// 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 = `<span>${escapeMarkdownHtml(t("chat.externalImage.notLoaded"))}: ${renderedLabel}</span>`;
const action = linkedImage
? ""
: ` <a href="${escapeMarkdownHtml(src)}">${escapeMarkdownHtml(t("chat.externalImage.open"))}</a>`;
return `<span class="markdown-external-image">${label}${action}</span>`;
},
interactiveImages: (env) =>
(env as Partial<MarkdownRenderEnv> | undefined)?.interactiveImages === true,
allowRemoteImages: (env) =>
+54 -14
View File
@@ -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("<p>Alt text</p>\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(
'<p><code class="assistant-transcript-role">user[Thu 2026-07-02]</code> release diagram</p>\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("<p>**Build log**</p>\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("<p>`error.log`</p>\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("<p>image</p>\n");
const fragment = htmlFragment(toSanitizedMarkdownHtml("![](https://example.com/image.png)"));
expect(fragment.querySelector(".markdown-external-image > span")?.textContent).toBe(
"External image not loaded: image",
);
});
});
+4
View File
@@ -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",
+15
View File
@@ -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. */