mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 17:04:42 +00:00
Preserve linked resources when pasting prompts (#8972)
* 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. * fix(app): keep pasted links undoable while drafting offline Use the browser edit command for converted paste so selection replacement participates in Undo and normal input publication. Keep complete Markdown clipboard text when it already carries the link destinations, and limit connection gating to image uploads. * fix(app): preserve copied Markdown with normalized links Copy renders normalized URLs in HTML while retaining original Markdown in plain text. Compare parsed destinations using the same Markdown parser so pasting complete responses keeps tables and formatting intact. * fix(app): retain external linked tables when pasting prompts Recovering missing link destinations must retain table rows and columns. Use the existing table converter and keep cell delimiters and line breaks inside their cells. * test(app): name rich clipboard paste arguments * fix(app): preserve pasted links around rowless tables The table converter requires a first row. Convert rowless table content separately so empty markup cannot abort recovery of surrounding destinations. * test(app): wait for workspace restore before undo teardown The directory appears before git finishes recreating the worktree. Wait for the host to publish its active workspace before cleanup removes the source repository. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -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 { pasteLinkedClipboardText } from "@/composer/pasted-text";
|
||||
import type { NativePastedFile } from "@/composer/native-pasted-image";
|
||||
import {
|
||||
EditingTextInput,
|
||||
@@ -513,7 +514,7 @@ function getTextInputNativeElement(current: ComposerTextInputHandle | null): HTM
|
||||
return native instanceof HTMLElement ? native : null;
|
||||
}
|
||||
|
||||
interface PasteImagesEffectArgs {
|
||||
interface PasteEffectArgs {
|
||||
textarea: HTMLElement | null;
|
||||
isConnected: boolean;
|
||||
disabled: boolean;
|
||||
@@ -522,7 +523,7 @@ interface PasteImagesEffectArgs {
|
||||
onAddImages: ((images: ImageAttachment[]) => void) | undefined;
|
||||
}
|
||||
|
||||
function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
|
||||
function usePasteEffect(args: PasteEffectArgs): void {
|
||||
const {
|
||||
textarea,
|
||||
isConnected,
|
||||
@@ -533,16 +534,20 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
|
||||
} = args;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb || !onAddImages) return;
|
||||
if (!isWeb) return;
|
||||
|
||||
if (!textarea) return;
|
||||
|
||||
let disposed = false;
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
if (!isConnected || disabled || isDictating || isRealtimeVoiceForCurrentAgent) return;
|
||||
if (disabled || isDictating || isRealtimeVoiceForCurrentAgent) return;
|
||||
|
||||
const imageFiles = collectImageFilesFromClipboardData(event.clipboardData);
|
||||
if (imageFiles.length === 0) return;
|
||||
if (imageFiles.length === 0) {
|
||||
if (pasteLinkedClipboardText(textarea, event.clipboardData)) event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!isConnected || !onAddImages) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
@@ -1811,7 +1816,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
handleAlternateSendAction();
|
||||
}, [alternateSendAction, handleAlternateSendAction]);
|
||||
|
||||
usePasteImagesEffect({
|
||||
usePasteEffect({
|
||||
textarea: webTextArea,
|
||||
isConnected,
|
||||
disabled,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createMarkdownClipboardContent } from "@/utils/rich-clipboard";
|
||||
import { pasteLinkedClipboardText } from "./pasted-text.web";
|
||||
|
||||
const inputs: HTMLTextAreaElement[] = [];
|
||||
function editor(): HTMLTextAreaElement {
|
||||
const input = document.createElement("textarea");
|
||||
document.body.append(input);
|
||||
inputs.push(input);
|
||||
return input;
|
||||
}
|
||||
afterEach(() => {
|
||||
for (const input of inputs.splice(0)) input.remove();
|
||||
});
|
||||
|
||||
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.each([
|
||||
"https://example.com/1",
|
||||
"https://example.com/日本語",
|
||||
"<https://example.com/a b>",
|
||||
"https://例え.テスト/issue",
|
||||
])("keeps complete Markdown from Copy with destination %s", (destination) => {
|
||||
const source = `| Resource | Status |\n| --- | --- |\n| [issue](${destination}) | ~~closed~~ |`;
|
||||
const content = createMarkdownClipboardContent(source);
|
||||
const input = editor();
|
||||
expect(pasteLinkedClipboardText(input, clipboard(content.html, content.plainText))).toBe(false);
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
it("converts external links when plain text names the resource but omits a destination", () => {
|
||||
const input = editor();
|
||||
expect(
|
||||
pasteLinkedClipboardText(
|
||||
input,
|
||||
clipboard(
|
||||
'<a href="https://example.com/%E6%97%A5%E6%9C%AC%E8%AA%9E">日本語</a> and <a href="https://example.com/missing">another</a>',
|
||||
"[日本語](https://example.com/日本語) and another",
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(input.value).toBe(
|
||||
"[日本語](https://example.com/%E6%97%A5%E6%9C%AC%E8%AA%9E) and [another](https://example.com/missing)",
|
||||
);
|
||||
});
|
||||
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...",
|
||||
);
|
||||
const input = editor();
|
||||
expect(pasteLinkedClipboardText(input, data)).toBe(true);
|
||||
expect(input.value).toBe(
|
||||
"Hey can you take a look at [this FR](https://example.com/issues/39150) and...",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves external linked table rows and columns", () => {
|
||||
const input = editor();
|
||||
expect(
|
||||
pasteLinkedClipboardText(
|
||||
input,
|
||||
clipboard(
|
||||
'<table><thead><tr><th>Resource</th><th>Status</th></tr></thead><tbody><tr><td><a href="https://example.com/issue">Issue</a></td><td>closed</td></tr></tbody></table>',
|
||||
"Resource\tStatus\nIssue\tclosed",
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(input.value).toBe(
|
||||
"| Resource | Status |\n| --- | --- |\n| [Issue](https://example.com/issue) | closed |",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps table cell separators and line breaks inside their cells", () => {
|
||||
const input = editor();
|
||||
expect(
|
||||
pasteLinkedClipboardText(
|
||||
input,
|
||||
clipboard(
|
||||
'<table><tr><th>Resource</th><th>Status</th></tr><tr><td><a href="https://example.com/issue">Issue | tracker</a></td><td>closed<br>today</td></tr></table>',
|
||||
"Resource\tStatus\nIssue | tracker\tclosed today",
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(input.value).toBe(
|
||||
"| Resource | Status |\n| --- | --- |\n| [Issue \\| tracker](https://example.com/issue) | closed<br>today |",
|
||||
);
|
||||
});
|
||||
|
||||
it("retains headerless tables as HTML without inventing a heading", () => {
|
||||
const html =
|
||||
'<table><tbody><tr><td><a href="https://example.com/issue">Issue</a></td><td>closed</td></tr><tr><td>Other</td><td>open</td></tr></tbody></table>';
|
||||
const input = editor();
|
||||
expect(pasteLinkedClipboardText(input, clipboard(html, "Issue\tclosed\nOther\topen"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(input.value).toBe(html);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["<table></table>", ""],
|
||||
["<table><caption>Context</caption></table>", "\n\nContext"],
|
||||
])("keeps surrounding links when a table has no rows: %s", (table, caption) => {
|
||||
const input = editor();
|
||||
expect(
|
||||
pasteLinkedClipboardText(
|
||||
input,
|
||||
clipboard(`<a href="https://example.com/">Issue</a>${table}`, "Issue"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(input.value).toBe(`[Issue](https://example.com/)${caption}`);
|
||||
});
|
||||
|
||||
it("leaves plain text and rich text without links to native paste", () => {
|
||||
const input = editor();
|
||||
expect(pasteLinkedClipboardText(input, clipboard("", "[link](https://example.com)"))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(pasteLinkedClipboardText(input, clipboard("<b>hello</b>", "hello"))).toBe(false);
|
||||
expect(pasteLinkedClipboardText(input, null)).toBe(false);
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
it("keeps paragraphs and multiple links", () => {
|
||||
const input = editor();
|
||||
expect(
|
||||
pasteLinkedClipboardText(
|
||||
input,
|
||||
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(true);
|
||||
expect(input.value).toBe(
|
||||
"See [one](https://example.com/a)\n\nand [two](https://example.com/b)",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Native inputs retain their platform clipboard handling. */
|
||||
export function pasteLinkedClipboardText(
|
||||
_input: HTMLElement,
|
||||
_clipboard: Pick<DataTransfer, "getData"> | null,
|
||||
): boolean {
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import TurndownService from "turndown";
|
||||
import { tables } from "turndown-plugin-gfm";
|
||||
import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser";
|
||||
|
||||
const markdown = new TurndownService({
|
||||
codeBlockStyle: "fenced",
|
||||
bulletListMarker: "-",
|
||||
});
|
||||
// Heading tables become Markdown; headerless tables retain their HTML structure.
|
||||
markdown.use(tables);
|
||||
markdown.addRule("rowlessTable", {
|
||||
filter: (node) => node.nodeName === "TABLE" && (node as HTMLTableElement).rows.length === 0,
|
||||
replacement: (content) => (content ? `\n\n${content}\n\n` : ""),
|
||||
});
|
||||
markdown.addRule("tableCell", {
|
||||
filter: ["th", "td"],
|
||||
replacement: (content, node) => {
|
||||
const prefix = node.previousElementSibling ? " " : "| ";
|
||||
const cell = content.trim().replace(/\|/g, "\\|").replace(/ *\n/g, "<br>");
|
||||
return `${prefix}${cell} |`;
|
||||
},
|
||||
});
|
||||
const copiedMarkdown = createAssistantMarkdownParser();
|
||||
|
||||
function linkDestinations(fragment: Document): string[] {
|
||||
return Array.from(fragment.querySelectorAll("a[href]"), (link) => link.getAttribute("href")!);
|
||||
}
|
||||
|
||||
function containsDestinations(plainText: string, destinations: string[]): boolean {
|
||||
if (destinations.every((url) => plainText.includes(url))) return true;
|
||||
|
||||
// Copy normalizes Markdown URLs in HTML (percent encoding, internationalized
|
||||
// domains). Compare parsed destinations using that same parser, not URL spelling.
|
||||
const rendered = new DOMParser().parseFromString(copiedMarkdown.render(plainText), "text/html");
|
||||
const links = new Set(linkDestinations(rendered));
|
||||
return destinations.every((url) => plainText.includes(url) || links.has(url));
|
||||
}
|
||||
|
||||
/** Paste linked HTML as Markdown through the browser's undoable editing command. */
|
||||
export function pasteLinkedClipboardText(
|
||||
input: HTMLElement,
|
||||
clipboard: Pick<DataTransfer, "getData"> | null,
|
||||
): boolean {
|
||||
const html = clipboard?.getData("text/html");
|
||||
if (!html) return false;
|
||||
|
||||
// Parse in a detached document. Clipboard markup is never mounted in the app.
|
||||
const fragment = new DOMParser().parseFromString(html, "text/html");
|
||||
const destinations = linkDestinations(fragment);
|
||||
const plainText = clipboard!.getData("text/plain");
|
||||
// Copy actions already put complete Markdown on the clipboard. Keep that
|
||||
// representation, including tables and formatting, whenever it carries the URLs.
|
||||
if (containsDestinations(plainText, destinations)) return false;
|
||||
const text = markdown.turndown(fragment.body);
|
||||
input.focus();
|
||||
// Direct value assignment drops the textarea's undo history. This also emits
|
||||
// the normal input event so the editor publishes its new draft as for typing.
|
||||
return input.ownerDocument.execCommand("insertText", false, text);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
declare module "turndown-plugin-gfm" {
|
||||
import type TurndownService from "turndown";
|
||||
|
||||
export const tables: TurndownService.Plugin;
|
||||
export const gfm: TurndownService.Plugin;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user