From 634939e9a01ac47ba6f4a5322befbc0e039b1fbe Mon Sep 17 00:00:00 2001 From: Honey Paptan Date: Thu, 5 Nov 2026 01:14:10 +0530 Subject: [PATCH 1/8] fix(web): keep pasted arithmetic stars verbatim in the rich-text composer The composer's paste handler already parses pasted markdown and tags the insert as a paste for undo grouping, which also triggered Tiptap's paste rules on the parsed result. Literal * and ** left over from the parse were turned into italic and bold marks, so pasted code like (7 * 2**3 * 5)**4 reached the agent changed. Paste rules are now off for the composer. Fixes #31079 --- ...rPromptEditorTiptap.input.browser.test.tsx | 19 +++++++++++++++++++ .../components/ComposerPromptEditorTiptap.tsx | 3 +++ apps/web/src/composer-rich-text-doc.ts | 6 +----- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index c59724cccf693..10323abaeb9a3 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -220,6 +220,25 @@ it("keeps unknown dollar names editable when restoring and pasting a draft", asy expect(serializeEditorDoc(editor.state.doc).value).toBe(value); }); +it("pastes arithmetic stars verbatim and still styles pasted markdown", async () => { + await using screen = await mountEditor("", true, []); + const { editor } = screen; + const paste = async (text: string) => { + await act(async () => { + editor.commands.selectAll(); + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", text); + editor.view.dom.dispatchEvent(new ClipboardEvent("paste", { clipboardData, cancelable: true })); + }); + }; + const code = "signal = (7 * 2**3 * 5)**4"; + await paste(code); + expect(serializeEditorDoc(editor.state.doc).value).toBe(code); + await paste("a **bold** and *soft* word"); + expect(serializeEditorDoc(editor.state.doc).value).toBe("a **bold** and *soft* word"); + expect(editor.state.doc.textContent).toBe("a bold and soft word"); +}); + it("keeps the caret inside unknown dollar text across rich and plain remounts", async () => { const value = "echo $HOME now"; await using screen = await mountEditor(value); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 3df1e47613370..6a10a84c92ca9 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1111,6 +1111,9 @@ function ComposerPromptEditorTiptapInner( literalText, }), editable: !disabled, + // handlePaste parses pasted markdown itself and tags the insert as a paste for undo grouping, so Tiptap's + // paste rules would parse that result a second time and turn literal `*` and `**` into marks. + enablePasteRules: false, editorProps: { attributes: editorAttributes, handleKeyDown: (view, event) => { diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index ae0c9441625b4..fa31d339fc91b 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -16,14 +16,13 @@ import { Blockquote } from "@tiptap/extension-blockquote"; import { CodeBlock } from "@tiptap/extension-code-block"; import { Heading } from "@tiptap/extension-heading"; import { HorizontalRule } from "@tiptap/extension-horizontal-rule"; -import { Italic, starInputRegex, starPasteRegex } from "@tiptap/extension-italic"; +import { Italic, starInputRegex } from "@tiptap/extension-italic"; import { type Command, type Editor, Extension, InputRule, markInputRule, - markPasteRule, mergeAttributes, } from "@tiptap/core"; import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list"; @@ -278,9 +277,6 @@ export const ComposerItalicExtension = Italic.extend({ addInputRules() { return [markInputRuleOutsideCode(starInputRegex, this.type)]; }, - addPasteRules() { - return [markPasteRule({ find: starPasteRegex, type: this.type })]; - }, }); export const ComposerBoldExtension = Bold.extend({ From 9ac428eeda1375b9880a9099c0ec61fae5b55d21 Mon Sep 17 00:00:00 2001 From: Honey Paptan Date: Thu, 5 Nov 2026 01:50:05 +0530 Subject: [PATCH 2/8] style(web): format composer paste test --- .../ComposerPromptEditorTiptap.input.browser.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 10323abaeb9a3..6a1dfedec3b1a 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -228,7 +228,9 @@ it("pastes arithmetic stars verbatim and still styles pasted markdown", async () editor.commands.selectAll(); const clipboardData = new DataTransfer(); clipboardData.setData("text/plain", text); - editor.view.dom.dispatchEvent(new ClipboardEvent("paste", { clipboardData, cancelable: true })); + editor.view.dom.dispatchEvent( + new ClipboardEvent("paste", { clipboardData, cancelable: true }), + ); }); }; const code = "signal = (7 * 2**3 * 5)**4"; From 93f009e6e09b211777657225e09682e0e2856b7d Mon Sep 17 00:00:00 2001 From: Honey Paptan Date: Thu, 5 Nov 2026 12:07:49 +0530 Subject: [PATCH 3/8] fix(web): parse markdown in text dropped into the composer --- ...rPromptEditorTiptap.input.browser.test.tsx | 28 ++++ .../components/ComposerPromptEditorTiptap.tsx | 158 ++++++++++-------- 2 files changed, 114 insertions(+), 72 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 6a1dfedec3b1a..59de853bc8248 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -241,6 +241,34 @@ it("pastes arithmetic stars verbatim and still styles pasted markdown", async () expect(editor.state.doc.textContent).toBe("a bold and soft word"); }); +it("parses markdown dropped from outside and keeps dropped arithmetic verbatim", async () => { + await using screen = await mountEditor("", true, []); + const { editor } = screen; + const drop = async (text: string) => { + await act(async () => { + editor.commands.clearContent(); + const dataTransfer = new DataTransfer(); + dataTransfer.setData("text/plain", text); + const { left, top } = editor.view.coordsAtPos(1); + editor.view.dom.dispatchEvent( + new DragEvent("drop", { + dataTransfer, + clientX: left, + clientY: top + 1, + bubbles: true, + cancelable: true, + }), + ); + }); + }; + await drop("a **bold** and *soft* word"); + expect(serializeEditorDoc(editor.state.doc).value).toBe("a **bold** and *soft* word"); + expect(editor.state.doc.textContent).toBe("a bold and soft word"); + const code = "signal = (7 * 2**3 * 5)**4"; + await drop(code); + expect(serializeEditorDoc(editor.state.doc).value).toBe(code); +}); + it("keeps the caret inside unknown dollar text across rich and plain remounts", async () => { const value = "echo $HOME now"; await using screen = await mountEditor(value); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 6a10a84c92ca9..0a3b447fc6e65 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1103,6 +1103,81 @@ function ComposerPromptEditorTiptapInner( "aria-placeholder": placeholder, }; + const insertClipboardText = (view: EditorView, clipboardData: DataTransfer): boolean => { + const pastedText = clipboardData.getData("text/plain"); + if (!pastedText) return false; + // Paste verbatim inside fences; markdown parsing would split blocks and create unsupported chips. + if (isInCodeBlock(view)) { + const { from, to } = view.state.selection; + view.dispatch( + markAsClipboardEdit( + view.state.tr.insertText(pastedText, from, to), + "paste", + ).scrollIntoView(), + ); + return true; + } + const importFragment = readCallbacks().importContextFragment; + let text = + !literalText && importFragment + ? importPastedComposerText(clipboardData, importFragment) + : pastedText; + const tokens = literalText ? [] : collectComposerPromptInlineTokens(`${text}\n`); + const isChip = (token: (typeof tokens)[number] | undefined) => + token?.type === "mention" || (token?.type === "skill" && skillLabelFor(token.value) !== null); + const lastToken = tokens.at(-1); + if (isChip(lastToken) && lastToken?.end === text.length) { + text += " "; + } + if (isChip(tokens[0]) && tokens[0]?.start === 0) { + const map = serializeEditorDoc(view.state.doc); + const offset = flatToMarkdown(map, pmToFlat(map, view.state.selection.from)); + if (offset > 0 && !/\s/.test(map.value[offset - 1]!)) text = ` ${text}`; + } + const { $from } = view.state.selection; + const lineBefore = $from.parent.textBetween(0, $from.parentOffset, undefined, "\uFFFC"); + const literalLength = literalText + ? 0 + : pastedPathQueryLength(lineBefore, text, readCallbacks().isPathQueryActive?.()); + const editorInstance = (view.dom as TiptapEditorHTMLElement).editor; + if (editorInstance) { + const $paste = view.state.selection.$from; + const nested = ["listItem", "taskItem", "blockquote"].some((name) => + hasAncestor($paste, name), + ); + insertMarkdownParagraphs( + text, + skillLabelFor, + { styling: richText, blocks: !nested, literalText, literalLength }, + (content) => { + editorInstance + .chain() + .command(({ tr }) => { + markAsClipboardEdit(tr, "paste"); + return true; + }) + .insertContent(content) + .command(({ tr }) => { + // Move after a pasted rule so the next keystroke cannot replace it; add a trailing line + // if needed. + const { selection } = tr; + if (!(selection instanceof NodeSelection) || !selection.node.isBlock) { + return true; + } + if (!tr.doc.resolve(selection.to).nodeAfter?.isTextblock) { + tr.insert(selection.to, tr.doc.type.schema.nodes.paragraph!.create()); + } + tr.setSelection(TextSelection.create(tr.doc, selection.to + 1)); + return true; + }) + .run(); + }, + ); + scrollTiptapCaretIntoView(editorInstance); + } + return true; + }; + const editor = useEditor( { extensions: richText ? RICH_TEXT_EXTENSIONS : PLAIN_TEXT_EXTENSIONS, @@ -1357,81 +1432,20 @@ function ComposerPromptEditorTiptapInner( handlePaste: (view, event) => { const clipboardData = event.clipboardData; if (!clipboardData || clipboardData.files.length > 0) return false; - const pastedText = clipboardData.getData("text/plain"); - if (!pastedText) return false; + if (!insertClipboardText(view, clipboardData)) return false; event.preventDefault(); - // Paste verbatim inside fences; markdown parsing would split blocks and create unsupported chips. - if (isInCodeBlock(view)) { - const { from, to } = view.state.selection; - view.dispatch( - markAsClipboardEdit( - view.state.tr.insertText(pastedText, from, to), - "paste", - ).scrollIntoView(), - ); - return true; - } - const importFragment = readCallbacks().importContextFragment; - let text = - !literalText && importFragment - ? importPastedComposerText(clipboardData, importFragment) - : pastedText; - const tokens = literalText ? [] : collectComposerPromptInlineTokens(`${text}\n`); - const isChip = (token: (typeof tokens)[number] | undefined) => - token?.type === "mention" || - (token?.type === "skill" && skillLabelFor(token.value) !== null); - const lastToken = tokens.at(-1); - if (isChip(lastToken) && lastToken?.end === text.length) { - text += " "; - } - if (isChip(tokens[0]) && tokens[0]?.start === 0) { - const map = serializeEditorDoc(view.state.doc); - const offset = flatToMarkdown(map, pmToFlat(map, view.state.selection.from)); - if (offset > 0 && !/\s/.test(map.value[offset - 1]!)) text = ` ${text}`; - } - const { $from } = view.state.selection; - const lineBefore = $from.parent.textBetween(0, $from.parentOffset, undefined, "\uFFFC"); - const literalLength = literalText - ? 0 - : pastedPathQueryLength(lineBefore, text, readCallbacks().isPathQueryActive?.()); - const editorInstance = (view.dom as TiptapEditorHTMLElement).editor; - if (editorInstance) { - const $paste = view.state.selection.$from; - const nested = ["listItem", "taskItem", "blockquote"].some((name) => - hasAncestor($paste, name), - ); - insertMarkdownParagraphs( - text, - skillLabelFor, - { styling: richText, blocks: !nested, literalText, literalLength }, - (content) => { - editorInstance - .chain() - .command(({ tr }) => { - markAsClipboardEdit(tr, "paste"); - return true; - }) - .insertContent(content) - .command(({ tr }) => { - // Move after a pasted rule so the next keystroke cannot replace it; add a trailing line - // if needed. - const { selection } = tr; - if (!(selection instanceof NodeSelection) || !selection.node.isBlock) { - return true; - } - if (!tr.doc.resolve(selection.to).nodeAfter?.isTextblock) { - tr.insert(selection.to, tr.doc.type.schema.nodes.paragraph!.create()); - } - tr.setSelection(TextSelection.create(tr.doc, selection.to + 1)); - return true; - }) - .run(); - }, - ); - scrollTiptapCaretIntoView(editorInstance); - } return true; }, + handleDrop: (view, event, _slice, moved) => { + const data = event.dataTransfer; + if (moved || !data || data.files.length > 0 || !data.getData("text/plain")) return false; + const target = view.posAtCoords({ left: event.clientX, top: event.clientY }); + if (!target) return false; + view.dispatch( + view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(target.pos))), + ); + return insertClipboardText(view, data); + }, }, onUpdate: ({ editor: updated, transaction }) => { // Undo can restore a chip for a skill the current provider no longer offers. From dd2b556e9baa246c6f72625d554e80056baa588b Mon Sep 17 00:00:00 2001 From: Honey Paptan Date: Thu, 5 Nov 2026 13:18:28 +0530 Subject: [PATCH 4/8] fix(web): leave composer drags that start inside the editor to ProseMirror --- ...rPromptEditorTiptap.input.browser.test.tsx | 25 +++++++++++++++++++ .../components/ComposerPromptEditorTiptap.tsx | 3 ++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 59de853bc8248..3f1de6c2624e7 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -269,6 +269,31 @@ it("parses markdown dropped from outside and keeps dropped arithmetic verbatim", expect(serializeEditorDoc(editor.state.doc).value).toBe(code); }); +it("leaves drops that started inside the editor to ProseMirror", async () => { + await using screen = await mountEditor("", true, []); + const { editor } = screen; + let handled: unknown; + await act(async () => { + const dataTransfer = new DataTransfer(); + dataTransfer.setData("text/plain", "a **bold** word"); + const { left, top } = editor.view.coordsAtPos(1); + const event = new DragEvent("drop", { + dataTransfer, + clientX: left, + clientY: top + 1, + bubbles: true, + cancelable: true, + }); + editor.view.dragging = { slice: editor.state.doc.slice(0, 0), move: false }; + handled = editor.view.someProp("handleDrop", (handleDrop) => + handleDrop(editor.view, event, editor.state.doc.slice(0, 0), false), + ); + editor.view.dragging = null; + }); + expect(handled).toBeFalsy(); + expect(serializeEditorDoc(editor.state.doc).value).toBe(""); +}); + it("keeps the caret inside unknown dollar text across rich and plain remounts", async () => { const value = "echo $HOME now"; await using screen = await mountEditor(value); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 0a3b447fc6e65..1d3404070bc15 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1438,7 +1438,8 @@ function ComposerPromptEditorTiptapInner( }, handleDrop: (view, event, _slice, moved) => { const data = event.dataTransfer; - if (moved || !data || data.files.length > 0 || !data.getData("text/plain")) return false; + if (moved || view.dragging || !data || data.files.length > 0) return false; + if (!data.getData("text/plain")) return false; const target = view.posAtCoords({ left: event.clientX, top: event.clientY }); if (!target) return false; view.dispatch( From b4e38a8dd2e5ae63ffbb07fc37c372b46b82b4cd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 03:27:53 +0000 Subject: [PATCH 5/8] test(web): drive the internal-drag case through a real drop event AI-assisted; verified with the tests and tools listed above. --- ...rPromptEditorTiptap.input.browser.test.tsx | 27 +++---------------- 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 48af6f64d1fed..8448f7d99a9b8 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -368,30 +368,9 @@ it("parses markdown dropped from outside and keeps dropped arithmetic verbatim", const code = "signal = (7 * 2**3 * 5)**4"; await drop(code); expect(serializeEditorDoc(editor.state.doc).value).toBe(code); -}); - -it("leaves drops that started inside the editor to ProseMirror", async () => { - await using screen = await mountEditor("", true, []); - const { editor } = screen; - let handled: unknown; - await act(async () => { - const dataTransfer = new DataTransfer(); - dataTransfer.setData("text/plain", "a **bold** word"); - const { left, top } = editor.view.coordsAtPos(1); - const event = new DragEvent("drop", { - dataTransfer, - clientX: left, - clientY: top + 1, - bubbles: true, - cancelable: true, - }); - editor.view.dragging = { slice: editor.state.doc.slice(0, 0), move: false }; - handled = editor.view.someProp("handleDrop", (handleDrop) => - handleDrop(editor.view, event, editor.state.doc.slice(0, 0), false), - ); - editor.view.dragging = null; - }); - expect(handled).toBeFalsy(); + // A drag that started inside the editor is ProseMirror's move, not an external text drop. + editor.view.dragging = { slice: editor.state.doc.slice(0, 0), move: false }; + await drop("a **bold** word"); expect(serializeEditorDoc(editor.state.doc).value).toBe(""); }); From fd3c3cb70b7f823a481cc1c987847dc8cc50b909 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 03:54:58 +0000 Subject: [PATCH 6/8] fix(web): leave text drops to ProseMirror so the composer takes focus The custom handleDrop returned before ProseMirror's own view.focus(), so typing after an external drop went to the previously focused control. Disabling paste rules already keeps dropped arithmetic verbatim. AI-assisted; verified with the tests and tools listed above. --- ...rPromptEditorTiptap.input.browser.test.tsx | 43 ++-- .../components/ComposerPromptEditorTiptap.tsx | 202 ++++++++---------- 2 files changed, 110 insertions(+), 135 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 8448f7d99a9b8..7a904566999c7 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -342,36 +342,27 @@ it("pastes arithmetic stars verbatim and still styles pasted markdown", async () expect(editor.state.doc.textContent).toBe("a bold and soft word"); }); -it("parses markdown dropped from outside and keeps dropped arithmetic verbatim", async () => { +it("keeps dropped arithmetic verbatim and focuses the editor", async () => { await using screen = await mountEditor("", true, []); const { editor } = screen; - const drop = async (text: string) => { - await act(async () => { - editor.commands.clearContent(); - const dataTransfer = new DataTransfer(); - dataTransfer.setData("text/plain", text); - const { left, top } = editor.view.coordsAtPos(1); - editor.view.dom.dispatchEvent( - new DragEvent("drop", { - dataTransfer, - clientX: left, - clientY: top + 1, - bubbles: true, - cancelable: true, - }), - ); - }); - }; - await drop("a **bold** and *soft* word"); - expect(serializeEditorDoc(editor.state.doc).value).toBe("a **bold** and *soft* word"); - expect(editor.state.doc.textContent).toBe("a bold and soft word"); const code = "signal = (7 * 2**3 * 5)**4"; - await drop(code); + await act(async () => { + editor.view.dom.blur(); + const dataTransfer = new DataTransfer(); + dataTransfer.setData("text/plain", code); + const { left, top } = editor.view.coordsAtPos(1); + editor.view.dom.dispatchEvent( + new DragEvent("drop", { + dataTransfer, + clientX: left, + clientY: top + 1, + bubbles: true, + cancelable: true, + }), + ); + }); expect(serializeEditorDoc(editor.state.doc).value).toBe(code); - // A drag that started inside the editor is ProseMirror's move, not an external text drop. - editor.view.dragging = { slice: editor.state.doc.slice(0, 0), move: false }; - await drop("a **bold** word"); - expect(serializeEditorDoc(editor.state.doc).value).toBe(""); + expect(editor.view.hasFocus()).toBe(true); }); it("keeps the caret inside unknown dollar text across rich and plain remounts", async () => { diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index da3eb75a0786d..aa99d27c6f2a1 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1098,101 +1098,6 @@ function ComposerPromptEditorTiptapInner( "aria-placeholder": placeholder, }; - const insertClipboardText = (view: EditorView, clipboardData: DataTransfer): boolean => { - const pastedText = clipboardData.getData("text/plain"); - if (!pastedText) return false; - // Paste verbatim inside fences; markdown parsing would split blocks and create unsupported chips. - if (isInCodeBlock(view)) { - const { from, to } = view.state.selection; - view.dispatch( - markAsClipboardEdit( - view.state.tr.insertText(pastedText, from, to), - "paste", - ).scrollIntoView(), - ); - return true; - } - // Shown Markdown source is parsed when the caret leaves, so paste one line into it as text. - const sourceType = view.state.schema.marks[MARKDOWN_SOURCE_MARK]; - const { from: pasteFrom, to: pasteTo, $from: $pasteFrom } = view.state.selection; - if ( - sourceType?.isInSet($pasteFrom.marks()) && - !pastedText.includes("\n") && - collectComposerPromptInlineTokens(`${pastedText}\n`).length === 0 - ) { - view.dispatch( - markAsClipboardEdit( - view.state.tr.insertText(pastedText, pasteFrom, pasteTo), - "paste", - ).scrollIntoView(), - ); - return true; - } - const importFragment = readCallbacks().importContextFragment; - let text = - !literalText && importFragment - ? importPastedComposerText(clipboardData, importFragment) - : pastedText; - const tokens = literalText ? [] : collectComposerPromptInlineTokens(`${text}\n`); - const isChip = (token: (typeof tokens)[number] | undefined) => - token?.type === "mention" || - (token?.type === "skill" && skillLabelFor(token.value) !== null) || - (token?.type === "model" && isKnownModel(token.instanceId, token.model)); - const lastToken = tokens.at(-1); - if (isChip(lastToken) && lastToken?.end === text.length) { - text += " "; - } - if (isChip(tokens[0]) && tokens[0]?.start === 0) { - const map = serializeEditorDoc(view.state.doc); - const offset = flatToMarkdown(map, pmToFlat(map, view.state.selection.from)); - if (offset > 0 && !/\s/.test(map.value[offset - 1]!)) text = ` ${text}`; - } - const { $from } = view.state.selection; - const lineBefore = $from.parent.textBetween(0, $from.parentOffset, undefined, "\uFFFC"); - const literalLength = literalText - ? 0 - : pastedPathQueryLength(lineBefore, text, readCallbacks().isPathQueryActive?.()); - const editorInstance = (view.dom as TiptapEditorHTMLElement).editor; - if (editorInstance) { - const $paste = view.state.selection.$from; - const nested = ["listItem", "taskItem", "blockquote"].some((name) => - hasAncestor($paste, name), - ); - insertMarkdownParagraphs( - text, - skillLabelFor, - { styling: richText, blocks: !nested, literalText, literalLength, isKnownModel }, - (content) => { - editorInstance - .chain() - .command(({ tr }) => { - markAsClipboardEdit(tr, "paste"); - return true; - }) - // Pasted chips and lines would split shown Markdown, so parse it first. - .command(collapseShownMarkdownSource) - .insertContent(content) - .command(({ tr }) => { - // Move after a pasted rule so the next keystroke cannot replace it; add a trailing line - // if needed. - const { selection } = tr; - if (!(selection instanceof NodeSelection) || !selection.node.isBlock) { - return true; - } - if (!tr.doc.resolve(selection.to).nodeAfter?.isTextblock) { - tr.insert(selection.to, tr.doc.type.schema.nodes.paragraph!.create()); - } - tr.setSelection(TextSelection.create(tr.doc, selection.to + 1)); - return true; - }) - .run(); - }, - ); - scrollTiptapCaretIntoView(editorInstance); - } - return true; - }; - const editor = useEditor( { extensions: richText ? RICH_TEXT_EXTENSIONS : PLAIN_TEXT_EXTENSIONS, @@ -1202,8 +1107,8 @@ function ComposerPromptEditorTiptapInner( isKnownModel: (instanceId, model) => modelKeys.has(modelMentionKey(instanceId, model)), }), editable: !disabled, - // handlePaste parses pasted markdown itself and tags the insert as a paste for undo grouping, so Tiptap's - // paste rules would parse that result a second time and turn literal `*` and `**` into marks. + // handlePaste parses pasted markdown itself, so Tiptap's paste rules would parse that result a second + // time and turn literal `*` and `**` into marks. enablePasteRules: false, editorProps: { attributes: editorAttributes, @@ -1462,21 +1367,100 @@ function ComposerPromptEditorTiptapInner( handlePaste: (view, event) => { const clipboardData = event.clipboardData; if (!clipboardData || clipboardData.files.length > 0) return false; - if (!insertClipboardText(view, clipboardData)) return false; + const pastedText = clipboardData.getData("text/plain"); + if (!pastedText) return false; event.preventDefault(); + // Paste verbatim inside fences; markdown parsing would split blocks and create unsupported chips. + if (isInCodeBlock(view)) { + const { from, to } = view.state.selection; + view.dispatch( + markAsClipboardEdit( + view.state.tr.insertText(pastedText, from, to), + "paste", + ).scrollIntoView(), + ); + return true; + } + // Shown Markdown source is parsed when the caret leaves, so paste one line into it as text. + const sourceType = view.state.schema.marks[MARKDOWN_SOURCE_MARK]; + const { from: pasteFrom, to: pasteTo, $from: $pasteFrom } = view.state.selection; + if ( + sourceType?.isInSet($pasteFrom.marks()) && + !pastedText.includes("\n") && + collectComposerPromptInlineTokens(`${pastedText}\n`).length === 0 + ) { + view.dispatch( + markAsClipboardEdit( + view.state.tr.insertText(pastedText, pasteFrom, pasteTo), + "paste", + ).scrollIntoView(), + ); + return true; + } + const importFragment = readCallbacks().importContextFragment; + let text = + !literalText && importFragment + ? importPastedComposerText(clipboardData, importFragment) + : pastedText; + const tokens = literalText ? [] : collectComposerPromptInlineTokens(`${text}\n`); + const isChip = (token: (typeof tokens)[number] | undefined) => + token?.type === "mention" || + (token?.type === "skill" && skillLabelFor(token.value) !== null) || + (token?.type === "model" && isKnownModel(token.instanceId, token.model)); + const lastToken = tokens.at(-1); + if (isChip(lastToken) && lastToken?.end === text.length) { + text += " "; + } + if (isChip(tokens[0]) && tokens[0]?.start === 0) { + const map = serializeEditorDoc(view.state.doc); + const offset = flatToMarkdown(map, pmToFlat(map, view.state.selection.from)); + if (offset > 0 && !/\s/.test(map.value[offset - 1]!)) text = ` ${text}`; + } + const { $from } = view.state.selection; + const lineBefore = $from.parent.textBetween(0, $from.parentOffset, undefined, "\uFFFC"); + const literalLength = literalText + ? 0 + : pastedPathQueryLength(lineBefore, text, readCallbacks().isPathQueryActive?.()); + const editorInstance = (view.dom as TiptapEditorHTMLElement).editor; + if (editorInstance) { + const $paste = view.state.selection.$from; + const nested = ["listItem", "taskItem", "blockquote"].some((name) => + hasAncestor($paste, name), + ); + insertMarkdownParagraphs( + text, + skillLabelFor, + { styling: richText, blocks: !nested, literalText, literalLength, isKnownModel }, + (content) => { + editorInstance + .chain() + .command(({ tr }) => { + markAsClipboardEdit(tr, "paste"); + return true; + }) + // Pasted chips and lines would split shown Markdown, so parse it first. + .command(collapseShownMarkdownSource) + .insertContent(content) + .command(({ tr }) => { + // Move after a pasted rule so the next keystroke cannot replace it; add a trailing line + // if needed. + const { selection } = tr; + if (!(selection instanceof NodeSelection) || !selection.node.isBlock) { + return true; + } + if (!tr.doc.resolve(selection.to).nodeAfter?.isTextblock) { + tr.insert(selection.to, tr.doc.type.schema.nodes.paragraph!.create()); + } + tr.setSelection(TextSelection.create(tr.doc, selection.to + 1)); + return true; + }) + .run(); + }, + ); + scrollTiptapCaretIntoView(editorInstance); + } return true; }, - handleDrop: (view, event, _slice, moved) => { - const data = event.dataTransfer; - if (moved || view.dragging || !data || data.files.length > 0) return false; - if (!data.getData("text/plain")) return false; - const target = view.posAtCoords({ left: event.clientX, top: event.clientY }); - if (!target) return false; - view.dispatch( - view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(target.pos))), - ); - return insertClipboardText(view, data); - }, }, onUpdate: ({ editor: updated, transaction }) => { // Undo can restore a chip for a skill or model the environment no longer offers. From 7d3f96ac8f8a1de73811548b78ec14604a3dd953 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 04:27:18 +0000 Subject: [PATCH 7/8] fix(web): style markdown dropped into the composer and focus it Dropping the custom drop handler left dropped Markdown literal, which main styled through paste rules. Restore the shared insertion for external text drops and focus the view first, since handling the drop skips ProseMirror's own focus call. AI-assisted; verified with the tests and tools listed above. --- ...rPromptEditorTiptap.input.browser.test.tsx | 45 ++-- .../components/ComposerPromptEditorTiptap.tsx | 204 ++++++++++-------- 2 files changed, 139 insertions(+), 110 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 7a904566999c7..f738a8d5e31ad 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -342,27 +342,38 @@ it("pastes arithmetic stars verbatim and still styles pasted markdown", async () expect(editor.state.doc.textContent).toBe("a bold and soft word"); }); -it("keeps dropped arithmetic verbatim and focuses the editor", async () => { +it("parses markdown dropped from outside, keeps dropped arithmetic verbatim, and focuses", async () => { await using screen = await mountEditor("", true, []); const { editor } = screen; + const drop = async (text: string) => { + await act(async () => { + editor.commands.clearContent(); + editor.view.dom.blur(); + const dataTransfer = new DataTransfer(); + dataTransfer.setData("text/plain", text); + const { left, top } = editor.view.coordsAtPos(1); + editor.view.dom.dispatchEvent( + new DragEvent("drop", { + dataTransfer, + clientX: left, + clientY: top + 1, + bubbles: true, + cancelable: true, + }), + ); + }); + }; + await drop("a **bold** and *soft* word"); + expect(serializeEditorDoc(editor.state.doc).value).toBe("a **bold** and *soft* word"); + expect(editor.state.doc.textContent).toBe("a bold and soft word"); + expect(editor.view.hasFocus()).toBe(true); const code = "signal = (7 * 2**3 * 5)**4"; - await act(async () => { - editor.view.dom.blur(); - const dataTransfer = new DataTransfer(); - dataTransfer.setData("text/plain", code); - const { left, top } = editor.view.coordsAtPos(1); - editor.view.dom.dispatchEvent( - new DragEvent("drop", { - dataTransfer, - clientX: left, - clientY: top + 1, - bubbles: true, - cancelable: true, - }), - ); - }); + await drop(code); expect(serializeEditorDoc(editor.state.doc).value).toBe(code); - expect(editor.view.hasFocus()).toBe(true); + // A drag that started inside the editor is ProseMirror's move, not an external text drop. + editor.view.dragging = { slice: editor.state.doc.slice(0, 0), move: false }; + await drop("a **bold** word"); + expect(serializeEditorDoc(editor.state.doc).value).toBe(""); }); it("keeps the caret inside unknown dollar text across rich and plain remounts", async () => { diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index aa99d27c6f2a1..e16bfa0add523 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1098,6 +1098,101 @@ function ComposerPromptEditorTiptapInner( "aria-placeholder": placeholder, }; + const insertClipboardText = (view: EditorView, clipboardData: DataTransfer): boolean => { + const pastedText = clipboardData.getData("text/plain"); + if (!pastedText) return false; + // Paste verbatim inside fences; markdown parsing would split blocks and create unsupported chips. + if (isInCodeBlock(view)) { + const { from, to } = view.state.selection; + view.dispatch( + markAsClipboardEdit( + view.state.tr.insertText(pastedText, from, to), + "paste", + ).scrollIntoView(), + ); + return true; + } + // Shown Markdown source is parsed when the caret leaves, so paste one line into it as text. + const sourceType = view.state.schema.marks[MARKDOWN_SOURCE_MARK]; + const { from: pasteFrom, to: pasteTo, $from: $pasteFrom } = view.state.selection; + if ( + sourceType?.isInSet($pasteFrom.marks()) && + !pastedText.includes("\n") && + collectComposerPromptInlineTokens(`${pastedText}\n`).length === 0 + ) { + view.dispatch( + markAsClipboardEdit( + view.state.tr.insertText(pastedText, pasteFrom, pasteTo), + "paste", + ).scrollIntoView(), + ); + return true; + } + const importFragment = readCallbacks().importContextFragment; + let text = + !literalText && importFragment + ? importPastedComposerText(clipboardData, importFragment) + : pastedText; + const tokens = literalText ? [] : collectComposerPromptInlineTokens(`${text}\n`); + const isChip = (token: (typeof tokens)[number] | undefined) => + token?.type === "mention" || + (token?.type === "skill" && skillLabelFor(token.value) !== null) || + (token?.type === "model" && isKnownModel(token.instanceId, token.model)); + const lastToken = tokens.at(-1); + if (isChip(lastToken) && lastToken?.end === text.length) { + text += " "; + } + if (isChip(tokens[0]) && tokens[0]?.start === 0) { + const map = serializeEditorDoc(view.state.doc); + const offset = flatToMarkdown(map, pmToFlat(map, view.state.selection.from)); + if (offset > 0 && !/\s/.test(map.value[offset - 1]!)) text = ` ${text}`; + } + const { $from } = view.state.selection; + const lineBefore = $from.parent.textBetween(0, $from.parentOffset, undefined, "\uFFFC"); + const literalLength = literalText + ? 0 + : pastedPathQueryLength(lineBefore, text, readCallbacks().isPathQueryActive?.()); + const editorInstance = (view.dom as TiptapEditorHTMLElement).editor; + if (editorInstance) { + const $paste = view.state.selection.$from; + const nested = ["listItem", "taskItem", "blockquote"].some((name) => + hasAncestor($paste, name), + ); + insertMarkdownParagraphs( + text, + skillLabelFor, + { styling: richText, blocks: !nested, literalText, literalLength, isKnownModel }, + (content) => { + editorInstance + .chain() + .command(({ tr }) => { + markAsClipboardEdit(tr, "paste"); + return true; + }) + // Pasted chips and lines would split shown Markdown, so parse it first. + .command(collapseShownMarkdownSource) + .insertContent(content) + .command(({ tr }) => { + // Move after a pasted rule so the next keystroke cannot replace it; add a trailing line + // if needed. + const { selection } = tr; + if (!(selection instanceof NodeSelection) || !selection.node.isBlock) { + return true; + } + if (!tr.doc.resolve(selection.to).nodeAfter?.isTextblock) { + tr.insert(selection.to, tr.doc.type.schema.nodes.paragraph!.create()); + } + tr.setSelection(TextSelection.create(tr.doc, selection.to + 1)); + return true; + }) + .run(); + }, + ); + scrollTiptapCaretIntoView(editorInstance); + } + return true; + }; + const editor = useEditor( { extensions: richText ? RICH_TEXT_EXTENSIONS : PLAIN_TEXT_EXTENSIONS, @@ -1107,8 +1202,8 @@ function ComposerPromptEditorTiptapInner( isKnownModel: (instanceId, model) => modelKeys.has(modelMentionKey(instanceId, model)), }), editable: !disabled, - // handlePaste parses pasted markdown itself, so Tiptap's paste rules would parse that result a second - // time and turn literal `*` and `**` into marks. + // handlePaste parses pasted markdown itself and tags the insert as a paste for undo grouping, so Tiptap's + // paste rules would parse that result a second time and turn literal `*` and `**` into marks. enablePasteRules: false, editorProps: { attributes: editorAttributes, @@ -1367,100 +1462,23 @@ function ComposerPromptEditorTiptapInner( handlePaste: (view, event) => { const clipboardData = event.clipboardData; if (!clipboardData || clipboardData.files.length > 0) return false; - const pastedText = clipboardData.getData("text/plain"); - if (!pastedText) return false; + if (!insertClipboardText(view, clipboardData)) return false; event.preventDefault(); - // Paste verbatim inside fences; markdown parsing would split blocks and create unsupported chips. - if (isInCodeBlock(view)) { - const { from, to } = view.state.selection; - view.dispatch( - markAsClipboardEdit( - view.state.tr.insertText(pastedText, from, to), - "paste", - ).scrollIntoView(), - ); - return true; - } - // Shown Markdown source is parsed when the caret leaves, so paste one line into it as text. - const sourceType = view.state.schema.marks[MARKDOWN_SOURCE_MARK]; - const { from: pasteFrom, to: pasteTo, $from: $pasteFrom } = view.state.selection; - if ( - sourceType?.isInSet($pasteFrom.marks()) && - !pastedText.includes("\n") && - collectComposerPromptInlineTokens(`${pastedText}\n`).length === 0 - ) { - view.dispatch( - markAsClipboardEdit( - view.state.tr.insertText(pastedText, pasteFrom, pasteTo), - "paste", - ).scrollIntoView(), - ); - return true; - } - const importFragment = readCallbacks().importContextFragment; - let text = - !literalText && importFragment - ? importPastedComposerText(clipboardData, importFragment) - : pastedText; - const tokens = literalText ? [] : collectComposerPromptInlineTokens(`${text}\n`); - const isChip = (token: (typeof tokens)[number] | undefined) => - token?.type === "mention" || - (token?.type === "skill" && skillLabelFor(token.value) !== null) || - (token?.type === "model" && isKnownModel(token.instanceId, token.model)); - const lastToken = tokens.at(-1); - if (isChip(lastToken) && lastToken?.end === text.length) { - text += " "; - } - if (isChip(tokens[0]) && tokens[0]?.start === 0) { - const map = serializeEditorDoc(view.state.doc); - const offset = flatToMarkdown(map, pmToFlat(map, view.state.selection.from)); - if (offset > 0 && !/\s/.test(map.value[offset - 1]!)) text = ` ${text}`; - } - const { $from } = view.state.selection; - const lineBefore = $from.parent.textBetween(0, $from.parentOffset, undefined, "\uFFFC"); - const literalLength = literalText - ? 0 - : pastedPathQueryLength(lineBefore, text, readCallbacks().isPathQueryActive?.()); - const editorInstance = (view.dom as TiptapEditorHTMLElement).editor; - if (editorInstance) { - const $paste = view.state.selection.$from; - const nested = ["listItem", "taskItem", "blockquote"].some((name) => - hasAncestor($paste, name), - ); - insertMarkdownParagraphs( - text, - skillLabelFor, - { styling: richText, blocks: !nested, literalText, literalLength, isKnownModel }, - (content) => { - editorInstance - .chain() - .command(({ tr }) => { - markAsClipboardEdit(tr, "paste"); - return true; - }) - // Pasted chips and lines would split shown Markdown, so parse it first. - .command(collapseShownMarkdownSource) - .insertContent(content) - .command(({ tr }) => { - // Move after a pasted rule so the next keystroke cannot replace it; add a trailing line - // if needed. - const { selection } = tr; - if (!(selection instanceof NodeSelection) || !selection.node.isBlock) { - return true; - } - if (!tr.doc.resolve(selection.to).nodeAfter?.isTextblock) { - tr.insert(selection.to, tr.doc.type.schema.nodes.paragraph!.create()); - } - tr.setSelection(TextSelection.create(tr.doc, selection.to + 1)); - return true; - }) - .run(); - }, - ); - scrollTiptapCaretIntoView(editorInstance); - } return true; }, + handleDrop: (view, event, _slice, moved) => { + const data = event.dataTransfer; + if (moved || view.dragging || !data || data.files.length > 0) return false; + if (!data.getData("text/plain")) return false; + const target = view.posAtCoords({ left: event.clientX, top: event.clientY }); + if (!target) return false; + view.dispatch( + view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(target.pos))), + ); + // Handling the drop skips ProseMirror's own focus call, so typing would go to the previous control. + view.focus(); + return insertClipboardText(view, data); + }, }, onUpdate: ({ editor: updated, transaction }) => { // Undo can restore a chip for a skill or model the environment no longer offers. From fcf6993adcce03b5860d284fe156be4b043fa169 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 04:41:20 +0000 Subject: [PATCH 8/8] fix(web): keep formatting of HTML dropped into the composer The drop handler took over every drop with text/plain and discarded the slice ProseMirror parsed from text/html. Handle plain-text-only drops and leave HTML drops to ProseMirror, as main does. AI-assisted; verified with the tests and tools listed above. --- .../ComposerPromptEditorTiptap.input.browser.test.tsx | 5 ++++- apps/web/src/components/ComposerPromptEditorTiptap.tsx | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index f738a8d5e31ad..5bbe94d4a909d 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -345,12 +345,13 @@ it("pastes arithmetic stars verbatim and still styles pasted markdown", async () it("parses markdown dropped from outside, keeps dropped arithmetic verbatim, and focuses", async () => { await using screen = await mountEditor("", true, []); const { editor } = screen; - const drop = async (text: string) => { + const drop = async (text: string, html?: string) => { await act(async () => { editor.commands.clearContent(); editor.view.dom.blur(); const dataTransfer = new DataTransfer(); dataTransfer.setData("text/plain", text); + if (html) dataTransfer.setData("text/html", html); const { left, top } = editor.view.coordsAtPos(1); editor.view.dom.dispatchEvent( new DragEvent("drop", { @@ -367,6 +368,8 @@ it("parses markdown dropped from outside, keeps dropped arithmetic verbatim, and expect(serializeEditorDoc(editor.state.doc).value).toBe("a **bold** and *soft* word"); expect(editor.state.doc.textContent).toBe("a bold and soft word"); expect(editor.view.hasFocus()).toBe(true); + await drop("bold", "

bold

"); + expect(serializeEditorDoc(editor.state.doc).value).toBe("**bold**"); const code = "signal = (7 * 2**3 * 5)**4"; await drop(code); expect(serializeEditorDoc(editor.state.doc).value).toBe(code); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index e16bfa0add523..fa409fdf6ab70 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1469,7 +1469,8 @@ function ComposerPromptEditorTiptapInner( handleDrop: (view, event, _slice, moved) => { const data = event.dataTransfer; if (moved || view.dragging || !data || data.files.length > 0) return false; - if (!data.getData("text/plain")) return false; + // ProseMirror already parses an HTML drop into marks; only plain-text drops need Markdown parsing. + if (!data.getData("text/plain") || data.getData("text/html")) return false; const target = view.posAtCoords({ left: event.clientX, top: event.clientY }); if (!target) return false; view.dispatch(