From d3074ce52ef864619ea021ccfc1ed76969815b97 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 13 May 2026 15:50:42 -0400 Subject: [PATCH 1/8] fix(ui): preserve hyperlinks when pasting rich text into chat input When pasting content with embedded hyperlinks (e.g., from Google Docs), the links were stripped because the textarea only receives plain text by default. Now the paste handler checks for HTML clipboard data containing links and converts them to markdown format [text](url) before inserting. Since the output side already renders markdown via ReactMarkdown, the full round-trip works: pasted links are preserved in the input, sent to Goose, and rendered as clickable links in responses. Fixes #6079 Signed-off-by: Douwe Osinga --- ui/desktop/src/components/ChatInput.tsx | 51 ++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 2878cb05dd6f..22e8d058c56b 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -814,7 +814,56 @@ export default function ChatInput({ const files = Array.from(evt.clipboardData.files || []); const imageFiles = files.filter((file) => file.type.startsWith('image/')); - if (imageFiles.length === 0) return; + if (imageFiles.length === 0) { + // Check for rich text with hyperlinks and convert to markdown + const html = evt.clipboardData.getData('text/html'); + if (html) { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const links = doc.querySelectorAll('a[href]'); + if (links.length > 0) { + evt.preventDefault(); + const convertNodeToMarkdown = (node: Node): string => { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent || ''; + } + if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as HTMLElement; + if (el.tagName === 'A' && el.getAttribute('href')) { + const href = el.getAttribute('href')!; + const text = el.textContent || href; + return `[${text}](${href})`; + } + if (el.tagName === 'BR') { + return '\n'; + } + if (el.tagName === 'P' || el.tagName === 'DIV') { + const inner = Array.from(el.childNodes).map(convertNodeToMarkdown).join(''); + return inner + '\n'; + } + return Array.from(el.childNodes).map(convertNodeToMarkdown).join(''); + } + return ''; + }; + const markdown = convertNodeToMarkdown(doc.body).replace(/\n{3,}/g, '\n\n').trim(); + const textarea = textAreaRef.current; + if (textarea) { + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const newValue = + displayValue.substring(0, start) + markdown + displayValue.substring(end); + setDisplayValue(newValue); + updateValue(newValue); + setHasUserTyped(true); + // Set cursor position after inserted text + requestAnimationFrame(() => { + textarea.selectionStart = textarea.selectionEnd = start + markdown.length; + }); + } + } + } + return; + } // Check if adding these images would exceed the limit if (pastedImages.length + imageFiles.length > MAX_IMAGES_PER_MESSAGE) { From 192cf708668b06b7b0fd72c775b7a54783ba4e20 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 13 May 2026 16:01:43 -0400 Subject: [PATCH 2/8] fix(ui): skip non-content HTML nodes when converting pasted rich text to markdown Clipboard payloads from Office/Google Docs often include ' + + '

Check this

