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 ( - +