diff --git a/packages/app/e2e/browser/composer-focus.spec.ts b/packages/app/e2e/browser/composer-focus.spec.ts
index 8423afad95..86a9dd8a80 100644
--- a/packages/app/e2e/browser/composer-focus.spec.ts
+++ b/packages/app/e2e/browser/composer-focus.spec.ts
@@ -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,
+ 'offline link',
+ "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,
+ '
| Resource | Status |
|---|
| issue | closed |
',
+ copiedTable,
+ );
+ await expectComposerDraft(page, copiedTable);
await expectComposerFocused(page);
} finally {
await agent.cleanup();
diff --git a/packages/app/e2e/support/helpers/composer.ts b/packages/app/e2e/support/helpers/composer.ts
index e5ef12c500..c1989f5b4f 100644
--- a/packages/app/e2e/support/helpers/composer.ts
+++ b/packages/app/e2e/support/helpers/composer.ts
@@ -587,3 +587,10 @@ export async function pasteRichTextIntoComposer(
);
await composerInput(page).press("ControlOrMeta+v");
}
+
+export async function undoComposerEdit(page: Page): Promise {
+ await composerInput(page).press("ControlOrMeta+z");
+}
+export async function redoComposerEdit(page: Page): Promise {
+ await composerInput(page).press("ControlOrMeta+Shift+z");
+}
diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx
index 4f4e97dece..fc82a73e6d 100644
--- a/packages/app/src/composer/input/input.tsx
+++ b/packages/app/src/composer/input/input.tsx
@@ -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(
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(
isDictating,
isRealtimeVoiceForCurrentAgent,
onAddImages,
- onPasteText: handlePasteText,
});
const handleSelectionChange = useCallback(
diff --git a/packages/app/src/composer/pasted-text.browser.test.ts b/packages/app/src/composer/pasted-text.browser.test.ts
index e9ed6580cf..b307cec1ff 100644
--- a/packages/app/src/composer/pasted-text.browser.test.ts
+++ b/packages/app/src/composer/pasted-text.browser.test.ts
@@ -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 this FR 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("hello", "hello"))).toBeNull();
- expect(readLinkedClipboardText(null)).toBeNull();
+ const input = editor();
+ expect(pasteLinkedClipboardText(input, clipboard("", "[link](https://example.com)"))).toBe(
+ false,
+ );
+ expect(pasteLinkedClipboardText(input, clipboard("hello", "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(
'See one
and two
',
"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)",
+ );
});
});
diff --git a/packages/app/src/composer/pasted-text.ts b/packages/app/src/composer/pasted-text.ts
index f25c1c3780..99e7b2cfb5 100644
--- a/packages/app/src/composer/pasted-text.ts
+++ b/packages/app/src/composer/pasted-text.ts
@@ -1,6 +1,7 @@
/** Native inputs retain their platform clipboard handling. */
-export function readLinkedClipboardText(
+export function pasteLinkedClipboardText(
+ _input: HTMLElement,
_clipboard: Pick | null,
-): string | null {
- return null;
+): boolean {
+ return false;
}
diff --git a/packages/app/src/composer/pasted-text.web.ts b/packages/app/src/composer/pasted-text.web.ts
index fdd05f347e..8ca0a27633 100644
--- a/packages/app/src/composer/pasted-text.web.ts
+++ b/packages/app/src/composer/pasted-text.web.ts
@@ -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 | 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);
}