fix(chat): paste browser links as plain text so URLs survive (#2804)

Co-authored-by: Joao Manso <X363081@bcpcorp.net>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
João Manso
2026-08-20 13:10:43 +01:00
committed by GitHub
parent 21336de2b2
commit 52b5bd387d
3 changed files with 219 additions and 17 deletions
@@ -338,28 +338,130 @@ describe("handleEditorPaste", () => {
expect(onImagePaste).toHaveBeenCalledWith([], "unreadable-image");
expect(preventDefault).toHaveBeenCalledOnce();
});
});
it("leaves rich HTML with visible text to the editor", () => {
function htmlPasteEvent(html: string, plain: string, preventDefault = vi.fn()): ClipboardEvent {
return {
clipboardData: {
files: [],
items: [],
getData: (type: string) => (type === "text/html" ? html : plain),
},
preventDefault,
} as unknown as ClipboardEvent;
}
function runPaste(event: ClipboardEvent, pasteText: () => void, onImagePaste = vi.fn()): boolean {
return handleEditorPaste({ pasteText } as unknown as EditorView, event, {
current: onImagePaste,
});
}
describe("handleEditorPaste formatting", () => {
it("strips formatting from rich HTML with visible text instead of attaching it", () => {
const pasteText = vi.fn();
const onImagePaste = vi.fn();
const preventDefault = vi.fn();
const event = {
clipboardData: {
files: [],
items: [{ kind: "string", type: "text/html" }],
getData: (type: string) =>
type === "text/html"
? '<p>Visible caption <img src="https://images.example.test/image.png"></p>'
: "Visible caption",
},
const event = htmlPasteEvent(
'<p>Visible caption <img src="https://images.example.test/image.png"></p>',
"Visible caption",
preventDefault,
} as unknown as ClipboardEvent;
);
const handled = handleEditorPaste({} as EditorView, event, {
current: onImagePaste,
});
expect(handled).toBe(false);
expect(runPaste(event, pasteText, onImagePaste)).toBe(true);
expect(onImagePaste).not.toHaveBeenCalled();
expect(pasteText).toHaveBeenCalledWith("Visible caption");
expect(preventDefault).toHaveBeenCalledOnce();
});
it("extracts the href when the clipboard is a single formatted link", () => {
const pasteText = vi.fn();
const preventDefault = vi.fn();
const url = "https://example.com/boards/sprint/42?workitem=1234";
const event = htmlPasteEvent(
`<a href="${url}">Example Board Sprint 42</a>`,
"Example Board Sprint 42",
preventDefault,
);
expect(runPaste(event, pasteText)).toBe(true);
expect(preventDefault).toHaveBeenCalledOnce();
expect(pasteText).toHaveBeenCalledWith(url);
});
it("extracts the href from case-insensitive HTML anchor tags", () => {
const pasteText = vi.fn();
const url = "https://example.com/boards/sprint/42?workitem=1234";
const event = htmlPasteEvent(
`<A HREF="${url}">Example Board Sprint 42</A>`,
"Example Board Sprint 42",
);
expect(runPaste(event, pasteText)).toBe(true);
expect(pasteText).toHaveBeenCalledWith(url);
});
it("falls back to plain text when a link is embedded in other content", () => {
const pasteText = vi.fn();
const event = htmlPasteEvent(
'<p>see <a href="https://example.com/x">the board</a> here</p>',
"see the board here",
);
expect(runPaste(event, pasteText)).toBe(true);
expect(pasteText).toHaveBeenCalledWith("see the board here");
});
it("strips formatting from external rich HTML, pasting only the plain text", () => {
const pasteText = vi.fn();
const event = htmlPasteEvent("<b>Bold</b> and <i>italic</i> text", "Bold and italic text");
expect(runPaste(event, pasteText)).toBe(true);
expect(pasteText).toHaveBeenCalledWith("Bold and italic text");
});
it("leaves internal editor copies marked with data-pm-slice to the default handler", () => {
const pasteText = vi.fn();
const preventDefault = vi.fn();
const event = htmlPasteEvent(
'<p data-pm-slice="1 1 []">reference</p>',
"reference",
preventDefault,
);
expect(runPaste(event, pasteText)).toBe(false);
expect(pasteText).not.toHaveBeenCalled();
expect(preventDefault).not.toHaveBeenCalled();
});
it("leaves plain-text-only pastes to the default handler", () => {
const pasteText = vi.fn();
const event = htmlPasteEvent("", "plain text only");
expect(runPaste(event, pasteText)).toBe(false);
expect(pasteText).not.toHaveBeenCalled();
});
});
describe("handleEditorPaste paste order and ownership", () => {
it("strips external rich HTML even when its plain text contains a code fence", () => {
const pasteText = vi.fn();
const html = "<b>bold</b>\n```\ncode\n```";
const plain = "bold\n```\ncode\n```";
const event = htmlPasteEvent(html, plain);
expect(runPaste(event, pasteText)).toBe(true);
expect(pasteText).toHaveBeenCalledWith(plain);
});
it("still strips a link whose visible text merely mentions data-pm-slice", () => {
const pasteText = vi.fn();
const event = htmlPasteEvent(
'<a href="https://example.com/y">the data-pm-slice attribute</a>',
"the data-pm-slice attribute",
);
expect(runPaste(event, pasteText)).toBe(true);
expect(pasteText).toHaveBeenCalledWith("https://example.com/y");
});
});
@@ -319,6 +319,41 @@ function insertCodeFenceNodes(
}
}
/**
* Rich content pasted from outside the editor should paste as plain text, the
* way Ctrl+Shift+V does. The chat composer has no link or styling marks, so the
* default HTML parse keeps a browser hyperlink's visible title and silently
* drops its URL. Copies made inside the editor (mentions, code blocks) carry
* ProseMirror's `data-pm-slice` attribute; those are left to the default handler
* so their nodes round-trip. The check matches the attribute form
* (`data-pm-slice="`) so prose that merely mentions the token is still stripped.
*/
function shouldStripPastedFormatting(clipboardData: DataTransfer): boolean {
const html = clipboardData.getData("text/html");
if (!html) return false;
return !html.includes('data-pm-slice="');
}
/**
* When the clipboard payload is a single hyperlink (e.g. a link copied from a
* browser page), return its href. Such a copy's plain text is often the link's
* title rather than the URL, so preferring the href preserves the real URL.
* Returns null when the payload is not exactly one link, so the caller falls
* back to the plain text. The parsed HTML is only read for its href attribute,
* never rendered.
*/
function singleLinkHref(html: string): string | null {
if (!/<a(?=[\t\n\f\r />])/iu.test(html)) return null;
const doc = new DOMParser().parseFromString(html, "text/html");
const anchors = doc.querySelectorAll("a[href]");
if (anchors.length !== 1) return null;
const href = anchors[0].getAttribute("href")?.trim();
if (!href) return null;
const payloadText = (doc.body.textContent ?? "").replace(/\s+/gu, " ").trim();
const linkText = (anchors[0].textContent ?? "").replace(/\s+/gu, " ").trim();
return payloadText === linkText ? href : null;
}
export function handleEditorPaste(
view: import("@tiptap/pm/view").EditorView,
event: ClipboardEvent,
@@ -340,8 +375,27 @@ export function handleEditorPaste(
}
}
// 2. Markdown code fence paste
const text = clipboardData?.getData("text/plain");
// 2. Strip formatting from externally pasted rich content. This runs before
// the code-fence branch so external HTML is not misread as a markdown code
// block. A single copied hyperlink pastes its href (the plain text is often
// the link title, not the URL); other rich content pastes as plain text.
// Either keeps the URL that the default HTML parse would otherwise drop.
// `pasteText` fires a synthetic empty-clipboard paste, so this handler
// re-enters once, no-ops, and ProseMirror inserts the text with its own
// inline/block handling.
if (clipboardData && shouldStripPastedFormatting(clipboardData)) {
const replacement = singleLinkHref(clipboardData.getData("text/html")) ?? text;
if (replacement) {
event.preventDefault();
view.pasteText(replacement);
return true;
}
}
// 3. Markdown code fence paste (plain-text or internal pastes; external rich
// content was handled above).
if (text && text.includes("```")) {
const segments = parseCodeFences(text);
if (segments.some((s) => s.type === "code")) {
@@ -0,0 +1,46 @@
import { test, expect } from "../../fixtures/test-base";
import { openTaskSession, waitForLatestSessionDone } from "../../helpers/session";
test.describe("paste strips rich text formatting", () => {
test("keeps a copied hyperlink's URL instead of its visible title", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Paste hyperlink keeps URL",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await waitForLatestSessionDone(apiClient, task.id, 1, "Wait for paste-formatting task");
const session = await openTaskSession(testPage, task.id);
await session.waitForChatIdle({ timeout: 30_000 });
const editor = session.activeChat().locator(".tiptap.ProseMirror").first();
await expect(editor).toBeEditable();
await editor.click();
const url = "https://example.com/boards/sprint/42?workitem=1234";
const title = "Example Board Sprint 42";
await editor.evaluate(
(element, data) => {
const clipboardData = new DataTransfer();
clipboardData.setData("text/plain", data.title);
clipboardData.setData("text/html", `<a href="${data.url}">${data.title}</a>`);
const pasteEvent = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(pasteEvent, "clipboardData", { value: clipboardData });
element.dispatchEvent(pasteEvent);
},
{ url, title },
);
await expect(editor).toContainText(url);
await expect(editor).not.toContainText(title);
});
});