mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 00:44:49 +00:00
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.
This commit is contained in:
@@ -2,6 +2,8 @@ import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
pasteRichTextIntoComposer,
|
||||
selectComposerDraftRange,
|
||||
undoComposerEdit,
|
||||
redoComposerEdit,
|
||||
blurComposerInput,
|
||||
roundTripComposerTabBeforePaint,
|
||||
composerLocator,
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
openMobileAgentSidebarWithKeyboard,
|
||||
swipeOpenMobileExplorer,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { readScrollMetrics } from "../support/helpers/agent-bottom-anchor";
|
||||
import {
|
||||
expectTimelinePromptPositionPreserved,
|
||||
@@ -131,6 +134,7 @@ test("submitting a message leaves the composer ready for the next message", asyn
|
||||
title: "Composer focus",
|
||||
});
|
||||
|
||||
const connection = await installDaemonWebSocketGate(page);
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
@@ -149,11 +153,38 @@ test("submitting a message leaves the composer ready for the next message", asyn
|
||||
page,
|
||||
"Investigate: [this FR](https://example.com/issues/39150). Thanks",
|
||||
);
|
||||
await undoComposerEdit(page);
|
||||
await expectComposerDraft(page, "Investigate: replace this. Thanks");
|
||||
await redoComposerEdit(page);
|
||||
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 connection.drop();
|
||||
await connection.waitForBlockedConnection();
|
||||
await pasteRichTextIntoComposer(
|
||||
page,
|
||||
'<a href="https://example.com/offline">offline link</a>',
|
||||
"offline link",
|
||||
);
|
||||
await expectComposerDraft(
|
||||
page,
|
||||
"Investigate: [this FR](https://example.com/issues/39150) next[offline link](https://example.com/offline). Thanks",
|
||||
);
|
||||
const copiedTable =
|
||||
"| Resource | Status |\n| --- | --- |\n| [issue](https://example.com/1) | ~~closed~~ |";
|
||||
await composerLocator(page).fill("");
|
||||
await pasteRichTextIntoComposer(
|
||||
page,
|
||||
'<table><tr><th>Resource</th><th>Status</th></tr><tr><td><a href="https://example.com/1">issue</a></td><td><s>closed</s></td></tr></table>',
|
||||
copiedTable,
|
||||
);
|
||||
await expectComposerDraft(page, copiedTable);
|
||||
await expectComposerFocused(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
|
||||
@@ -587,3 +587,10 @@ export async function pasteRichTextIntoComposer(
|
||||
);
|
||||
await composerInput(page).press("ControlOrMeta+v");
|
||||
}
|
||||
|
||||
export async function undoComposerEdit(page: Page): Promise<void> {
|
||||
await composerInput(page).press("ControlOrMeta+z");
|
||||
}
|
||||
export async function redoComposerEdit(page: Page): Promise<void> {
|
||||
await composerInput(page).press("ControlOrMeta+Shift+z");
|
||||
}
|
||||
|
||||
@@ -88,7 +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 { pasteLinkedClipboardText } from "@/composer/pasted-text";
|
||||
import type { NativePastedFile } from "@/composer/native-pasted-image";
|
||||
import {
|
||||
EditingTextInput,
|
||||
@@ -521,7 +521,6 @@ interface PasteEffectArgs {
|
||||
isDictating: boolean;
|
||||
isRealtimeVoiceForCurrentAgent: boolean;
|
||||
onAddImages: ((images: ImageAttachment[]) => void) | undefined;
|
||||
onPasteText: (text: string) => void;
|
||||
}
|
||||
|
||||
function usePasteEffect(args: PasteEffectArgs): void {
|
||||
@@ -532,7 +531,6 @@ function usePasteEffect(args: PasteEffectArgs): void {
|
||||
isDictating,
|
||||
isRealtimeVoiceForCurrentAgent,
|
||||
onAddImages,
|
||||
onPasteText,
|
||||
} = args;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -542,17 +540,14 @@ function usePasteEffect(args: PasteEffectArgs): void {
|
||||
|
||||
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) {
|
||||
const text = readLinkedClipboardText(event.clipboardData);
|
||||
if (text === null) return;
|
||||
event.preventDefault();
|
||||
onPasteText(text);
|
||||
if (pasteLinkedClipboardText(textarea, event.clipboardData)) event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!onAddImages) return;
|
||||
if (!isConnected || !onAddImages) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
@@ -572,15 +567,7 @@ function usePasteEffect(args: PasteEffectArgs): void {
|
||||
disposed = true;
|
||||
textarea.removeEventListener?.("paste", handlePaste);
|
||||
};
|
||||
}, [
|
||||
disabled,
|
||||
textarea,
|
||||
isConnected,
|
||||
isDictating,
|
||||
isRealtimeVoiceForCurrentAgent,
|
||||
onAddImages,
|
||||
onPasteText,
|
||||
]);
|
||||
}, [disabled, textarea, isConnected, isDictating, isRealtimeVoiceForCurrentAgent, onAddImages]);
|
||||
}
|
||||
|
||||
function useAutoFocusOnWebEffect(
|
||||
@@ -1829,23 +1816,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
handleAlternateSendAction();
|
||||
}, [alternateSendAction, handleAlternateSendAction]);
|
||||
|
||||
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,
|
||||
@@ -1853,7 +1823,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
isDictating,
|
||||
isRealtimeVoiceForCurrentAgent,
|
||||
onAddImages,
|
||||
onPasteText: handlePasteText,
|
||||
});
|
||||
|
||||
const handleSelectionChange = useCallback(
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readLinkedClipboardText } from "./pasted-text.web";
|
||||
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();
|
||||
@@ -9,30 +21,49 @@ function clipboard(html: string, text: string): DataTransfer {
|
||||
}
|
||||
|
||||
describe("pasted prompt links", () => {
|
||||
it("keeps complete Markdown from Copy without reconverting its linked table or strikethrough", () => {
|
||||
const source =
|
||||
"| Resource | Status |\n| --- | --- |\n| [issue](https://example.com/1) | ~~closed~~ |";
|
||||
const content = createMarkdownClipboardContent(source);
|
||||
const input = editor();
|
||||
expect(pasteLinkedClipboardText(input, clipboard(content.html, content.plainText))).toBe(false);
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
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(
|
||||
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("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();
|
||||
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(
|
||||
readLinkedClipboardText(
|
||||
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("See [one](https://example.com/a)\n\nand [two](https://example.com/b)");
|
||||
).toBe(true);
|
||||
expect(input.value).toBe(
|
||||
"See [one](https://example.com/a)\n\nand [two](https://example.com/b)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Native inputs retain their platform clipboard handling. */
|
||||
export function readLinkedClipboardText(
|
||||
export function pasteLinkedClipboardText(
|
||||
_input: HTMLElement,
|
||||
_clipboard: Pick<DataTransfer, "getData"> | null,
|
||||
): string | null {
|
||||
return null;
|
||||
): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2,15 +2,27 @@ 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(
|
||||
/** Paste linked HTML as Markdown through the browser's undoable editing command. */
|
||||
export function pasteLinkedClipboardText(
|
||||
input: HTMLElement,
|
||||
clipboard: Pick<DataTransfer, "getData"> | null,
|
||||
): string | null {
|
||||
): boolean {
|
||||
const html = clipboard?.getData("text/html");
|
||||
if (!html) return null;
|
||||
if (!html) return false;
|
||||
|
||||
// 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);
|
||||
const fragment = new DOMParser().parseFromString(html, "text/html");
|
||||
const destinations = Array.from(
|
||||
fragment.querySelectorAll("a[href]"),
|
||||
(link) => link.getAttribute("href")!,
|
||||
);
|
||||
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 (destinations.every((url) => plainText.includes(url))) 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user