fix(app): preserve linked resources when pasting prompts

Rich clipboard links carried their destination only in HTML, while the composer let textarea default paste insert the plain label. Convert linked fragments to Markdown at ingestion and replace the selected draft range through the existing editor command.
This commit is contained in:
paseo-bot[bot]
2026-11-06 22:06:19 +00:00
parent 9f68af0274
commit 4cf91fa16d
6 changed files with 142 additions and 8 deletions
@@ -1,5 +1,7 @@
import { expect, test, type Page } from "../support/fixtures";
import {
pasteRichTextIntoComposer,
selectComposerDraftRange,
blurComposerInput,
roundTripComposerTabBeforePaint,
composerLocator,
@@ -136,8 +138,23 @@ test("submitting a message leaves the composer ready for the next message", asyn
await submitMessage(page, "First message");
await expectComposerFocused(page);
await typeIntoFocusedComposer(page, "Second message");
await expectComposerDraft(page, "Second message");
await typeIntoFocusedComposer(page, "Investigate: replace this. Thanks");
await selectComposerDraftRange(page, 13, 25);
await pasteRichTextIntoComposer(
page,
'<a href="https://example.com/issues/39150">this FR</a>',
"this FR",
);
await expectComposerDraft(
page,
"Investigate: [this FR](https://example.com/issues/39150). Thanks",
);
await typeIntoFocusedComposer(page, " next");
await expectComposerDraft(
page,
"Investigate: [this FR](https://example.com/issues/39150) next. Thanks",
);
await expectComposerFocused(page);
} finally {
await agent.cleanup();
}
@@ -566,3 +566,24 @@ export async function roundTripComposerTabBeforePaint(
).toBe(true);
expect(result.focused, "returning on touch must leave the composer unfocused").toBe(false);
}
/** Paste both clipboard representations through the browser's normal paste command. */
export async function pasteRichTextIntoComposer(
page: Page,
html: string,
text: string,
): Promise<void> {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await page.evaluate(
async ({ html: richHtml, text: plainText }) => {
await navigator.clipboard.write([
new ClipboardItem({
"text/html": new Blob([richHtml], { type: "text/html" }),
"text/plain": new Blob([plainText], { type: "text/plain" }),
}),
]);
},
{ html, text },
);
await composerInput(page).press("ControlOrMeta+v");
}
+42 -6
View File
@@ -88,6 +88,7 @@ import { RenderProfile } from "@/utils/render-profiler";
import { useComposerHeight } from "./height";
import { ComposerInputPresentation } from "./presentation";
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
import { readLinkedClipboardText } from "@/composer/pasted-text";
import type { NativePastedFile } from "@/composer/native-pasted-image";
import {
EditingTextInput,
@@ -513,16 +514,17 @@ function getTextInputNativeElement(current: ComposerTextInputHandle | null): HTM
return native instanceof HTMLElement ? native : null;
}
interface PasteImagesEffectArgs {
interface PasteEffectArgs {
textarea: HTMLElement | null;
isConnected: boolean;
disabled: boolean;
isDictating: boolean;
isRealtimeVoiceForCurrentAgent: boolean;
onAddImages: ((images: ImageAttachment[]) => void) | undefined;
onPasteText: (text: string) => void;
}
function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
function usePasteEffect(args: PasteEffectArgs): void {
const {
textarea,
isConnected,
@@ -530,10 +532,11 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
isDictating,
isRealtimeVoiceForCurrentAgent,
onAddImages,
onPasteText,
} = args;
useEffect(() => {
if (!isWeb || !onAddImages) return;
if (!isWeb) return;
if (!textarea) return;
@@ -542,7 +545,14 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
if (!isConnected || disabled || isDictating || isRealtimeVoiceForCurrentAgent) return;
const imageFiles = collectImageFilesFromClipboardData(event.clipboardData);
if (imageFiles.length === 0) return;
if (imageFiles.length === 0) {
const text = readLinkedClipboardText(event.clipboardData);
if (text === null) return;
event.preventDefault();
onPasteText(text);
return;
}
if (!onAddImages) return;
event.preventDefault();
@@ -562,7 +572,15 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
disposed = true;
textarea.removeEventListener?.("paste", handlePaste);
};
}, [disabled, textarea, isConnected, isDictating, isRealtimeVoiceForCurrentAgent, onAddImages]);
}, [
disabled,
textarea,
isConnected,
isDictating,
isRealtimeVoiceForCurrentAgent,
onAddImages,
onPasteText,
]);
}
function useAutoFocusOnWebEffect(
@@ -1811,13 +1829,31 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
handleAlternateSendAction();
}, [alternateSendAction, handleAlternateSendAction]);
usePasteImagesEffect({
const handlePasteText = useCallback(
(text: string) => {
const snapshot = getComposerInputSnapshot(
textInputRef.current,
valueRef.current,
selectionRef.current,
);
const nextText =
snapshot.text.slice(0, snapshot.selection.start) +
text +
snapshot.text.slice(snapshot.selection.end);
const cursor = snapshot.selection.start + text.length;
replaceText(nextText, { start: cursor, end: cursor });
},
[replaceText],
);
usePasteEffect({
textarea: webTextArea,
isConnected,
disabled,
isDictating,
isRealtimeVoiceForCurrentAgent,
onAddImages,
onPasteText: handlePasteText,
});
const handleSelectionChange = useCallback(
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { readLinkedClipboardText } from "./pasted-text.web";
function clipboard(html: string, text: string): DataTransfer {
const data = new DataTransfer();
data.setData("text/html", html);
data.setData("text/plain", text);
return data;
}
describe("pasted prompt links", () => {
it("preserves a labeled resource URL as Markdown", () => {
const data = clipboard(
'Hey can you take a look at <a href="https://example.com/issues/39150">this FR</a> and...',
"Hey can you take a look at this FR and...",
);
expect(readLinkedClipboardText(data)).toBe(
"Hey can you take a look at [this FR](https://example.com/issues/39150) and...",
);
});
it("leaves plain text and rich text without links to native paste", () => {
expect(readLinkedClipboardText(clipboard("", "[link](https://example.com)"))).toBeNull();
expect(readLinkedClipboardText(clipboard("<b>hello</b>", "hello"))).toBeNull();
expect(readLinkedClipboardText(null)).toBeNull();
});
it("keeps paragraphs and multiple links", () => {
expect(
readLinkedClipboardText(
clipboard(
'<p>See <a href="https://example.com/a">one</a></p><p>and <a href="https://example.com/b">two</a></p>',
"See one\n\nand two",
),
),
).toBe("See [one](https://example.com/a)\n\nand [two](https://example.com/b)");
});
});
+6
View File
@@ -0,0 +1,6 @@
/** Native inputs retain their platform clipboard handling. */
export function readLinkedClipboardText(
_clipboard: Pick<DataTransfer, "getData"> | null,
): string | null {
return null;
}
@@ -0,0 +1,16 @@
import TurndownService from "turndown";
const markdown = new TurndownService({ codeBlockStyle: "fenced", bulletListMarker: "-" });
/** Preserve destinations that the clipboard's plain-text representation cannot carry. */
export function readLinkedClipboardText(
clipboard: Pick<DataTransfer, "getData"> | null,
): string | null {
const html = clipboard?.getData("text/html");
if (!html) return null;
// Parse in a detached document. Clipboard markup is never mounted in the app.
const document = new DOMParser().parseFromString(html, "text/html");
if (!document.querySelector("a[href]")) return null;
return markdown.turndown(document.body);
}