mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 00:44:49 +00:00
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:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user