From 950e1f8f414a4b6fa717c8755008ada0e6602bff Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:07:01 +0200 Subject: [PATCH] Keep an image's alt text and source when copying a selection from an assistant reply (#6181) * fix(app): copy an assistant image as its Markdown, with alt text Selecting across an image in an assistant reply copied the rendered img, which has no alt text and, for data and workspace images, points at a preview URL. The image frame now carries its Markdown source and alt text the same way other Markdown elements declare their copy form. Closes #6163 * fix(app): copy an image selected on its own as its Markdown A selection inside the rendered image left the image's copy attributes on the frame outside the range, so nothing was copied as Markdown. * test(app): name the image-only selection in the copy spec --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../browser/assistant-selection-copy.spec.ts | 60 +++++++++++++++++++ .../content.browser.test.ts | 18 +++++- .../assistant-selection-copy/content.web.ts | 13 +++- .../src/assistant-selection-copy/markup.ts | 14 +++++ packages/app/src/components/message.tsx | 8 ++- 5 files changed, 106 insertions(+), 7 deletions(-) diff --git a/packages/app/e2e/browser/assistant-selection-copy.spec.ts b/packages/app/e2e/browser/assistant-selection-copy.spec.ts index e2d9e3c02b..323b2b0c15 100644 --- a/packages/app/e2e/browser/assistant-selection-copy.spec.ts +++ b/packages/app/e2e/browser/assistant-selection-copy.spec.ts @@ -155,6 +155,19 @@ async function selectAssistantElementFromEndOf( ); } +/** A drag over an image alone selects it inside its rendered frame. */ +async function selectAssistantImage(page: Page): Promise { + await assistantMessageBlocks(page) + .locator("img") + .evaluate((element) => { + const range = document.createRange(); + range.selectNode(element); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + }); +} + async function selectAssistantText(page: Page, text: string): Promise { await selectAssistantTextRange(page, text, text); } @@ -649,3 +662,50 @@ test("copying an assistant selection preserves Markdown structure and links", as await agent.cleanup(); } }); + +test("copying a selection across an assistant image keeps its Markdown source and alt text", async ({ + context, + page, +}) => { + const image = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + const agent = await seedMockAgentWorkspace({ + repoPrefix: "assistant-selection-copy-image-", + title: "Assistant selection copy image", + initialPrompt: "Render the image fixture.", + featureValues: { + mockAssistantResponse: [ + "Before the image.", + "", + `![chart](${image})`, + "", + "After the image.", + ].join("\n"), + }, + }); + + try { + await allowRichClipboard(context); + await agent.client.waitForAgentUpsert( + agent.agentId, + (snapshot) => snapshot.status === "idle", + 30_000, + ); + await openAgentRoute(page, agent); + await expect(assistantMessageBlocks(page).locator("img")).toHaveCount(1); + + await selectAssistantTextRange(page, "Before the image.", "After the image."); + await copySelection(page); + + expect((await readRichClipboard(page)).plainText).toBe( + `Before the image.\n\n![chart](${image})\n\nAfter the image.`, + ); + + await selectAssistantImage(page); + await copySelection(page); + + expect((await readRichClipboard(page)).plainText).toBe(`![chart](${image})`); + } finally { + await agent.cleanup(); + } +}); diff --git a/packages/app/src/assistant-selection-copy/content.browser.test.ts b/packages/app/src/assistant-selection-copy/content.browser.test.ts index 995927f106..5fe8e0fddc 100644 --- a/packages/app/src/assistant-selection-copy/content.browser.test.ts +++ b/packages/app/src/assistant-selection-copy/content.browser.test.ts @@ -411,6 +411,20 @@ describe("assistant selection copy ranges", () => { expect(content?.html).not.toContain("
");
   });
 
+  it("copies an image as its Markdown when the selection stays inside the rendered image", () => {
+    const message = mountFixture();
+    const frame = document.createElement("div");
+    frame.setAttribute("data-paseo-markdown-tag", "img");
+    frame.setAttribute("data-paseo-markdown-src", "https://example.test/chart.png");
+    frame.setAttribute("data-paseo-markdown-alt", "chart");
+    frame.innerHTML = '
'; + message.prepend(frame); + + expect(copiedMarkdown(selectNodeContents(fixtureElement(frame, "img").parentElement!))).toBe( + "![chart](https://example.test/chart.png)", + ); + }); + it("copies code without a fence when every character is selected inside the block", () => { const message = mountFixture(); const blockCode = fixtureElement( @@ -674,7 +688,9 @@ describe("assistant selection copy inside highlighted code", () => { const message = mountHighlighted(); const image = document.createElement("div"); image.setAttribute("data-paseo-markdown-tag", "p"); - image.innerHTML = '
chart
'; + // The rendered `img` shows a preview URL with no alt text; the frame carries the Markdown. + image.innerHTML = + '
'; message.querySelector('[data-paseo-markdown-tag="pre"]')!.before(image); const before = tokenText(message, "Before the block."); diff --git a/packages/app/src/assistant-selection-copy/content.web.ts b/packages/app/src/assistant-selection-copy/content.web.ts index ac4643b2ff..60ff6d2165 100644 --- a/packages/app/src/assistant-selection-copy/content.web.ts +++ b/packages/app/src/assistant-selection-copy/content.web.ts @@ -7,10 +7,12 @@ import { } from "@/utils/rich-clipboard"; import { MARKDOWN_COPY_ALIGN_ATTRIBUTE, + MARKDOWN_COPY_ALT_ATTRIBUTE, MARKDOWN_COPY_IGNORE_ATTRIBUTE, MARKDOWN_COPY_LANGUAGE_ATTRIBUTE, MARKDOWN_COPY_LIST_MARKER_ATTRIBUTE, MARKDOWN_COPY_LIST_START_ATTRIBUTE, + MARKDOWN_COPY_SRC_ATTRIBUTE, MARKDOWN_COPY_TAG_ATTRIBUTE, MARKDOWN_COPY_UNWRAP_ATTRIBUTE, TRAILING_CODE_LINE_BREAKS, @@ -293,7 +295,8 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean return false; } const tag = element.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE); - if (tag === "p" || isTableStructure(tag)) { + // An image has no part to select, so touching it selects all of it. + if (tag === "p" || tag === "img" || isTableStructure(tag)) { return true; } const isSelectableSemantic = tag !== null && tag !== "li" && tag !== "ol" && tag !== "ul"; @@ -430,7 +433,7 @@ function hasMarkdownContent(fragment: DocumentFragment, includeIgnored: boolean) if (fragment.textContent) { return true; } - const visibleVoidSelector = ["br", "hr"] + const visibleVoidSelector = ["br", "hr", "img"] .map((tag) => `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="${tag}"]`) .concat("img") .join(","); @@ -526,9 +529,13 @@ function restoreMarkdownElements(container: HTMLElement): void { continue; } const semanticElement = document.createElement(tagName); - if (tagName !== "br") { + if (tagName !== "br" && tagName !== "img") { semanticElement.append(...element.childNodes); } + if (tagName === "img") { + semanticElement.setAttribute("src", element.getAttribute(MARKDOWN_COPY_SRC_ATTRIBUTE) ?? ""); + semanticElement.setAttribute("alt", element.getAttribute(MARKDOWN_COPY_ALT_ATTRIBUTE) ?? ""); + } if (tagName === "ol") { const start = element.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE); if (start) { diff --git a/packages/app/src/assistant-selection-copy/markup.ts b/packages/app/src/assistant-selection-copy/markup.ts index 8f220043ce..91c6c1618a 100644 --- a/packages/app/src/assistant-selection-copy/markup.ts +++ b/packages/app/src/assistant-selection-copy/markup.ts @@ -5,6 +5,8 @@ export const MARKDOWN_COPY_UNWRAP_ATTRIBUTE = "data-paseo-markdown-unwrap"; export const MARKDOWN_COPY_LIST_START_ATTRIBUTE = "data-paseo-markdown-list-start"; export const MARKDOWN_COPY_LANGUAGE_ATTRIBUTE = "data-paseo-markdown-language"; export const MARKDOWN_COPY_ALIGN_ATTRIBUTE = "data-paseo-markdown-align"; +export const MARKDOWN_COPY_SRC_ATTRIBUTE = "data-paseo-markdown-src"; +export const MARKDOWN_COPY_ALT_ATTRIBUTE = "data-paseo-markdown-alt"; /** * Trailing line breaks, with any indentation that followed the last one. @@ -63,6 +65,18 @@ export function markdownCopyCodeBlockDataSet(language: string | null | undefined } as const; } +/** + * An image copies as the Markdown it came from. The rendered `img` points at a preview + * URL for data and workspace images, and carries no alt text of its own. + */ +export function markdownCopyImageDataSet(source: string, alt: string | undefined) { + return { + paseoMarkdownTag: "img", + paseoMarkdownSrc: source, + ...(alt ? { paseoMarkdownAlt: alt } : {}), + } as const; +} + export function markdownCopyTableCellDataSet(tag: "td" | "th", style: unknown) { const alignment = typeof style === "string" diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 8d93f4588b..1bbeca891f 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -108,6 +108,7 @@ import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assist import { useRetainedPanelActive } from "@/components/retained-panel"; import { markdownCopyDataSet, + markdownCopyImageDataSet, markdownCopyOrderedListDataSet, markdownCopyTableCellDataSet, type MarkdownCopyInlineTag, @@ -884,10 +885,11 @@ function AssistantMarkdownImage({ ], [containerStyle, imageSizeStyle], ); + const copyDataSet = useMemo(() => markdownCopyImageDataSet(source, alt), [source, alt]); if (image.status === "failed") { return ( - + {image.message} ); @@ -895,14 +897,14 @@ function AssistantMarkdownImage({ if (!binding) { return ( - + ); } return ( - +