Alt text
\n"); + it("shows an explicit opt-in placeholder for remote images", () => { + const fragment = htmlFragment( + toSanitizedMarkdownHtml(""), + ); + 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
**Build log**
\n"); + const fragment = htmlFragment( + toSanitizedMarkdownHtml(""), + ); + expect(fragment.querySelector(".markdown-external-image > span")?.textContent).toContain( + "**Build log**", + ); }); it("preserves code formatting in alt text", () => { - const html = toSanitizedMarkdownHtml(""); - expect(html).toBe("`error.log`
\n"); + const fragment = htmlFragment( + toSanitizedMarkdownHtml(""), + ); + 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  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(""); - expect(html).toBe("image
\n"); + const fragment = htmlFragment(toSanitizedMarkdownHtml("")); + 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. */