diff --git a/packages/app/e2e/browser/composer-focus.spec.ts b/packages/app/e2e/browser/composer-focus.spec.ts index 11fe0bf50f..4f2610b8db 100644 --- a/packages/app/e2e/browser/composer-focus.spec.ts +++ b/packages/app/e2e/browser/composer-focus.spec.ts @@ -1,5 +1,11 @@ import { expect, test, type Page } from "../support/fixtures"; import { + pasteRichTextIntoComposer, + fillComposerDraft, + copyLatestTurnIntoComposer, + selectComposerDraftRange, + undoComposerEdit, + redoComposerEdit, blurComposerInput, roundTripComposerTabBeforePaint, composerLocator, @@ -28,6 +34,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, @@ -55,7 +62,9 @@ test("clicking chat text or background focuses the feed for keyboard paging", as await expect .poll(async () => (await readScrollMetrics(page)).offsetY) .toBeLessThan(beforePaging.offsetY - beforePaging.viewportHeight / 2); - await page.screenshot({ path: testInfo.outputPath("chat-keyboard-paging.png") }); + await page.screenshot({ + path: testInfo.outputPath("chat-keyboard-paging.png"), + }); await page.keyboard.press("PageDown"); await expect .poll(async () => (await readScrollMetrics(page)).distanceFromBottom) @@ -124,11 +133,15 @@ test("focusing the feed preserves assistant text selection and code copy control }); test("submitting a message leaves the composer ready for the next message", async ({ page }) => { + const copiedTable = + "| Resource | Status |\n| --- | --- |\n| [Unicode](https://example.com/日本語) | ~~closed~~ |\n| [Space]() | **open** |\n| [ASCII](https://example.com/1) | closed |"; const agent = await seedMockAgentWorkspace({ repoPrefix: "composer-focus-", title: "Composer focus", + featureValues: { mockAssistantResponse: copiedTable }, }); + const connection = await installDaemonWebSocketGate(page); try { await openAgentRoute(page, agent); await expectComposerVisible(page); @@ -136,8 +149,62 @@ 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, + html: 'this FR', + text: "this FR", + }); + await expectComposerDraft( + 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 agent.client.waitForFinish(agent.agentId, 15_000); + await expect(page.getByRole("button", { name: "Copy turn", exact: true })).toBeVisible(); + await connection.drop(); + await connection.waitForBlockedConnection(); + await pasteRichTextIntoComposer({ + page, + html: 'offline link', + text: "offline link", + }); + await expectComposerDraft( + page, + "Investigate: [this FR](https://example.com/issues/39150) next[offline link](https://example.com/offline). Thanks", + ); + await fillComposerDraft(page, ""); + await pasteRichTextIntoComposer({ + page, + html: '
ResourceStatus
Issueclosed
', + text: "Resource\tStatus\nIssue\tclosed", + }); + await expectComposerDraft( + page, + "| Resource | Status |\n| --- | --- |\n| [Issue](https://example.com/issue) | closed |", + ); + await fillComposerDraft(page, ""); + await pasteRichTextIntoComposer({ + page, + html: 'Issue
', + text: "Issue", + }); + await expectComposerDraft(page, "[Issue](https://example.com/)"); + await copyLatestTurnIntoComposer(page); + await expectComposerDraft(page, copiedTable); + await expectComposerFocused(page); } finally { await agent.cleanup(); } @@ -168,7 +235,11 @@ async function waitForAnimationFrames(page: Page, count: number): Promise test.describe("touch screen wide enough for the desktop layout", () => { // A tablet or an unfolded foldable gets the desktop layout, but focusing the // composer there raises the on-screen keyboard over the conversation. - test.use({ viewport: { width: 1024, height: 768 }, isMobile: true, hasTouch: true }); + test.use({ + viewport: { width: 1024, height: 768 }, + isMobile: true, + hasTouch: true, + }); test("remounting an inline composer does not restore focus on touch", async ({ page }) => { const agent = await seedMockAgentWorkspace({ @@ -192,7 +263,9 @@ test.describe("touch screen wide enough for the desktop layout", () => { test("returning to an inactive inline composer does not restore focus on touch", async ({ page, }) => { - const workspace = await seedWorkspace({ repoPrefix: "composer-touch-retained-" }); + const workspace = await seedWorkspace({ + repoPrefix: "composer-touch-retained-", + }); try { const first = await createMockIdleAgent(workspace.client, { cwd: workspace.repoPath, diff --git a/packages/app/e2e/browser/workspace-archive-undo.spec.ts b/packages/app/e2e/browser/workspace-archive-undo.spec.ts index 90e47aab3c..9bf8e4cafd 100644 --- a/packages/app/e2e/browser/workspace-archive-undo.spec.ts +++ b/packages/app/e2e/browser/workspace-archive-undo.spec.ts @@ -112,6 +112,7 @@ test.describe("Workspace archive undo", () => { await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible({ timeout: 30_000 }); await expectDirectoryPresence(worktree.workspaceDirectory, true); + await expectHostWorkspaceSettledActive(client, worktree.workspaceId); }); test("hovering Undo keeps the toast open past its deadline, and its close ends the undo", async ({ diff --git a/packages/app/e2e/support/helpers/composer.ts b/packages/app/e2e/support/helpers/composer.ts index bc0fef67f3..3c29bc24cc 100644 --- a/packages/app/e2e/support/helpers/composer.ts +++ b/packages/app/e2e/support/helpers/composer.ts @@ -31,7 +31,9 @@ export async function expectComposerVisible( page: Page, options?: { timeout?: number }, ): Promise { - await expect(composerInput(page)).toBeVisible({ timeout: options?.timeout ?? 15_000 }); + await expect(composerInput(page)).toBeVisible({ + timeout: options?.timeout ?? 15_000, + }); } export async function expectComposerDisabled(page: Page): Promise { @@ -156,7 +158,9 @@ export async function attachImageFromMenu( } export async function expectAttachmentPill(page: Page, testID: string): Promise { - await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId(testID).first()).toBeVisible({ + timeout: 10_000, + }); } export async function attachFileFromMenu( @@ -218,18 +222,24 @@ export async function expectGithubAttachmentPill( export async function openImageLightbox(page: Page): Promise { await page.getByRole("button", { name: "Open image attachment" }).first().click(); - await expect(page.getByTestId("attachment-lightbox-close")).toBeVisible({ timeout: 5_000 }); + await expect(page.getByTestId("attachment-lightbox-close")).toBeVisible({ + timeout: 5_000, + }); } export async function closeImageLightbox(page: Page): Promise { await page.keyboard.press("Escape"); - await expect(page.getByTestId("attachment-lightbox-close")).not.toBeVisible({ timeout: 5_000 }); + await expect(page.getByTestId("attachment-lightbox-close")).not.toBeVisible({ + timeout: 5_000, + }); } export async function openGithubPickerFromMenu(page: Page): Promise { await openAttachmentMenu(page); await page.getByTestId("message-input-attachment-menu-item-github").click(); - await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 5_000 }); + await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ + timeout: 5_000, + }); } /** Open picker, type a query, wait for the matching option by id (e.g. "issue:3", "change_request:1"), and click it. */ @@ -283,7 +293,10 @@ export async function startRunningMockAgent( model: opts.model, featureValues: opts.featureValues, }); - const agentUrl = `${buildHostWorkspaceRoute(serverId, workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`; + const agentUrl = `${buildHostWorkspaceRoute( + serverId, + workspace.id, + )}?open=${encodeURIComponent(`agent:${agent.id}`)}`; await page.goto(agentUrl); await expectComposerVisible(page); await client.sendAgentMessage(agent.id, opts.prompt); @@ -347,7 +360,11 @@ export async function pasteComposerTestImage(page: Page): Promise { const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0)); const clipboardData = new DataTransfer(); clipboardData.items.add(new File([bytes], "pasted.png", { type: "image/png" })); - const event = new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }); + const event = new ClipboardEvent("paste", { + clipboardData, + bubbles: true, + cancelable: true, + }); input.dispatchEvent(event); return event.defaultPrevented; }, COMPOSER_TEST_IMAGE_BASE64); @@ -505,7 +522,10 @@ export async function recordVisibleComposerGeometry(page: Page): Promise { export async function expectNoCollapsedVisibleComposer(page: Page): Promise { const frames = await page.evaluate(() => { Reflect.get(window, "__stopComposerGeometry")?.(); - return Reflect.get(window, "__composerGeometryFrames") as { input: number; root: number }[]; + return Reflect.get(window, "__composerGeometryFrames") as { + input: number; + root: number; + }[]; }); expect(frames.length).toBeGreaterThan(0); expect(frames.filter((frame) => frame.root < 42 || frame.input < 42)).toEqual([]); @@ -516,7 +536,10 @@ export async function roundTripComposerTabBeforePaint( page: Page, activeChatTitle: string, ): Promise { - const activeTab = page.getByRole("button", { name: activeChatTitle, exact: true }); + const activeTab = page.getByRole("button", { + name: activeChatTitle, + exact: true, + }); await expect(activeTab).toHaveAttribute("aria-selected", "true"); const result = await activeTab.evaluate((tab) => { const editor = document.activeElement; @@ -556,7 +579,10 @@ export async function roundTripComposerTabBeforePaint( }); } }); - observer.observe(tab, { attributes: true, attributeFilter: ["aria-selected"] }); + observer.observe(tab, { + attributes: true, + attributeFilter: ["aria-selected"], + }); shortcut("BracketLeft"); }); }); @@ -566,3 +592,44 @@ 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, + html, + text, +}: { + page: Page; + html: string; + text: string; +}): Promise { + 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"); +} + +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"); +} + +/** Reuse the actual response Copy action and the browser's native paste. */ +export async function copyLatestTurnIntoComposer(page: Page): Promise { + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await page.getByRole("button", { name: "Copy turn", exact: true }).last().click(); + await expect(page.getByRole("button", { name: "Copied", exact: true })).toBeVisible(); + await composerInput(page).fill(""); + await composerInput(page).press("ControlOrMeta+v"); +} diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 3c4541181f..fc82a73e6d 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -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( handleAlternateSendAction(); }, [alternateSendAction, handleAlternateSendAction]); - usePasteImagesEffect({ + usePasteEffect({ textarea: webTextArea, isConnected, disabled, diff --git a/packages/app/src/composer/pasted-text.browser.test.ts b/packages/app/src/composer/pasted-text.browser.test.ts new file mode 100644 index 0000000000..e492cd237b --- /dev/null +++ b/packages/app/src/composer/pasted-text.browser.test.ts @@ -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://例え.テスト/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( + '日本語 and another', + "[日本語](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 this FR 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( + '
ResourceStatus
Issueclosed
', + "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( + '
ResourceStatus
Issue | trackerclosed
today
', + "Resource\tStatus\nIssue | tracker\tclosed today", + ), + ), + ).toBe(true); + expect(input.value).toBe( + "| Resource | Status |\n| --- | --- |\n| [Issue \\| tracker](https://example.com/issue) | closed
today |", + ); + }); + + it("retains headerless tables as HTML without inventing a heading", () => { + const html = + '
Issueclosed
Otheropen
'; + const input = editor(); + expect(pasteLinkedClipboardText(input, clipboard(html, "Issue\tclosed\nOther\topen"))).toBe( + true, + ); + expect(input.value).toBe(html); + }); + + it.each([ + ["
", ""], + ["
Context
", "\n\nContext"], + ])("keeps surrounding links when a table has no rows: %s", (table, caption) => { + const input = editor(); + expect( + pasteLinkedClipboardText( + input, + clipboard(`Issue${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("hello", "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( + '

See one

and two

', + "See one\n\nand two", + ), + ), + ).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 new file mode 100644 index 0000000000..99e7b2cfb5 --- /dev/null +++ b/packages/app/src/composer/pasted-text.ts @@ -0,0 +1,7 @@ +/** Native inputs retain their platform clipboard handling. */ +export function pasteLinkedClipboardText( + _input: HTMLElement, + _clipboard: Pick | null, +): boolean { + return false; +} diff --git a/packages/app/src/composer/pasted-text.web.ts b/packages/app/src/composer/pasted-text.web.ts new file mode 100644 index 0000000000..b9b89d215e --- /dev/null +++ b/packages/app/src/composer/pasted-text.web.ts @@ -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, "
"); + 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 | 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); +} diff --git a/packages/app/src/types/turndown-plugin-gfm.d.ts b/packages/app/src/types/turndown-plugin-gfm.d.ts index a6c0855cf9..3562d8bfbd 100644 --- a/packages/app/src/types/turndown-plugin-gfm.d.ts +++ b/packages/app/src/types/turndown-plugin-gfm.d.ts @@ -1,5 +1,6 @@ declare module "turndown-plugin-gfm" { import type TurndownService from "turndown"; + export const tables: TurndownService.Plugin; export const gfm: TurndownService.Plugin; }