From e9df44665cd57a6f8e79cc2e2777ee9ec2754619 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 26 Oct 2026 02:20:48 +0000 Subject: [PATCH 1/4] test(web): reproduce pasted and rebuilt underscores turning into emphasis Failing tests for Fixes pingdotgg/t3code#25180: a draft rebuilt from Markdown and plain-text pastes (plain, inside inline code, with bold markers inside a code span) must keep their text exactly. Co-Authored-By: Claude Opus 5.5 --- ...rPromptEditorTiptap.input.browser.test.tsx | 35 +++++++++++++++++++ apps/web/src/composer-rich-text-doc.test.ts | 6 ++++ 2 files changed, 41 insertions(+) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 7caa6f06f98ee..21577d33a7acb 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -220,6 +220,41 @@ it("keeps unknown dollar names editable when restoring and pasting a draft", asy expect(serializeEditorDoc(editor.state.doc).value).toBe(value); }); +// regression: #25180 pasted plain text was re-read as Markdown and sent rewritten +it.each([ + { + name: "underscores in a URL and a path", + value: "", + caret: 1, + pasted: "Read https://example.com/_handle_/status/1 and open src/__test__.tsx", + expected: "Read https://example.com/_handle_/status/1 and open src/__test__.tsx", + }, + { + name: "Markdown inside inline code", + value: "`xy`", + caret: 2, + pasted: "a_b_ **c**", + expected: "`xa_b_ **c**y`", + }, + { + name: "bold markers inside a pasted code span", + value: "", + caret: 1, + pasted: "see `**x**` now", + expected: "see `**x**` now", + }, +])("sends pasted $name exactly as pasted", async ({ value, caret, pasted, expected }) => { + await using screen = await mountEditor(value); + const { editor } = screen; + await act(async () => { + editor.commands.setTextSelection(caret); + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", pasted); + editor.view.dom.dispatchEvent(new ClipboardEvent("paste", { clipboardData, cancelable: true })); + }); + expect(serializeEditorDoc(editor.state.doc).value).toBe(expected); +}); + 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/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 3ad569e4b8a40..627cec8470832 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -813,6 +813,12 @@ describe("serializeSelection", () => { }); }); +// regression: #25180 rebuilding a draft rewrote underscores in URLs and paths as emphasis +it("rebuilds a draft with underscores in a URL and a path unchanged", () => { + const value = "Read https://example.com/_handle_/status/1 and open src/__test__.tsx"; + expect(roundTrip(value).value).toBe(value); +}); + describe("pasting into a list item or quote", () => { it("keeps inline marks but builds no blocks", () => { const content = buildTiptapContent( From f632bea29af0dd8e14f362d896635672a13f3ed8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 26 Oct 2026 02:25:38 +0000 Subject: [PATCH 2/4] fix(web): pasted URLs and paths keep their underscores Underscores after a word character or slash, as in /_handle_/ or src/__test__.tsx, no longer open emphasis, so drafts rebuilt from Markdown and plain-text pastes keep them. Pastes inside a code span stay literal, and Tiptap's paste rules no longer restyle pasted code spans such as `**x**`. Fixes pingdotgg/t3code#25180 Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ComposerPromptEditorTiptap.tsx | 10 ++++++++-- apps/web/src/composer-rich-text-doc.test.ts | 2 +- apps/web/src/composer-rich-text.ts | 6 +++++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 4b0ad2daa7dc5..75806089ddb33 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1119,6 +1119,8 @@ function ComposerPromptEditorTiptapInner( literalText, }), editable: !disabled, + // Pastes are parsed as Markdown above; Tiptap's rules would restyle the literal text of code spans. + enablePasteRules: false, editorProps: { attributes: editorAttributes, handleKeyDown: (view, event) => { @@ -1365,8 +1367,12 @@ function ComposerPromptEditorTiptapInner( 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)) { + // Paste verbatim inside fences and code spans; markdown parsing would split blocks and create unsupported chips. + const { storedMarks, selection } = view.state; + const inCodeSpan = (storedMarks ?? selection.$from.marks()).some( + (mark) => mark.type.name === "code", + ); + if (isInCodeBlock(view) || inCodeSpan) { const { from, to } = view.state.selection; view.dispatch( markAsClipboardEdit( diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 627cec8470832..011c02aa9b0b1 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -894,7 +894,7 @@ describe("pasting into a path query", () => { }); it("parses markdown when the paste does not continue a path query", () => { - expect(boldText(paste("Open ", "src/__test__.tsx"))).toEqual(["test"]); + expect(boldText(paste("Open ", "__test__.tsx"))).toEqual(["test"]); expect(boldText(paste("Open @a ", "__b__"))).toEqual(["b"]); expect(boldText(paste("Open @", " __b__"))).toEqual(["b"]); }); diff --git a/apps/web/src/composer-rich-text.ts b/apps/web/src/composer-rich-text.ts index 2a08f91636576..58d430c0b6586 100644 --- a/apps/web/src/composer-rich-text.ts +++ b/apps/web/src/composer-rich-text.ts @@ -65,7 +65,11 @@ export function parseInlineMarkdown(text: string): RichTextSpan[] { const before = text[index - 1] ?? ""; const after = text[end] ?? ""; const canClose = before !== "" && !/\s/.test(before) && (char !== "_" || !/\w/.test(after)); - const canOpen = after !== "" && !/\s/.test(after) && (char !== "_" || !/\w/.test(before)); + // `_` opens only after a space or opening punctuation, so `/_x_/` in URLs and paths stays literal. + const canOpen = + after !== "" && + !/\s/.test(after) && + (char !== "_" || before === "" || /[\s([{"'*~]/.test(before)); while (index < end) { const top = stack.at(-1); const opensNestedBold = From 5d71b6cf9f4b9c792956868e037c0033fb96a385 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 26 Oct 2026 02:35:18 +0000 Subject: [PATCH 3/4] fix(web): keep underscores in URL, path, and filename words; split multiline code span pastes The `_` opening whitelist still rewrote words like `src/(_foo_).tsx`, `~_user_/a`, `_build_/file.ts`, and `__init__.py`. Words with a slash or a file extension now keep `_` literal, and ordinary `_emphasis_` uses the original flanking rule. A multiline paste inside a code span now takes the Markdown path, because a code span with a newline does not rebuild. Co-Authored-By: Claude Opus 5.5 --- ...rPromptEditorTiptap.input.browser.test.tsx | 15 +++++++++++++ .../components/ComposerPromptEditorTiptap.tsx | 7 +++--- apps/web/src/composer-rich-text-doc.test.ts | 2 +- apps/web/src/composer-rich-text.test.ts | 21 ++++++++++++++++++ apps/web/src/composer-rich-text.ts | 22 ++++++++++++++----- 5 files changed, 58 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 21577d33a7acb..aa691507d876f 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -255,6 +255,21 @@ it.each([ expect(serializeEditorDoc(editor.state.doc).value).toBe(expected); }); +// regression: #25180 a multiline paste into a code span serialized as a span the parser cannot rebuild +it("rebuilds a multiline paste into a code span unchanged", async () => { + await using screen = await mountEditor("`xy`"); + const { editor } = screen; + await act(async () => { + editor.commands.setTextSelection(2); + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", " _a_\nb"); + editor.view.dom.dispatchEvent(new ClipboardEvent("paste", { clipboardData, cancelable: true })); + }); + const { value } = serializeEditorDoc(editor.state.doc); + await using rebuilt = await mountEditor(value); + expect(serializeEditorDoc(rebuilt.editor.state.doc).value).toBe(value); +}); + 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 75806089ddb33..df25ed459f653 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1369,9 +1369,10 @@ function ComposerPromptEditorTiptapInner( event.preventDefault(); // Paste verbatim inside fences and code spans; markdown parsing would split blocks and create unsupported chips. const { storedMarks, selection } = view.state; - const inCodeSpan = (storedMarks ?? selection.$from.marks()).some( - (mark) => mark.type.name === "code", - ); + // Code spans cannot hold a newline, so multiline pastes take the Markdown path and split the span. + const inCodeSpan = + !pastedText.includes("\n") && + (storedMarks ?? selection.$from.marks()).some((mark) => mark.type.name === "code"); if (isInCodeBlock(view) || inCodeSpan) { const { from, to } = view.state.selection; view.dispatch( diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 011c02aa9b0b1..4d4c3899aebed 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -894,7 +894,7 @@ describe("pasting into a path query", () => { }); it("parses markdown when the paste does not continue a path query", () => { - expect(boldText(paste("Open ", "__test__.tsx"))).toEqual(["test"]); + expect(boldText(paste("Open ", "__test__ now"))).toEqual(["test"]); expect(boldText(paste("Open @a ", "__b__"))).toEqual(["b"]); expect(boldText(paste("Open @", " __b__"))).toEqual(["b"]); }); diff --git a/apps/web/src/composer-rich-text.test.ts b/apps/web/src/composer-rich-text.test.ts index 3fca4b7196c91..20498921630c1 100644 --- a/apps/web/src/composer-rich-text.test.ts +++ b/apps/web/src/composer-rich-text.test.ts @@ -51,6 +51,27 @@ describe("composer rich text markdown", () => { ]); }); + // regression: #25180 underscores in URLs and paths were read as emphasis + it("keeps underscores in URLs and paths literal", () => { + for (const text of [ + "Read src/(_foo_).tsx", + "https://example.com/~_user_/a", + "_build_/file.ts", + "src/__test__.tsx", + "edit __init__.py, then", + ]) { + expect(parseInlineMarkdown(text)).toEqual([{ text, marks: [] }]); + } + expect(parseInlineMarkdown("(_see_) _a b_ __c__")).toEqual([ + { text: "(", marks: [] }, + { text: "see", marks: ["italic"] }, + { text: ") ", marks: [] }, + { text: "a b", marks: ["italic"] }, + { text: " ", marks: [] }, + { text: "c", marks: ["bold"] }, + ]); + }); + it("keeps code span contents literal", () => { expect(parseInlineMarkdown("`**not bold**`")).toEqual([ { text: "**not bold**", marks: ["code"] }, diff --git a/apps/web/src/composer-rich-text.ts b/apps/web/src/composer-rich-text.ts index 58d430c0b6586..4f82860d5f743 100644 --- a/apps/web/src/composer-rich-text.ts +++ b/apps/web/src/composer-rich-text.ts @@ -26,7 +26,14 @@ function pushSpan(spans: RichTextSpan[], text: string, marks: RichTextMark[]): v } } +/** Words containing a slash or ending in a file extension are URLs, paths, or filenames. */ +const LITERAL_UNDERSCORE_WORD = /\/|\.[a-z][a-z0-9]*\W*$/i; + export function parseInlineMarkdown(text: string): RichTextSpan[] { + const literalWords = Array.from(text.matchAll(/\S+/g)).filter((word) => + LITERAL_UNDERSCORE_WORD.test(word[0]), + ); + let literalWord = 0; const root: RichTextSpan[] = []; const stack: { delimiter: string; mark: RichTextMark; spans: RichTextSpan[] }[] = []; const current = () => stack.at(-1)?.spans ?? root; @@ -62,14 +69,19 @@ export function parseInlineMarkdown(text: string): RichTextSpan[] { } let end = index; while (text[end] === char) end += 1; + if (char === "_") { + let word = literalWords[literalWord]; + while (word && word.index + word[0].length <= index) word = literalWords[++literalWord]; + if (word && word.index <= index) { + pushSpan(current(), text.slice(index, end), []); + index = end; + continue; + } + } const before = text[index - 1] ?? ""; const after = text[end] ?? ""; const canClose = before !== "" && !/\s/.test(before) && (char !== "_" || !/\w/.test(after)); - // `_` opens only after a space or opening punctuation, so `/_x_/` in URLs and paths stays literal. - const canOpen = - after !== "" && - !/\s/.test(after) && - (char !== "_" || before === "" || /[\s([{"'*~]/.test(before)); + const canOpen = after !== "" && !/\s/.test(after) && (char !== "_" || !/\w/.test(before)); while (index < end) { const top = stack.at(-1); const opensNestedBold = From 2e2111ba1977fc00cb8ffb144ee144b08954e199 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 26 Oct 2026 02:39:27 +0000 Subject: [PATCH 4/4] fix(web): split code span pastes that contain a backtick A backtick pasted into a code span was inserted literally, but the single-backtick serializer cannot represent it, so the span rebuilt with the pasted text parsed as Markdown. Such pastes now take the Markdown path, like multiline pastes. Co-Authored-By: Claude Opus 5.5 --- ...rPromptEditorTiptap.input.browser.test.tsx | 33 +++++++++++-------- .../components/ComposerPromptEditorTiptap.tsx | 4 +-- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index aa691507d876f..2ef49ec5cfdb4 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -255,20 +255,25 @@ it.each([ expect(serializeEditorDoc(editor.state.doc).value).toBe(expected); }); -// regression: #25180 a multiline paste into a code span serialized as a span the parser cannot rebuild -it("rebuilds a multiline paste into a code span unchanged", async () => { - await using screen = await mountEditor("`xy`"); - const { editor } = screen; - await act(async () => { - editor.commands.setTextSelection(2); - const clipboardData = new DataTransfer(); - clipboardData.setData("text/plain", " _a_\nb"); - editor.view.dom.dispatchEvent(new ClipboardEvent("paste", { clipboardData, cancelable: true })); - }); - const { value } = serializeEditorDoc(editor.state.doc); - await using rebuilt = await mountEditor(value); - expect(serializeEditorDoc(rebuilt.editor.state.doc).value).toBe(value); -}); +// regression: #25180 a multiline or backtick paste into a code span serialized as a span the parser cannot rebuild +it.each([" _a_\nb", "`__z__`"])( + "rebuilds a paste of %j into a code span unchanged", + async (pasted) => { + await using screen = await mountEditor("`xy`"); + const { editor } = screen; + await act(async () => { + editor.commands.setTextSelection(2); + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", pasted); + editor.view.dom.dispatchEvent( + new ClipboardEvent("paste", { clipboardData, cancelable: true }), + ); + }); + const { value } = serializeEditorDoc(editor.state.doc); + await using rebuilt = await mountEditor(value); + expect(serializeEditorDoc(rebuilt.editor.state.doc).value).toBe(value); + }, +); it("keeps the caret inside unknown dollar text across rich and plain remounts", async () => { const value = "echo $HOME now"; diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 4cf4a08fb5eef..42055a0d8d09e 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1373,9 +1373,9 @@ function ComposerPromptEditorTiptapInner( event.preventDefault(); // Paste verbatim inside fences and code spans; markdown parsing would split blocks and create unsupported chips. const { storedMarks, selection } = view.state; - // Code spans cannot hold a newline, so multiline pastes take the Markdown path and split the span. + // Code spans cannot hold a newline or backtick, so those pastes take the Markdown path and split the span. const inCodeSpan = - !pastedText.includes("\n") && + !/[\n`]/.test(pastedText) && (storedMarks ?? selection.$from.marks()).some((mark) => mark.type.name === "code"); if (isInCodeBlock(view) || inCodeSpan) { const { from, to } = view.state.selection;