'; + expect(htmlToMarkdown(html)).toBe('Check [this](https://example.com)'); + }); + + it('strips script tags', () => { + const html = + '

See link

'; + expect(htmlToMarkdown(html)).toBe('See [link](https://example.com)'); + }); + + it('strips meta and title tags from Office paste', () => { + const html = + 'Doc' + + '

link

'; + expect(htmlToMarkdown(html)).toBe('[link](https://example.com)'); + }); + + it('strips SVG elements', () => { + const html = + 'iconlink'; + expect(htmlToMarkdown(html)).toBe('[link](https://example.com)'); + }); + + it('handles nested spans inside links', () => { + const html = 'styled link'; + expect(htmlToMarkdown(html)).toBe('[styled link](https://example.com)'); + }); + + it('handles div containers', () => { + const html = '
Hello world
'; + expect(htmlToMarkdown(html)).toBe('Hello [world](https://example.com)'); + }); + + it('collapses excessive newlines', () => { + const html = + '

A

B

'; + expect(htmlToMarkdown(html)).toBe('[A](https://a.com)\n\n[B](https://b.com)'); + }); +}); diff --git a/ui/desktop/src/utils/pasteMarkdown.ts b/ui/desktop/src/utils/pasteMarkdown.ts new file mode 100644 index 000000000000..0e4b831fd148 --- /dev/null +++ b/ui/desktop/src/utils/pasteMarkdown.ts @@ -0,0 +1,57 @@ +const NON_CONTENT_TAGS = new Set([ + 'STYLE', + 'SCRIPT', + 'NOSCRIPT', + 'HEAD', + 'META', + 'LINK', + 'TITLE', + 'TEMPLATE', + 'SVG', + 'MATH', + 'IFRAME', + 'OBJECT', + 'EMBED', + 'APPLET', + 'COMMENT', +]); + +function convertNodeToMarkdown(node: Node): string { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent || ''; + } + if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as HTMLElement; + const tag = el.tagName.toUpperCase(); + if (NON_CONTENT_TAGS.has(tag)) { + return ''; + } + if (tag === 'A' && el.getAttribute('href')) { + const href = el.getAttribute('href')!; + const text = el.textContent || href; + return `[${text}](${href})`; + } + if (tag === 'BR') { + return '\n'; + } + if (tag === 'P' || tag === 'DIV') { + const inner = Array.from(el.childNodes).map(convertNodeToMarkdown).join(''); + return inner + '\n\n'; + } + return Array.from(el.childNodes).map(convertNodeToMarkdown).join(''); + } + return ''; +} + +/** + * Converts pasted HTML containing hyperlinks into markdown text. + * Returns null if the HTML has no links (caller should let the browser handle the paste). + */ +export function htmlToMarkdown(html: string): string | null { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + if (doc.querySelectorAll('a[href]').length === 0) { + return null; + } + return convertNodeToMarkdown(doc.body).replace(/\n{3,}/g, '\n\n').trim(); +} From f0dd93ba48d58e8add7a0595b0ee2a1a22e76f91 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 13 May 2026 16:50:54 -0400 Subject: [PATCH 4/8] refactor: use turndown for HTML-to-markdown paste conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hand-rolled HTML-to-markdown converter with turndown library. This properly handles lists, headings, bold, italic, line breaks, and all other block-level elements instead of just links and paragraphs. Remove the unit test file — turndown is well-tested upstream. Signed-off-by: Douwe Osinga --- ui/desktop/package.json | 2 + .../src/utils/__tests__/pasteMarkdown.test.ts | 83 ------------------- ui/desktop/src/utils/pasteMarkdown.ts | 55 ++---------- ui/pnpm-lock.yaml | 56 +++++++------ 4 files changed, 41 insertions(+), 155 deletions(-) delete mode 100644 ui/desktop/src/utils/__tests__/pasteMarkdown.test.ts diff --git a/ui/desktop/package.json b/ui/desktop/package.json index 25b83a1cc84f..d2de6f702471 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -96,6 +96,7 @@ "swr": "^2.4.0", "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7", + "turndown": "^7.2.4", "tw-animate-css": "^1.4.0", "unist-util-visit": "^5.1.0", "uuid": "^13.0.0", @@ -132,6 +133,7 @@ "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", "@types/shell-quote": "^1.7.5", + "@types/turndown": "^5.0.6", "@types/yauzl": "^2.10.3", "@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/parser": "^8.56.1", diff --git a/ui/desktop/src/utils/__tests__/pasteMarkdown.test.ts b/ui/desktop/src/utils/__tests__/pasteMarkdown.test.ts deleted file mode 100644 index 82216de70cac..000000000000 --- a/ui/desktop/src/utils/__tests__/pasteMarkdown.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { htmlToMarkdown } from '../pasteMarkdown'; - -describe('htmlToMarkdown', () => { - it('returns null when there are no links', () => { - expect(htmlToMarkdown('

plain text

')).toBeNull(); - }); - - it('converts a simple link', () => { - expect(htmlToMarkdown('Example')).toBe( - '[Example](https://example.com)' - ); - }); - - it('converts text with an inline link', () => { - expect(htmlToMarkdown('

Visit Example today

')).toBe( - 'Visit [Example](https://example.com) today' - ); - }); - - it('converts multiple links', () => { - const html = '

A and B

'; - expect(htmlToMarkdown(html)).toBe('[A](https://a.com) and [B](https://b.com)'); - }); - - it('preserves paragraph breaks', () => { - const html = '

A

B

'; - expect(htmlToMarkdown(html)).toBe('[A](https://a.com)\n\n[B](https://b.com)'); - }); - - it('converts BR tags to newlines', () => { - const html = 'A
B'; - expect(htmlToMarkdown(html)).toBe('[A](https://a.com)\n[B](https://b.com)'); - }); - - it('uses href as text when link text is empty', () => { - expect(htmlToMarkdown('')).toBe( - '[https://example.com](https://example.com)' - ); - }); - - it('strips style tags from Google Docs paste', () => { - const html = - '' + - '

Check this

'; - expect(htmlToMarkdown(html)).toBe('Check [this](https://example.com)'); - }); - - it('strips script tags', () => { - const html = - '

See link

'; - expect(htmlToMarkdown(html)).toBe('See [link](https://example.com)'); - }); - - it('strips meta and title tags from Office paste', () => { - const html = - 'Doc' + - '

link

'; - expect(htmlToMarkdown(html)).toBe('[link](https://example.com)'); - }); - - it('strips SVG elements', () => { - const html = - 'iconlink'; - expect(htmlToMarkdown(html)).toBe('[link](https://example.com)'); - }); - - it('handles nested spans inside links', () => { - const html = 'styled link'; - expect(htmlToMarkdown(html)).toBe('[styled link](https://example.com)'); - }); - - it('handles div containers', () => { - const html = '
Hello world
'; - expect(htmlToMarkdown(html)).toBe('Hello [world](https://example.com)'); - }); - - it('collapses excessive newlines', () => { - const html = - '

A

B

'; - expect(htmlToMarkdown(html)).toBe('[A](https://a.com)\n\n[B](https://b.com)'); - }); -}); diff --git a/ui/desktop/src/utils/pasteMarkdown.ts b/ui/desktop/src/utils/pasteMarkdown.ts index 0e4b831fd148..9f001d12553c 100644 --- a/ui/desktop/src/utils/pasteMarkdown.ts +++ b/ui/desktop/src/utils/pasteMarkdown.ts @@ -1,57 +1,16 @@ -const NON_CONTENT_TAGS = new Set([ - 'STYLE', - 'SCRIPT', - 'NOSCRIPT', - 'HEAD', - 'META', - 'LINK', - 'TITLE', - 'TEMPLATE', - 'SVG', - 'MATH', - 'IFRAME', - 'OBJECT', - 'EMBED', - 'APPLET', - 'COMMENT', -]); +import TurndownService from 'turndown'; -function convertNodeToMarkdown(node: Node): string { - if (node.nodeType === Node.TEXT_NODE) { - return node.textContent || ''; - } - if (node.nodeType === Node.ELEMENT_NODE) { - const el = node as HTMLElement; - const tag = el.tagName.toUpperCase(); - if (NON_CONTENT_TAGS.has(tag)) { - return ''; - } - if (tag === 'A' && el.getAttribute('href')) { - const href = el.getAttribute('href')!; - const text = el.textContent || href; - return `[${text}](${href})`; - } - if (tag === 'BR') { - return '\n'; - } - if (tag === 'P' || tag === 'DIV') { - const inner = Array.from(el.childNodes).map(convertNodeToMarkdown).join(''); - return inner + '\n\n'; - } - return Array.from(el.childNodes).map(convertNodeToMarkdown).join(''); - } - return ''; -} +const turndown = new TurndownService({ + headingStyle: 'atx', + bulletListMarker: '-', + codeBlockStyle: 'fenced', +}); -/** - * Converts pasted HTML containing hyperlinks into markdown text. - * Returns null if the HTML has no links (caller should let the browser handle the paste). - */ export function htmlToMarkdown(html: string): string | null { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); if (doc.querySelectorAll('a[href]').length === 0) { return null; } - return convertNodeToMarkdown(doc.body).replace(/\n{3,}/g, '\n\n').trim(); + return turndown.turndown(doc.body).trim(); } diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 609bbab6b48c..ca34af655979 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -171,6 +171,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@4.2.2) + turndown: + specifier: ^7.2.4 + version: 7.2.4 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -274,6 +277,9 @@ importers: '@types/shell-quote': specifier: ^1.7.5 version: 1.7.5 + '@types/turndown': + specifier: ^5.0.6 + version: 5.0.6 '@types/yauzl': specifier: ^2.10.3 version: 2.10.3 @@ -868,6 +874,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} @@ -1927,6 +1937,9 @@ packages: '@mermaid-js/parser@1.1.0': resolution: {integrity: sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==} + '@mixmark-io/domino@2.2.0': + resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + '@modelcontextprotocol/ext-apps@0.3.1': resolution: {integrity: sha512-Iivz2KwWK8xlRbiWwFB/C4NXqE8VJBoRCbBkJCN98ST2UbQvA6kfyebcLsypiqylJS467XOOaBcI9DeQ3t+zqA==} peerDependencies: @@ -3832,6 +3845,9 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/turndown@5.0.6': + resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -8247,6 +8263,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + turndown@7.2.4: + resolution: {integrity: sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==} + engines: {node: '>=18', npm: '>=9'} + tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -8978,6 +8998,8 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/runtime@7.28.6': {} + '@babel/runtime@7.29.2': {} '@babel/template@7.28.6': @@ -10306,7 +10328,7 @@ snapshots: '@mcp-ui/client@7.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@modelcontextprotocol/ext-apps': 1.2.2(@modelcontextprotocol/sdk@1.27.1(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@3.25.76) - '@modelcontextprotocol/sdk': 1.27.1(zod@4.3.6) + '@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) zod: 3.25.76 @@ -10318,6 +10340,8 @@ snapshots: dependencies: langium: 4.2.2 + '@mixmark-io/domino@2.2.0': {} + '@modelcontextprotocol/ext-apps@0.3.1(@modelcontextprotocol/sdk@1.27.1(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@3.25.76)': dependencies: '@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76) @@ -10373,28 +10397,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)': - dependencies: - '@hono/node-server': 1.19.11(hono@4.12.8) - ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) - content-type: 1.0.5 - cors: 2.8.6 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.0.6 - express: 5.2.1 - express-rate-limit: 8.3.1(express@5.2.1) - hono: 4.12.8 - jose: 6.2.2 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 - zod: 4.3.6 - zod-to-json-schema: 3.25.1(zod@3.25.76) - transitivePeerDependencies: - - supports-color - '@napi-rs/wasm-runtime@1.1.1': dependencies: '@emnapi/core': 1.9.1 @@ -11806,7 +11808,7 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.28.6 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -12194,6 +12196,8 @@ snapshots: '@types/trusted-types@2.0.7': optional: true + '@types/turndown@5.0.6': {} + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -17531,6 +17535,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + turndown@7.2.4: + dependencies: + '@mixmark-io/domino': 2.2.0 + tw-animate-css@1.4.0: {} type-check@0.4.0: From c855973744caec473c20610218d4877fe1bce9cc Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 13 May 2026 16:54:40 -0400 Subject: [PATCH 5/8] refactor: inline turndown usage, remove pasteMarkdown utility file Signed-off-by: Douwe Osinga --- ui/desktop/src/components/ChatInput.tsx | 14 +++++++++++--- ui/desktop/src/utils/pasteMarkdown.ts | 16 ---------------- 2 files changed, 11 insertions(+), 19 deletions(-) delete mode 100644 ui/desktop/src/utils/pasteMarkdown.ts diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 204facfda1f2..4656cbb37d39 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -44,7 +44,13 @@ import { UserInput, ImageData } from '../types/message'; import { compressImageDataUrl } from '../utils/conversionUtils'; import { fetchCanonicalModelInfo } from '../utils/canonical'; import { defineMessages, useIntl } from '../i18n'; -import { htmlToMarkdown } from '../utils/pasteMarkdown'; +import TurndownService from 'turndown'; + +const turndown = new TurndownService({ + headingStyle: 'atx', + bulletListMarker: '-', + codeBlockStyle: 'fenced', +}); interface PastedImage { id: string; @@ -818,8 +824,10 @@ export default function ChatInput({ if (imageFiles.length === 0) { const html = evt.clipboardData.getData('text/html'); if (html) { - const markdown = htmlToMarkdown(html); - if (markdown !== null) { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const hasLinks = doc.querySelectorAll('a[href]').length > 0; + if (hasLinks) { + const markdown = turndown.turndown(doc.body).trim(); evt.preventDefault(); const textarea = textAreaRef.current; if (textarea) { diff --git a/ui/desktop/src/utils/pasteMarkdown.ts b/ui/desktop/src/utils/pasteMarkdown.ts deleted file mode 100644 index 9f001d12553c..000000000000 --- a/ui/desktop/src/utils/pasteMarkdown.ts +++ /dev/null @@ -1,16 +0,0 @@ -import TurndownService from 'turndown'; - -const turndown = new TurndownService({ - headingStyle: 'atx', - bulletListMarker: '-', - codeBlockStyle: 'fenced', -}); - -export function htmlToMarkdown(html: string): string | null { - const parser = new DOMParser(); - const doc = parser.parseFromString(html, 'text/html'); - if (doc.querySelectorAll('a[href]').length === 0) { - return null; - } - return turndown.turndown(doc.body).trim(); -} From bc1b7ed7d8a863b4efa77c83047f9668513e90f0 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 13 May 2026 17:18:02 -0400 Subject: [PATCH 6/8] fix(ui): flatten complex link content to produce valid markdown links Links wrapping block-level content (divs, lists, headings) produce multi-line text that breaks markdown link syntax. Add a turndown rule that detects these cases and collapses the content to a single line. Signed-off-by: Douwe Osinga --- ui/desktop/src/components/ChatInput.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 4656cbb37d39..2ce83c617db9 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -52,6 +52,22 @@ const turndown = new TurndownService({ codeBlockStyle: 'fenced', }); +turndown.addRule('complexLinks', { + filter: (node) => { + return ( + node.nodeName === 'A' && + !!node.getAttribute('href') && + /\n/.test(node.textContent || '') + ); + }, + replacement: (_content, node) => { + const el = node as HTMLElement; + const href = el.getAttribute('href')!; + const text = (el.textContent || '').trim().replace(/\n+/g, ' '); + return `[${text}](${href})`; + }, +}); + interface PastedImage { id: string; dataUrl: string; From 9f26c7349f5731da18b260475e88e4b5858fe0ea Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 13 May 2026 17:25:05 -0400 Subject: [PATCH 7/8] fix(ui): address review feedback for paste handler - Skip paste entirely when recording (readOnly mode) - Check for empty markdown after turndown conversion to avoid suppressing normal paste when links are inside stripped elements - Call checkForMentionOrSlash after paste to dismiss stale popover Signed-off-by: Douwe Osinga --- ui/desktop/src/components/ChatInput.tsx | 32 +++++++++++++++---------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 2ce83c617db9..459db3c29cca 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -834,6 +834,8 @@ export default function ChatInput({ }, [droppedFiles.length, localDroppedFiles.length, onFilesProcessed, setLocalDroppedFiles]); const handlePaste = async (evt: React.ClipboardEvent) => { + if (isRecording) return; + const files = Array.from(evt.clipboardData.files || []); const imageFiles = files.filter((file) => file.type.startsWith('image/')); @@ -844,19 +846,23 @@ export default function ChatInput({ const hasLinks = doc.querySelectorAll('a[href]').length > 0; if (hasLinks) { const markdown = turndown.turndown(doc.body).trim(); - evt.preventDefault(); - const textarea = textAreaRef.current; - if (textarea) { - const start = textarea.selectionStart; - const end = textarea.selectionEnd; - const newValue = - displayValue.substring(0, start) + markdown + displayValue.substring(end); - setDisplayValue(newValue); - updateValue(newValue); - setHasUserTyped(true); - requestAnimationFrame(() => { - textarea.selectionStart = textarea.selectionEnd = start + markdown.length; - }); + if (markdown) { + evt.preventDefault(); + const textarea = textAreaRef.current; + if (textarea) { + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const newValue = + displayValue.substring(0, start) + markdown + displayValue.substring(end); + const cursorPos = start + markdown.length; + setDisplayValue(newValue); + updateValue(newValue); + setHasUserTyped(true); + checkForMentionOrSlash(newValue, cursorPos, textarea); + requestAnimationFrame(() => { + textarea.selectionStart = textarea.selectionEnd = cursorPos; + }); + } } } } From c6e3aa31ea058a9c9beb49e452ac6cd4d5f8b4c6 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Thu, 14 May 2026 10:02:27 -0400 Subject: [PATCH 8/8] fix(ui): use turndown-converted content in complexLinks rule Use the already-escaped content parameter instead of raw textContent, so markdown metacharacters in link labels are properly escaped. Signed-off-by: Douwe Osinga --- ui/desktop/src/components/ChatInput.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 459db3c29cca..3b0e62712602 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -60,11 +60,11 @@ turndown.addRule('complexLinks', { /\n/.test(node.textContent || '') ); }, - replacement: (_content, node) => { + replacement: (content, node) => { const el = node as HTMLElement; const href = el.getAttribute('href')!; - const text = (el.textContent || '').trim().replace(/\n+/g, ' '); - return `[${text}](${href})`; + const label = content.replace(/\n+/g, ' ').trim(); + return `[${label}](${href})`; }, });