From 3bd4183c8246c4dde5f489b8e672b9328335071e Mon Sep 17 00:00:00 2001 From: AlphaCat Date: Sat, 1 Aug 2026 09:37:10 +0800 Subject: [PATCH 1/3] fix(chat): preserve pasted user message newlines Normalize logical line endings once across paste, draft, send, history, and user bubble rendering. Add mirrored GUI/WebUI pipeline coverage for DOM serialization, replay, cross-client parity, and visual blank-line spacing. --- .../webui/paste-newline-pipeline.test.mjs | 148 +++++++++++++++++ .../agent-gateway/web/src/app/GatewayApp.tsx | 9 +- crates/agent-gateway/web/src/app/chatDraft.ts | 53 +++--- .../src/components/chat/MentionComposer.tsx | 97 +++++++---- crates/agent-gateway/web/src/index.css | 5 + .../web/src/lib/chat/composerText.ts | 34 ++++ .../web/src/lib/chat/uploadedFiles.ts | 7 +- .../web/src/lib/chat/userMessageContent.tsx | 20 ++- .../test/browser/paste-newline-pipeline.html | 133 +++++++++++++++ .../src/components/chat/MentionComposer.tsx | 95 +++++++---- crates/agent-gui/src/index.css | 5 + crates/agent-gui/src/lib/chat/composerText.ts | 34 ++++ .../src/lib/chat/messages/uploadedFiles.ts | 8 +- .../lib/chat/messages/userMessageContent.tsx | 20 ++- .../pages/chat/composer/composerDraftText.ts | 62 +++---- .../src/pages/chat/runtime/useSendChatTurn.ts | 16 +- .../test/browser/paste-newline-pipeline.html | 133 +++++++++++++++ ...erify-paste-newline-pipeline.playwright.js | 143 +++++++++++++++++ crates/agent-gui/test/chat/messages.test.mjs | 2 +- .../test/chat/paste-newline-pipeline.test.mjs | 151 ++++++++++++++++++ .../test/chat/user-message-content.test.mjs | 13 ++ docs/worklog/paste-newline-serialization.md | 145 +++++++++++++++++ scripts/mirror-manifest.json | 1 + 23 files changed, 1198 insertions(+), 136 deletions(-) create mode 100644 crates/agent-gateway/test/webui/paste-newline-pipeline.test.mjs create mode 100644 crates/agent-gateway/web/src/lib/chat/composerText.ts create mode 100644 crates/agent-gateway/web/test/browser/paste-newline-pipeline.html create mode 100644 crates/agent-gui/src/lib/chat/composerText.ts create mode 100644 crates/agent-gui/test/browser/paste-newline-pipeline.html create mode 100644 crates/agent-gui/test/browser/verify-paste-newline-pipeline.playwright.js create mode 100644 crates/agent-gui/test/chat/paste-newline-pipeline.test.mjs create mode 100644 docs/worklog/paste-newline-serialization.md diff --git a/crates/agent-gateway/test/webui/paste-newline-pipeline.test.mjs b/crates/agent-gateway/test/webui/paste-newline-pipeline.test.mjs new file mode 100644 index 000000000..05ffee1bb --- /dev/null +++ b/crates/agent-gateway/test/webui/paste-newline-pipeline.test.mjs @@ -0,0 +1,148 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createWebModuleLoader } from "../helpers/load-web-module.mjs"; + +const loader = createWebModuleLoader(); +const composer = loader.loadModule("src/components/chat/MentionComposer.tsx"); +const composerText = loader.loadModule("src/lib/chat/composerText.ts"); +const draftText = loader.loadModule("src/app/chatDraft.ts"); +const uploadedFiles = loader.loadModule("src/lib/chat/uploadedFiles.ts"); + +const originalNode = globalThis.Node; +globalThis.Node = { TEXT_NODE: 3, ELEMENT_NODE: 1 }; + +test.after(() => { + if (originalNode === undefined) delete globalThis.Node; + else globalThis.Node = originalNode; +}); + +function textNode(text) { + return { nodeType: Node.TEXT_NODE, textContent: text }; +} + +function elementNode(tagName, childNodes = [], attributes = {}) { + return { + nodeType: Node.ELEMENT_NODE, + tagName, + childNodes, + getAttribute(name) { + return attributes[name] ?? null; + }, + hasAttribute(name) { + return Object.hasOwn(attributes, name); + }, + }; +} + +function chromiumPasteDom(clipboardText) { + const normalized = clipboardText.replace(/\r\n?/g, "\n"); + const lines = normalized.split("\n"); + const children = []; + if (lines[0]) children.push(textNode(lines[0])); + else if (lines.length > 1) children.push(elementNode("DIV", [elementNode("BR")])); + for (const line of lines.slice(1)) { + children.push(elementNode("DIV", line ? [textNode(line)] : [elementNode("BR")])); + } + return elementNode("DIV", children); +} + +function draftFromSegments(segments) { + const text = segments.map((segment) => segment.text ?? "").join(""); + return { + segments, + text, + textWithoutLargePastes: text, + largePastes: [], + skillMentions: [], + commitMentions: [], + gitFileMentions: [], + codeMentions: [], + isEmpty: text.trim().length === 0, + }; +} + +const cases = [ + ["LF no blank line", "alpha\nbeta"], + ["LF one blank line", "alpha\n\nbeta"], + ["CRLF one blank line", "alpha\r\n\r\nbeta"], + ["CR one blank line", "alpha\r\rbeta"], + ["multiple blank lines", "alpha\n\n\nbeta"], + ["leading newline", "\nalpha"], + ["trailing newline", "alpha\n"], + ["Markdown paragraphs", "first paragraph\n\nsecond paragraph"], + ["Markdown list", "- one\n- two"], + ["Markdown quote", "> quote\n> continued"], + ["Markdown code block", "```ts\nconst value = 1;\n```"], + ["Markdown table", "| a | b |\n| - | - |\n| 1 | 2 |"], + ["Unicode and emoji", "你好🙂\n\nκαλημέρα"], + ["long text", `${"x".repeat(20_000)}\n\n${"y".repeat(20_000)}`], +]; + +test("clipboard DOM -> composer draft -> outbound -> history preserves logical newlines", () => { + for (const [name, clipboardText] of cases) { + const expected = clipboardText.replace(/\r\n?/g, "\n"); + const segments = composer.serializeChildrenToSegments(chromiumPasteDom(clipboardText), new Map()); + const draft = draftFromSegments(segments); + const outbound = draftText.buildTextFromComposerDraft(draft); + const message = uploadedFiles.createUserMessageWithUploads(outbound, [], 1); + const history = JSON.parse(JSON.stringify(message)); + + assert.equal(draft.text, expected, `${name}: composer draft`); + assert.equal(outbound, expected, `${name}: outbound payload`); + assert.equal(history.content, expected, `${name}: history/replay`); + assert.equal( + uploadedFiles.getUserMessageDisplayText(history), + expected, + `${name}: transcript user bubble text`, + ); + } +}); + +test("pure whitespace remains structurally intact in the draft but is not sendable", () => { + const clipboardText = " \r\n\r\n "; + const expected = " \n\n "; + const segments = composer.serializeChildrenToSegments(chromiumPasteDom(clipboardText), new Map()); + const draft = draftFromSegments(segments); + assert.equal(draft.text, expected); + assert.equal(draftText.buildTextFromComposerDraft(draft), expected); + assert.equal(uploadedFiles.createUserMessageWithUploads(expected, [], 1), null); +}); + +test("message creation normalizes line endings without trimming logical edge newlines", () => { + const input = "\r\nalpha\r\nbeta\r"; + const expected = "\nalpha\nbeta\n"; + const message = uploadedFiles.createUserMessageWithUploads(input, [], 1); + assert.equal(message.content, expected); + assert.equal(uploadedFiles.getUserMessageDisplayText(message), expected); +}); + +test("plaintext HTML escaping preserves literal content and logical newlines", () => { + assert.equal( + composerText.plainTextToContentEditableHtml("& value\r\nnext"), + "<tag>& value\nnext", + ); +}); + +test("composer serialization preserves newlines around mention chips", () => { + const root = elementNode("DIV", [ + textNode("first\n"), + elementNode("SPAN", [], { + "data-mention-path": "src/App.tsx", + "data-mention-kind": "file", + }), + textNode("\nsecond"), + ]); + const segments = composer.serializeChildrenToSegments(root, new Map()); + assert.deepEqual( + segments.map((segment) => + segment.type === "text" + ? { type: "text", text: segment.text } + : { type: segment.type, path: segment.reference?.path }, + ), + [ + { type: "text", text: "first\n" }, + { type: "fileMention", path: "src/App.tsx" }, + { type: "text", text: "\nsecond" }, + ], + ); +}); diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index a8dce2858..95dc62a8a 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -31,6 +31,7 @@ import { registerAskUserQuestionAnswerHandler } from "@/lib/chat/askUserQuestion import type { ChatFileLink } from "@/lib/chat/chatFileLinks"; import type { ChatHistorySummary } from "@/lib/chat/chatHistory"; import { buildModelOptions } from "@/lib/chat/chatPageHelpers"; +import { normalizeLogicalLineEndings } from "@/lib/chat/composerText"; import type { HistoryMessageRef } from "@/lib/chat/conversationState"; import { adoptHistoryWindowState, @@ -2392,11 +2393,11 @@ export default function GatewayApp() { files: PendingUploadedFile[], workdir: string, ) { - let text = ( + let text = normalizeLogicalLineEndings( isAgentMode && draft.largePastes.length > 0 ? draft.textWithoutLargePastes - : buildTextFromComposerDraft(draft) - ).trim(); + : buildTextFromComposerDraft(draft), + ); let uploadedFiles = files; if (isAgentMode && draft.largePastes.length > 0) { @@ -2414,7 +2415,7 @@ export default function GatewayApp() { if (apiRef.current?.getActiveAgent().trim() !== agentID) { throw new Error("Agent 已切换,已取消发送本次大段粘贴内容。"); } - text = buildTextFromComposerDraft(draft, imported.fileByPasteId).trim(); + text = buildTextFromComposerDraft(draft, imported.fileByPasteId); uploadedFiles = mergePendingUploadedFiles(files, imported.files); } finally { isImportingPastedTextRef.current = false; diff --git a/crates/agent-gateway/web/src/app/chatDraft.ts b/crates/agent-gateway/web/src/app/chatDraft.ts index a125d7b4a..e8ba582d2 100644 --- a/crates/agent-gateway/web/src/app/chatDraft.ts +++ b/crates/agent-gateway/web/src/app/chatDraft.ts @@ -4,6 +4,7 @@ import type { MentionComposerGitFileMention, MentionComposerLargePaste, } from "@/components/chat/MentionComposer"; +import { normalizeLogicalLineEndings } from "@/lib/chat/composerText"; import { formatCodeMentionToken, formatFileMentionToken } from "@/lib/chat/mentionReferences"; import type { PendingUploadedFile } from "@/lib/chat/uploadedFiles"; import { withPastedTextDisplayMetadata } from "@/lib/chat/uploadedFiles"; @@ -53,31 +54,33 @@ export function buildTextFromComposerDraft( draft: MentionComposerDraft, pastedFileById?: Map, ) { - return draft.segments - .map((segment) => { - if (segment.type === "text") { - return segment.text; - } - if (segment.type === "fileMention") { - return formatFileMentionToken(segment.reference); - } - if (segment.type === "skillMention") { - return `$${segment.skill.name}`; - } - if (segment.type === "commitMention") { - return formatComposerCommitMention(segment.commit); - } - if (segment.type === "gitFileMention") { - return formatComposerGitFileMention(segment.file); - } - if (segment.type === "codeMention") { - return formatCodeMentionToken(segment.reference); - } - const file = pastedFileById?.get(segment.paste.id); - return file ? `[${segment.paste.label}: ${file.relativePath}]` : segment.paste.text; - }) - .join("") - .replace(/\u00A0/g, " "); + return normalizeLogicalLineEndings( + draft.segments + .map((segment) => { + if (segment.type === "text") { + return segment.text; + } + if (segment.type === "fileMention") { + return formatFileMentionToken(segment.reference); + } + if (segment.type === "skillMention") { + return `$${segment.skill.name}`; + } + if (segment.type === "commitMention") { + return formatComposerCommitMention(segment.commit); + } + if (segment.type === "gitFileMention") { + return formatComposerGitFileMention(segment.file); + } + if (segment.type === "codeMention") { + return formatCodeMentionToken(segment.reference); + } + const file = pastedFileById?.get(segment.paste.id); + return file ? `[${segment.paste.label}: ${file.relativePath}]` : segment.paste.text; + }) + .join("") + .replace(/\u00A0/g, " "), + ); } export async function importPastedTextsAsFiles(params: { diff --git a/crates/agent-gateway/web/src/components/chat/MentionComposer.tsx b/crates/agent-gateway/web/src/components/chat/MentionComposer.tsx index c52522236..9b14a9d1a 100644 --- a/crates/agent-gateway/web/src/components/chat/MentionComposer.tsx +++ b/crates/agent-gateway/web/src/components/chat/MentionComposer.tsx @@ -17,6 +17,10 @@ import { } from "react"; import { createPortal } from "react-dom"; import { useLocale } from "../../i18n"; +import { + insertPlainTextWithUndo, + normalizeLogicalLineEndings, +} from "../../lib/chat/composerText"; import { type CodeMentionReference, codeMentionDisplayName, @@ -281,7 +285,7 @@ function countCaretAnchors(value: string) { } function normalizeSerializedText(value: string) { - return removeCaretAnchors(value).replace(/\u00A0/g, " "); + return normalizeLogicalLineEndings(removeCaretAnchors(value).replace(/\u00A0/g, " ")); } function isMentionBoundaryChar(value: string) { @@ -300,7 +304,7 @@ function pushTextSegment(out: MentionComposerDraftSegment[], text: string) { out.push({ type: "text", text }); } -function serializeChildrenToSegments( +export function serializeChildrenToSegments( parent: Node, largePastes: Map, ): MentionComposerDraftSegment[] { @@ -337,9 +341,16 @@ function collectDraftSegments( largePastes: Map, ): MentionComposerDraftSegment[] { const parts: MentionComposerDraftSegment[] = []; + let hasLogicalChild = false; + let previousChildWasBlock = false; parent.childNodes.forEach((child) => { + const childParts: MentionComposerDraftSegment[] = []; + let childIsBlock = false; + let childIsLogical = false; if (child.nodeType === Node.TEXT_NODE) { - pushTextSegment(parts, removeCaretAnchors(child.textContent || "")); + const text = removeCaretAnchors(child.textContent || ""); + pushTextSegment(childParts, text); + childIsLogical = text.length > 0; } else if (child.nodeType === Node.ELEMENT_NODE) { const el = child as HTMLElement; const mentionPath = el.getAttribute(MENTION_TAG_ATTR); @@ -347,29 +358,33 @@ function collectDraftSegments( const kind = el.getAttribute(MENTION_KIND_ATTR) === "dir" ? "dir" : "file"; const reference = createFileMentionReference(mentionPath, kind); if (reference) { - parts.push({ type: "fileMention", reference }); + childParts.push({ type: "fileMention", reference }); + childIsLogical = true; } } else if (el.hasAttribute(GIT_FILE_MENTION_PATH_ATTR)) { const file = gitFileMentionFromElement(el); if (file) { - parts.push({ type: "gitFileMention", file }); + childParts.push({ type: "gitFileMention", file }); + childIsLogical = true; } } else if (el.hasAttribute(CODE_MENTION_PATH_ATTR)) { const reference = codeMentionFromElement(el); if (reference) { - parts.push({ type: "codeMention", reference }); + childParts.push({ type: "codeMention", reference }); + childIsLogical = true; } } else if (el.hasAttribute(COMMIT_MENTION_SHA_ATTR)) { const commit = commitMentionFromElement(el); if (commit) { - parts.push({ type: "commitMention", commit }); + childParts.push({ type: "commitMention", commit }); + childIsLogical = true; } } else if (el.hasAttribute(SKILL_MENTION_NAME_ATTR)) { const name = el.getAttribute(SKILL_MENTION_NAME_ATTR)?.trim() ?? ""; const skillFile = el.getAttribute(SKILL_MENTION_FILE_ATTR)?.trim() ?? ""; const baseDir = el.getAttribute(SKILL_MENTION_BASE_DIR_ATTR)?.trim() ?? ""; if (name && skillFile && baseDir) { - parts.push({ + childParts.push({ type: "skillMention", skill: { name, @@ -378,31 +393,49 @@ function collectDraftSegments( description: el.getAttribute(SKILL_MENTION_DESCRIPTION_ATTR)?.trim() ?? "", }, }); + childIsLogical = true; } } else { const largePasteId = el.getAttribute(LARGE_PASTE_TAG_ATTR); const largePaste = largePasteId ? largePastes.get(largePasteId) : undefined; if (largePaste) { - parts.push({ type: "largePaste", paste: largePaste }); - return; - } - if (el.tagName === "BR") { - pushTextSegment(parts, "\n"); - } else { - // Block-level wrappers (DIV / P) inserted by the browser on Enter - if (el.tagName === "DIV" || el.tagName === "P") { - if (parts.length > 0) pushTextSegment(parts, "\n"); + childParts.push({ type: "largePaste", paste: largePaste }); + childIsLogical = true; + } else if (el.tagName === "BR") { + const parentEl = + parent.nodeType === Node.ELEMENT_NODE ? (parent as HTMLElement) : null; + const isEmptyBlockPlaceholder = + parentEl != null && + (parentEl.tagName === "DIV" || parentEl.tagName === "P") && + parent.childNodes.length === 1; + if (!isEmptyBlockPlaceholder) { + pushTextSegment(childParts, "\n"); + childIsLogical = true; } + } else { + childIsBlock = el.tagName === "DIV" || el.tagName === "P"; for (const segment of collectDraftSegments(el, largePastes)) { if (segment.type === "text") { - pushTextSegment(parts, segment.text); + pushTextSegment(childParts, segment.text); } else { - parts.push(segment); + childParts.push(segment); } } + childIsLogical = childIsBlock || childParts.length > 0; } } } + + if (!childIsLogical) return; + if (hasLogicalChild && (previousChildWasBlock || childIsBlock)) { + pushTextSegment(parts, "\n"); + } + for (const segment of childParts) { + if (segment.type === "text") pushTextSegment(parts, segment.text); + else parts.push(segment); + } + hasLogicalChild = true; + previousChildWasBlock = childIsBlock; }); return parts; } @@ -2277,15 +2310,16 @@ export const MentionComposer = memo( }, []); const createLargePaste = useCallback((text: string): MentionComposerLargePaste => { + const normalizedText = normalizeLogicalLineEndings(text); const index = largePasteCounterRef.current + 1; largePasteCounterRef.current = index; return { id: `large-paste-${Date.now()}-${createUuid()}`, label: `Pasted text ${index}`, - text, - charCount: text.length, - lineCount: countLargePasteLines(text), - preview: normalizeLargePastePreview(text), + text: normalizedText, + charCount: normalizedText.length, + lineCount: countLargePasteLines(normalizedText), + preview: normalizeLargePastePreview(normalizedText), }; }, []); @@ -2376,10 +2410,11 @@ export const MentionComposer = memo( el.innerHTML = ""; largePastesRef.current.clear(); closeCommitTooltip(); - if (isLargePasteText(text)) { - insertLargePaste(text); + const normalizedText = normalizeLogicalLineEndings(text); + if (isLargePasteText(normalizedText)) { + insertLargePaste(normalizedText); } else { - el.innerText = text; + el.textContent = normalizedText; closeMentionSession(); refreshEmptyState(); } @@ -2416,8 +2451,10 @@ export const MentionComposer = memo( } else if (segment.type === "codeMention") { const chip = createCodeMentionChip(segment.reference); if (chip) el.appendChild(chip); - } else if (segment.text) { - el.appendChild(document.createTextNode(segment.text)); + } else if (segment.text) { + el.appendChild( + document.createTextNode(normalizeLogicalLineEndings(segment.text)), + ); } } largePasteCounterRef.current = Math.max( @@ -3028,12 +3065,12 @@ export const MentionComposer = memo( return; } e.preventDefault(); - const text = e.clipboardData.getData("text/plain"); + const text = normalizeLogicalLineEndings(e.clipboardData.getData("text/plain")); if (isLargePasteText(text)) { insertLargePaste(text); return; } - document.execCommand("insertText", false, text); + insertPlainTextWithUndo(text); refreshEmptyState(); refreshMention(); }, diff --git a/crates/agent-gateway/web/src/index.css b/crates/agent-gateway/web/src/index.css index 3059caae6..1080a39ea 100644 --- a/crates/agent-gateway/web/src/index.css +++ b/crates/agent-gateway/web/src/index.css @@ -803,6 +803,11 @@ word-break: break-word; } + /* Browsers do not allocate a final line box for a trailing preserved LF. */ + .chat-user-trailing-newline-anchor::before { + content: "\200b"; + } + /* Settings page section transitions */ .settings-section-enter { animation: settingsSectionIn 0.28s cubic-bezier(0.16, 1, 0.3, 1); diff --git a/crates/agent-gateway/web/src/lib/chat/composerText.ts b/crates/agent-gateway/web/src/lib/chat/composerText.ts new file mode 100644 index 000000000..88e21c40e --- /dev/null +++ b/crates/agent-gateway/web/src/lib/chat/composerText.ts @@ -0,0 +1,34 @@ +/** + * Canonical line-ending model for user-authored composer text. + * + * CRLF and bare CR are transport/platform spellings of one logical line + * break. Internally the frontends keep that break as LF without trimming or + * otherwise changing whitespace. + */ +export function normalizeLogicalLineEndings(value: string) { + return value.replace(/\r\n?/g, "\n"); +} + +/** Escape plaintext for execCommand("insertHTML") without translating LF. */ +export function plainTextToContentEditableHtml(value: string) { + return normalizeLogicalLineEndings(value) + .replace(/&/g, "&") + .replace(//g, ">"); +} + +/** + * Insert canonical plaintext as one undoable browser editing operation. + * Literal LF stays in text nodes under the composer's `white-space: pre-wrap` + * rule, avoiding browser-generated DIV/BR structures that are ambiguous to + * serialize. The insertText fallback remains for engines without insertHTML; + * the serializer also handles their block DOM without duplicating blank lines. + */ +export function insertPlainTextWithUndo(value: string) { + const normalized = normalizeLogicalLineEndings(value); + if (document.execCommand("insertHTML", false, plainTextToContentEditableHtml(normalized))) { + return normalized; + } + document.execCommand("insertText", false, normalized); + return normalized; +} diff --git a/crates/agent-gateway/web/src/lib/chat/uploadedFiles.ts b/crates/agent-gateway/web/src/lib/chat/uploadedFiles.ts index a972873da..d394c5545 100644 --- a/crates/agent-gateway/web/src/lib/chat/uploadedFiles.ts +++ b/crates/agent-gateway/web/src/lib/chat/uploadedFiles.ts @@ -1,5 +1,6 @@ import type { Message, UserMessage } from "../agentTypes"; import { createUuid } from "../shared/id"; +import { normalizeLogicalLineEndings } from "./composerText"; export type UploadedReadableFileKind = | "text" @@ -125,12 +126,12 @@ export function buildUploadedFilesInstruction(files: PendingUploadedFile[]) { } export function buildUserMessageContentWithUploads(userText: string, files: PendingUploadedFile[]) { - const normalizedText = userText.trim(); + const normalizedText = normalizeLogicalLineEndings(userText); if (files.length === 0) return normalizedText; const instruction = buildUploadedFilesInstruction(files); if (!instruction) return normalizedText; - if (!normalizedText) { + if (!normalizedText.trim()) { return `Please inspect the selected files first.\n\n${instruction}`; } return `${normalizedText}\n\n${instruction}`; @@ -151,7 +152,7 @@ export function createUserMessageWithUploads( timestamp, }; if (files.length > 0) { - message[DISPLAY_CONTENT_FIELD] = userText.trim(); + message[DISPLAY_CONTENT_FIELD] = normalizeLogicalLineEndings(userText); message[ATTACHMENTS_FIELD] = clonePendingUploadedFiles(files); } return message; diff --git a/crates/agent-gateway/web/src/lib/chat/userMessageContent.tsx b/crates/agent-gateway/web/src/lib/chat/userMessageContent.tsx index 6380c6a6d..644939e48 100644 --- a/crates/agent-gateway/web/src/lib/chat/userMessageContent.tsx +++ b/crates/agent-gateway/web/src/lib/chat/userMessageContent.tsx @@ -14,6 +14,7 @@ import { createPortal } from "react-dom"; import { getFileTypeIcon } from "../../components/chat/fileTypeIcons"; import { SkillIcon } from "../../components/icons"; import { useLocale } from "../../i18n"; +import { normalizeLogicalLineEndings } from "./composerText"; import { type CodeMentionReference, @@ -947,7 +948,11 @@ export const UserMessageContent = memo(function UserMessageContent({ pastedTextFiles?: PendingUploadedFile[]; loadCommitDetails?: CommitDetailsLoader; }) { - const parts = useMemo(() => tokenizeUserMessage(text, pastedTextFiles), [text, pastedTextFiles]); + const normalizedText = useMemo(() => normalizeLogicalLineEndings(text), [text]); + const parts = useMemo( + () => tokenizeUserMessage(normalizedText, pastedTextFiles), + [normalizedText, pastedTextFiles], + ); const hasChip = parts.some( (part) => part.type === "mention" || @@ -957,7 +962,17 @@ export const UserMessageContent = memo(function UserMessageContent({ part.type === "codeRef" || part.type === "pastedText", ); - if (!hasChip) return <>{text}; + const trailingNewlineAnchor = normalizedText.endsWith("\n") ? ( +