diff --git a/workspace/frontend/components/issues/issue-mention-input.test.ts b/workspace/frontend/components/issues/issue-mention-input.test.ts
new file mode 100644
index 000000000..d24608ae1
--- /dev/null
+++ b/workspace/frontend/components/issues/issue-mention-input.test.ts
@@ -0,0 +1,36 @@
+// @vitest-environment jsdom
+import { act, createElement, useState } from 'react';
+import { createRoot } from 'react-dom/client';
+import { expect, it, vi } from 'vitest';
+import { IssueMentionInput } from './issue-mention-input';
+vi.mock('@/lib/i18n', () => ({ useT: () => (key: string) => key }));
+vi.mock('./issue-ui', () => ({ IssueAvatar: () => null }));
+it('replaces selected text with Markdown and preserves existing mention identities', async () => {
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+ const host = document.createElement('div');
+ document.body.append(host);
+ const root = createRoot(host);
+ let saved = '';
+ function Editor() {
+ const [value, setValue] = useState('@alice@example.com replace');
+ saved = value;
+ return createElement(IssueMentionInput, { value, onValueChange: setValue,
+ mentions: [{ name: 'Alice', token: 'alice@example.com', source: 'human:alice@example.com', kind: 'human' }],
+ });
+ }
+ try {
+ await act(async () => root.render(createElement(Editor)));
+ const textarea = host.querySelector('textarea')!;
+ expect(textarea.value).toBe('@Alice replace');
+ textarea.setSelectionRange(7, 14);
+ const event = new Event('paste', { bubbles: true, cancelable: true });
+ Object.defineProperty(event, 'clipboardData', { value: { getData: (type: string) => type === 'text/html' ? 'formatted' : '' } });
+ await act(async () => { textarea.dispatchEvent(event); });
+ expect(event.defaultPrevented).toBe(true);
+ expect(textarea.value).toBe('@Alice **formatted**');
+ expect(saved).toBe('@alice@example.com **formatted**');
+ } finally {
+ await act(async () => root.unmount());
+ host.remove();
+ }
+});
diff --git a/workspace/frontend/components/issues/issue-mention-input.tsx b/workspace/frontend/components/issues/issue-mention-input.tsx
index 7a3eb2d95..3c6e78b81 100644
--- a/workspace/frontend/components/issues/issue-mention-input.tsx
+++ b/workspace/frontend/components/issues/issue-mention-input.tsx
@@ -1,5 +1,6 @@
'use client';
import { useEffect, useId, useRef, useState, type ComponentProps } from 'react';
+import { clipboardMarkdown } from '@/lib/clipboard-markdown';
import { Textarea } from '@/components/ui/textarea';
import { useT } from '@/lib/i18n';
import { cn } from '@/lib/utils';
@@ -26,6 +27,7 @@ export function IssueMentionInput({
onKeyDown,
onSelect,
onBlur,
+ onPaste,
...props
}: Props) {
const t = useT();
@@ -86,6 +88,21 @@ export function IssueMentionInput({
aria-activedescendant={
open && items.length ? `${listId}-${active}` : undefined
}
+ onPaste={(e) => {
+ onPaste?.(e);
+ if (e.defaultPrevented || props.disabled || props.readOnly) return;
+ const markdown = clipboardMarkdown(e.clipboardData);
+ if (markdown === null) return;
+ const { selectionStart: start, selectionEnd: end } = e.currentTarget;
+ const next = replaceMentionDraft(draft, start, end, markdown);
+ e.preventDefault();
+ if (props.maxLength !== undefined && next.length > props.maxLength) return;
+ onValueChange(next);
+ setRange(null);
+ requestAnimationFrame(() => {
+ input.current?.setSelectionRange(start + markdown.length, start + markdown.length);
+ });
+ }}
onChange={(e) => {
onValueChange(editMentionDraft(draft, e.target.value));
detect(e.target);
diff --git a/workspace/frontend/lib/clipboard-markdown.test.ts b/workspace/frontend/lib/clipboard-markdown.test.ts
new file mode 100644
index 000000000..8419aa162
--- /dev/null
+++ b/workspace/frontend/lib/clipboard-markdown.test.ts
@@ -0,0 +1,24 @@
+// @vitest-environment jsdom
+import { expect, it } from 'vitest';
+import { clipboardMarkdown } from './clipboard-markdown';
+const clipboard = (html: string, markdown = '') => ({ getData: (type: string) => type === 'text/html' ? html : type === 'text/markdown' ? markdown : '' });
+it('preserves code blocks, bold, inline code and links from formatted text', () => {
+ const result = clipboardMarkdown(clipboard('
Login checker runs:
security find-generic-password -s "Claude Code-credentials" -w\n
The -w retrieves the stored secret itself. MITRE documents this technique.
'));
+ expect(result).toContain('```arduino\nsecurity find-generic-password');
+ expect(result).toContain('**`-w` retrieves the stored secret itself**');
+ expect(result).toContain('[MITRE documents this technique](https://attack.mitre.org/techniques/T1555/001/)');
+});
+it('leaves plain-text Markdown to the native paste handler', () => {
+ expect(clipboardMarkdown(clipboard(''))).toBeNull();
+ expect(clipboardMarkdown(clipboard('**bold** and `code`
'))).toBeNull();
+});
+it('prefers explicit Markdown over HTML', () => {
+ expect(clipboardMarkdown(clipboard('bold', '**original**'))).toBe('**original**');
+});
+it('preserves lists and headings while dropping active content and copy buttons', () => {
+ const result = clipboardMarkdown(clipboard('Steps
'));
+ expect(result).toContain('## Steps');
+ expect(result).toMatch(/-\s+One/);
+ expect(result).toContain('_Two_');
+ expect(result).not.toMatch(/alert|Copy code/);
+});
diff --git a/workspace/frontend/lib/clipboard-markdown.ts b/workspace/frontend/lib/clipboard-markdown.ts
new file mode 100644
index 000000000..7fccbc667
--- /dev/null
+++ b/workspace/frontend/lib/clipboard-markdown.ts
@@ -0,0 +1,26 @@
+import TurndownService from 'turndown';
+
+/** Return Markdown for rich clipboard content, or null for native plain-text paste. */
+export function clipboardMarkdown(clipboard: Pick): string | null {
+ const markdown = clipboard.getData('text/markdown');
+ if (markdown) return markdown;
+ const html = clipboard.getData('text/html');
+ if (!html) return null;
+ try {
+ // Parse in an inert document; never mount clipboard HTML in the page.
+ const document = new DOMParser().parseFromString(html, 'text/html');
+ document.querySelectorAll('script, style, iframe, object, button, input, select, textarea').forEach(node => node.remove());
+ // Plain text copied from editors is often wrapped in a div or span. Keep it
+ // verbatim rather than escaping Markdown syntax that the user already wrote.
+ if (!document.body.querySelector('strong, b, em, i, s, del, a, pre, code, h1, h2, h3, h4, h5, h6, ul, ol, blockquote, hr, img')) return null;
+ const converter = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced', bulletListMarker: '-' });
+ converter.addRule('strikethrough', {
+ filter: ['s', 'del'],
+ replacement: content => `~~${content}~~`,
+ });
+ const result = converter.turndown(document.body);
+ return result.trim() ? result : null;
+ } catch {
+ return null; // Clipboard formats vary; ordinary paste must still work.
+ }
+}
diff --git a/workspace/frontend/package-lock.json b/workspace/frontend/package-lock.json
index 7b7ca0fc6..4e1c40dcc 100644
--- a/workspace/frontend/package-lock.json
+++ b/workspace/frontend/package-lock.json
@@ -30,6 +30,7 @@
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
+ "turndown": "^7.2.4",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2"
},
@@ -41,6 +42,7 @@
"@types/node": "^24.3.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
+ "@types/turndown": "^5.0.6",
"@vitejs/plugin-react": "^5.2.0",
"jsdom": "^29.1.1",
"postcss": "^8.5.6",
@@ -2481,6 +2483,12 @@
"@chevrotain/types": "~11.1.1"
}
},
+ "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/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -5843,6 +5851,13 @@
"license": "MIT",
"optional": true
},
+ "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",
@@ -10225,6 +10240,19 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
+ "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"
+ }
+ },
"node_modules/tw-animate-css": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
diff --git a/workspace/frontend/package.json b/workspace/frontend/package.json
index 51c7cc4d4..c36a18d72 100644
--- a/workspace/frontend/package.json
+++ b/workspace/frontend/package.json
@@ -34,6 +34,7 @@
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
+ "turndown": "^7.2.4",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2"
},
@@ -45,6 +46,7 @@
"@types/node": "^24.3.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
+ "@types/turndown": "^5.0.6",
"@vitejs/plugin-react": "^5.2.0",
"jsdom": "^29.1.1",
"postcss": "^8.5.6",