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:
paseo-bot[bot]
2026-11-06 22:16:18 +00:00
parent 4cf91fa16d
commit 474a2671a2
6 changed files with 105 additions and 54 deletions
@@ -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");
}
+5 -36
View File
@@ -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)",
);
});
});
+4 -3
View File
@@ -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;
}
+19 -7
View File
@@ -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);
}