From 93b4d8b8c55e078e7b4a0e2e62a5c397111474e6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 21:53:59 +0000 Subject: [PATCH 1/3] test(web): CRLF and CR pastes reproduce #27080 Pasting "x\r\n\r\ny" into the composer leaves "\r" in the paragraphs and a zero-height blank line; pasting "x\r\ry" stays one paragraph. Both should produce the same document as "x\n\ny". These tests reproduce pingdotgg/t3code#27080 and fail on main. Co-Authored-By: Claude Opus 5.5 --- ...rPromptEditorTiptap.input.browser.test.tsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index c59724cccf693..9a64223fb02b2 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -220,6 +220,26 @@ it("keeps unknown dollar names editable when restoring and pasting a draft", asy expect(serializeEditorDoc(editor.state.doc).value).toBe(value); }); +it.each(["x\r\n\r\ny", "x\r\ry"])( + "pastes %j with the same blank line as x\\n\\ny", + async (text) => { + await using screen = await mountEditor(""); + const { editor } = screen; + await act(async () => { + editor.commands.focus("end"); + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", text); + editor.view.dom.dispatchEvent( + new ClipboardEvent("paste", { clipboardData, cancelable: true }), + ); + }); + const paragraphs: string[] = []; + editor.state.doc.forEach((node) => paragraphs.push(node.textContent)); + expect(paragraphs).toEqual(["x", "", "y"]); + expect(serializeEditorDoc(editor.state.doc).value).toBe("x\n\ny"); + }, +); + 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); From 7ce42465ebc3e253d8347544e914dca49f479e8e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 21:57:59 +0000 Subject: [PATCH 2/3] fix(web): CRLF pastes keep blank lines in the composer The composer's custom paste handler read text/plain as-is, bypassing ProseMirror's own \r\n? -> \n normalization, so Windows CRLF and lone-CR pastes left "\r" in paragraphs and collapsed blank lines. Read pasted plain text through one helper that normalizes line endings, used by the editor's paste handler (code block, literal and markdown paths) and by importPastedComposerText (context-fragment and paste-to-focus paths). Fixes pingdotgg/t3code#27080 Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ComposerPromptEditorTiptap.tsx | 4 ++-- apps/web/src/components/composerInlineTokenPaste.ts | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index abc9549ee3d00..349d1ac36a4d0 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -121,7 +121,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView"; import { resolveAssistantCitationCommentDismissal } from "./chat/assistantCitationCommentDismissal"; import { composerCodeBlockHighlight } from "./composerCodeBlockHighlight"; -import { importPastedComposerText } from "./composerInlineTokenPaste"; +import { importPastedComposerText, readPastedPlainText } from "./composerInlineTokenPaste"; import { didComposerSelectionChangeVisibly } from "./composerSelection"; import type { ComposerDraftContextRecords } from "./composerContextPresentation"; @@ -1349,7 +1349,7 @@ function ComposerPromptEditorTiptapInner( handlePaste: (view, event) => { const clipboardData = event.clipboardData; if (!clipboardData || clipboardData.files.length > 0) return false; - const pastedText = clipboardData.getData("text/plain"); + const pastedText = readPastedPlainText(clipboardData); if (!pastedText) return false; event.preventDefault(); // Paste verbatim inside fences; markdown parsing would split blocks and create unsupported chips. diff --git a/apps/web/src/components/composerInlineTokenPaste.ts b/apps/web/src/components/composerInlineTokenPaste.ts index 32722ad6a9571..baa5e5aa21724 100644 --- a/apps/web/src/components/composerInlineTokenPaste.ts +++ b/apps/web/src/components/composerInlineTokenPaste.ts @@ -10,6 +10,12 @@ import { formatComposerContextReference, replaceComposerContextReferences, } from "@t3tools/shared/composerContextReferences"; + +// Matches ProseMirror's default clipboard parser, which a custom handlePaste bypasses. +export function readPastedPlainText(clipboardData: Pick): string { + return clipboardData.getData("text/plain").replace(/\r\n?/g, "\n"); +} + export function readPastedComposerContext( clipboardData: Pick, ): ComposerContextClipboardFragment | null { @@ -43,7 +49,7 @@ export function importPastedComposerText( fragment: ComposerContextClipboardFragment, ) => ReadonlyMap, ): string { - const pastedText = clipboardData.getData("text/plain"); + const pastedText = readPastedPlainText(clipboardData); const fragment = importContextFragment ? readPastedComposerContext(clipboardData) : null; const rewrittenIds = fragment && fragment.records.length > 0 ? importContextFragment!(fragment) : null; From 95133f7f2ae352efc1ac3133e0be950235aee57f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 23:32:21 +0000 Subject: [PATCH 3/3] test(web): cover CRLF normalization in pasted composer text import Co-Authored-By: Claude Opus 5.5 --- .../src/components/composerInlineTokenPaste.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 apps/web/src/components/composerInlineTokenPaste.test.ts diff --git a/apps/web/src/components/composerInlineTokenPaste.test.ts b/apps/web/src/components/composerInlineTokenPaste.test.ts new file mode 100644 index 0000000000000..f6ad647687ff8 --- /dev/null +++ b/apps/web/src/components/composerInlineTokenPaste.test.ts @@ -0,0 +1,10 @@ +import { expect, it } from "vite-plus/test"; + +import { importPastedComposerText } from "./composerInlineTokenPaste"; + +it("imports CRLF and CR clipboard text with LF line breaks", () => { + const clipboardData = { + getData: (type: string) => (type === "text/plain" ? "x\r\n\r\ny\rz" : ""), + }; + expect(importPastedComposerText(clipboardData, () => new Map())).toBe("x\n\ny\nz"); +});