diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index d23e9ece2..a268f41ed 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-Mg6cn08RoEFmAYKDjowYd7DZSeeJezix0rzQeDadRAs= +sha256-WOVtBB7AQke7RM9FDJ1mUDU0sLMf03QOrvajrXSFwPs= diff --git a/package-lock.json b/package-lock.json index ad0390d58..a322f4b7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7728,6 +7728,12 @@ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "license": "MIT" }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "license": "BSD-2-Clause" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -12952,6 +12958,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/turndown": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz", + "integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -33434,6 +33447,12 @@ "domino": "^2.1.6" } }, + "node_modules/turndown-plugin-gfm": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.2.tgz", + "integrity": "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==", + "license": "MIT" + }, "node_modules/tweetnacl": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", @@ -35388,6 +35407,8 @@ "react-native-webview": "^13.16.0", "react-native-worklets": "~0.8.3", "tiny-invariant": "^1.3.3", + "turndown": "^7.2.4", + "turndown-plugin-gfm": "^1.0.2", "use-sync-external-store": "^1.6.0", "zod": "^4.4.3", "zustand": "^5.0.9" @@ -35399,6 +35420,7 @@ "@types/markdown-it": "^14.1.2", "@types/qrcode": "^1.5.6", "@types/react": "~19.2.0", + "@types/turndown": "^5.0.6", "@types/ws": "^8.18.1", "@vitest/browser": "^4.1.7", "@vitest/browser-playwright": "^4.1.7", @@ -36237,6 +36259,19 @@ "dev": true, "license": "MIT" }, + "packages/app/node_modules/turndown": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz", + "integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==", + "license": "MIT", + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + }, + "engines": { + "node": ">=18", + "npm": ">=9" + } + }, "packages/app/node_modules/undici": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", diff --git a/packages/app/e2e/browser/assistant-selection-copy.spec.ts b/packages/app/e2e/browser/assistant-selection-copy.spec.ts new file mode 100644 index 000000000..2df4420ec --- /dev/null +++ b/packages/app/e2e/browser/assistant-selection-copy.spec.ts @@ -0,0 +1,315 @@ +import type { BrowserContext } from "@playwright/test"; +import { expect, test, type Page } from "../support/fixtures"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; + +const ASSISTANT_MARKDOWN = [ + "Direct matches:", + "", + "Formatted **strong prose**, _emphasized prose_, and ~~struck prose~~.", + "", + "- **[First issue](https://example.com/issues/1)**: exact `apply_patch` failure.", + "- [Second issue](https://example.com/issues/2): repeated sandbox setup.", + "", + "5. Fifth item", + "6. Sixth item", + "7. Seventh item", + "8. Eighth item", + "", + "Nested list:", + "", + "3. Outer three", + "", + " 7. Inner seven", + " 8. Inner eight", + " 9. Inner nine", + "", + "A hard break ", + "stays hard.", + "", + "www.example.com stays plain Markdown text.", + "", + '[docs](https://docs.example.com "Reference") and .', + "", + "[workspace file](file:///tmp/paseo%20notes.md#L4)", + "", + "`https://example.com/generated` stays code, not a generated link.", + "", + "```typescript", + 'const answer = "yes";', + "```", + "", + "````text", + "before", + "```", + "after", + "````", + "", + "| Left | Right | Center |", + "| :-- | --: | :-: |", + "| Current \\| active | ~~obsolete~~ | ready |", +].join("\n"); + +const EXPECTED_WHOLE_SELECTION_MARKDOWN = ASSISTANT_MARKDOWN.replace( + "", + "[https://autolink.example.com](https://autolink.example.com)", +).replace( + "3. Outer three\n\n 7. Inner seven\n 8. Inner eight\n 9. Inner nine", + "3. Outer three\n 7. Inner seven\n 8. Inner eight\n 9. Inner nine", +); + +interface ClipboardContent { + html: string; + plainText: string; +} + +async function allowRichClipboard(context: BrowserContext): Promise { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); +} + +async function selectAssistantMessage(page: Page): Promise { + const assistantMessage = page.getByTestId("assistant-message").filter({ + hasText: "Direct matches:", + }); + await expect(assistantMessage).toBeVisible(); + await assistantMessage.evaluate((element) => { + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(element); + selection?.removeAllRanges(); + selection?.addRange(range); + }); +} + +async function selectAssistantText(page: Page, text: string): Promise { + await selectAssistantTextRange(page, text, text); +} + +async function selectAssistantTextRange( + page: Page, + startText: string, + endText: string, +): Promise { + const assistantMessage = page.getByTestId("assistant-message").filter({ + hasText: "Direct matches:", + }); + await assistantMessage.evaluate( + (element, selectedRange) => { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let startNode: Node | null = null; + let startOffset = -1; + let endNode: Node | null = null; + let endOffset = -1; + let textNode = walker.nextNode(); + while (textNode) { + if (!startNode) { + const offset = textNode.textContent?.indexOf(selectedRange.startText) ?? -1; + if (offset >= 0) { + startNode = textNode; + startOffset = offset; + } + } + if (startNode) { + const offset = textNode.textContent?.indexOf(selectedRange.endText) ?? -1; + if (offset >= 0) { + endNode = textNode; + endOffset = offset + selectedRange.endText.length; + break; + } + } + textNode = walker.nextNode(); + } + if (!startNode || !endNode) { + throw new Error( + `Could not find assistant text range: ${selectedRange.startText} — ${selectedRange.endText}`, + ); + } + const range = document.createRange(); + range.setStart(startNode, startOffset); + range.setEnd(endNode, endOffset); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + }, + { startText, endText }, + ); +} + +async function copySelection(page: Page): Promise { + await page.keyboard.press("ControlOrMeta+c"); +} + +async function readRichClipboard(page: Page): Promise { + return page.evaluate(async () => { + const items = await navigator.clipboard.read(); + const item = items[0]; + if (!item) { + throw new Error("Expected clipboard content"); + } + return { + html: await (await item.getType("text/html")).text(), + plainText: await (await item.getType("text/plain")).text(), + }; + }); +} + +test("copying an assistant selection preserves Markdown structure and links", async ({ + context, + page, +}) => { + await page.addInitScript(() => { + localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontFamily: "serif" })); + }); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "assistant-selection-copy-", + title: "Assistant selection copy", + initialPrompt: "Render the clipboard fixture.", + featureValues: { mockAssistantResponse: ASSISTANT_MARKDOWN }, + }); + + try { + await allowRichClipboard(context); + await agent.client.waitForAgentUpsert( + agent.agentId, + (snapshot) => snapshot.status === "idle", + 30_000, + ); + await openAgentRoute(page, agent); + + const assistantMessage = page.getByTestId("assistant-message").filter({ + hasText: "Direct matches:", + }); + for (const [tag, text] of [ + ["strong", "strong prose"], + ["em", "emphasized prose"], + ["s", "struck prose"], + ]) { + const formattedProse = assistantMessage + .locator(`[data-paseo-markdown-tag="${tag}"]`) + .filter({ hasText: text }); + await expect(formattedProse).toHaveCSS("font-family", "serif"); + await expect(formattedProse).not.toHaveAttribute("data-pmono"); + } + const inlineCode = assistantMessage + .locator('[data-paseo-markdown-tag="code"]') + .filter({ hasText: "apply_patch" }); + await expect(inlineCode).toHaveAttribute("data-pmono", ""); + + await selectAssistantMessage(page); + await copySelection(page); + + const clipboard = await readRichClipboard(page); + expect(clipboard.plainText).toBe(EXPECTED_WHOLE_SELECTION_MARKDOWN); + expect(clipboard.html).toContain("