From 45de1098bf3e8a63d2a3f4f9c91110b4c4ac8abc Mon Sep 17 00:00:00 2001 From: Soorria Saruva Date: Mon, 2 Nov 2026 13:39:42 +1100 Subject: [PATCH 1/3] fix(web): text after a composer list no longer joins its last item Co-authored-by: Claude Opus 5.5 --- apps/web/src/composer-rich-text-doc.test.ts | 14 ++++++++++++-- apps/web/src/composer-rich-text-doc.ts | 13 +++++++------ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 77383df61e8f7..5a44586855dbe 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -303,7 +303,7 @@ describe("composer rich text document model", () => { "- [ ] parent\n - [ ] child\n - [ ] sibling\n- [ ] uncle", "- [ ] empty task follows\n- [ ]", "- [ ] **bold** task with @README.md", - "para\n- [ ] task\npara", + "para\n- [ ] task\n\npara", "- [ ]No space stays literal", "-[ ] also literal", "@README.md explain this", @@ -487,7 +487,7 @@ describe("composer rich text document model", () => { "- **bold** item with @README.md", "- [ ] task\n- bullet after", "- bullet\n- [x] task after", - "para\n- item\npara", + "- item\n indented continuation", "\t- tab indented\n\t- again", " - leading indent only", "- item\n\n- after a blank", @@ -587,6 +587,16 @@ describe("composer rich text document model", () => { }); }); + it.each([ + ["para\n- item\npara", "para\n- item\n\npara"], + ["1. one\n2. two\nafter", "1. one\n2. two\n\nafter"], + ["- [ ] task\nafter", "- [ ] task\n\nafter"], + ["- a\n - b\nafter", "- a\n - b\n\nafter"], + ])("separates %j from the list, or Markdown folds it into the last item", (glued, settled) => { + expect(roundTrip(glued).value).toBe(settled); + expect(roundTrip(settled).value).toBe(settled); + }); + it.each([ "---", "***", diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index ae0c9441625b4..34d478c3fb08a 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -1004,11 +1004,12 @@ function pushBreakRun(acc: RichAccumulator, position?: number): void { acc.md += 1; } -// Separate prose from quotes to avoid lazy continuation; an empty paragraph already separates them. -function needsBlankLineAfterQuote(previous: ProseMirrorNode, block: ProseMirrorNode): boolean { - return ( - previous.type.name === "blockquote" && block.type.name === "paragraph" && block.content.size > 0 - ); +// Separate prose from quotes and lists to avoid lazy continuation; an empty paragraph already +// separates them. An indented line after a list continues the item on purpose. +function needsBlankLineBeforeParagraph(previous: ProseMirrorNode, block: ProseMirrorNode): boolean { + if (block.type.name !== "paragraph" || block.content.size === 0) return false; + if (previous.type.name === "blockquote") return true; + return LIST_NODE_NAMES.has(previous.type.name) && !/^[ \t]/.test(block.textContent); } // Blank-line runs own no document characters; offsets clamp to the paragraph start. @@ -1454,7 +1455,7 @@ function serializeDoc(doc: ProseMirrorNode): RichDocMap { let pmBlockStart = 0; blocks.forEach((block, blockIndex) => { if (blockIndex > 0 && !isTrailingLineAfterRule(doc, blockIndex)) pushBreakRun(acc); - if (blockIndex > 0 && needsBlankLineAfterQuote(blocks[blockIndex - 1]!, block)) { + if (blockIndex > 0 && needsBlankLineBeforeParagraph(blocks[blockIndex - 1]!, block)) { pushBlankLineRun(acc, pmBlockStart + 1); } if (LIST_NODE_NAMES.has(block.type.name)) { From 41c4b1fb2e3032b561f0bd77174b00abfe606372 Mon Sep 17 00:00:00 2001 From: Soorria Saruva Date: Mon, 2 Nov 2026 17:28:20 +1100 Subject: [PATCH 2/3] fix(web): composer drafts reload without an extra line after lists The blank line written between a list or quote and the paragraph after it came back as an empty paragraph when a draft reloaded. The parser now drops it. The leading-space check looks at the first node, so a paragraph that starts with a chip still gets the blank line. When loading adds blank lines, the caret moves past them. Co-authored-by: Claude Opus 5.5 --- ...rPromptEditorTiptap.input.browser.test.tsx | 12 ++++ .../components/ComposerPromptEditorTiptap.tsx | 13 +++- .../src/composer-list-split.browser.test.ts | 4 +- apps/web/src/composer-rich-text-doc.test.ts | 9 ++- apps/web/src/composer-rich-text-doc.ts | 71 ++++++++++++++++--- 5 files changed, 94 insertions(+), 15 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..ffbb6b4a004f4 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -239,6 +239,18 @@ it("keeps the caret inside unknown dollar text across rich and plain remounts", expect(serializeEditorDoc(editor.state.doc).value).toBe("echo $HOXYME now"); }); +it.each([ + ["- item\npara", "- item\n\nparaX"], + ["> quote\npara", "> quote\n\nparaX"], +])("keeps the caret at the end of %j when loading adds a blank line", async (value, typed) => { + await using screen = await mountEditor(value); + const { editor } = screen; + await act(async () => { + editor.commands.insertContent("X"); + }); + expect(serializeEditorDoc(editor.state.doc).value).toBe(typed); +}); + const file = { type: "file", id: "file-1", diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 3df1e47613370..a5c5098b2240f 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -84,6 +84,7 @@ import { pmToFlat, serializeEditorDoc, serializeSelection, + shiftPastAddedBlankLines, skillChipReplacements, splitOrLiftListItem, stepCaretAcrossStyledEdge, @@ -1563,7 +1564,10 @@ function ComposerPromptEditorTiptapInner( ); } const map = serializeEditorDoc(editor.state.doc); - const flat = markdownToFlat(map, normalizedExpandedCursor); + const flat = markdownToFlat( + map, + shiftPastAddedBlankLines(value, map.value, normalizedExpandedCursor), + ); editor.commands.setTextSelection(flatToPm(map, flat)); if (isFocused) scrollTiptapCaretIntoView(editor); if (pendingCitation) { @@ -1634,7 +1638,12 @@ function ComposerPromptEditorTiptapInner( const expandedCursor = exactExpandedCursor ?? expandEditorCursor(snapshotRef.current.value, boundedCursor); const map = serializeEditorDoc(editor.state.doc); - editor.commands.setTextSelection(flatToPm(map, markdownToFlat(map, expandedCursor))); + const markdownCursor = shiftPastAddedBlankLines( + snapshotRef.current.value, + map.value, + expandedCursor, + ); + editor.commands.setTextSelection(flatToPm(map, markdownToFlat(map, markdownCursor))); scrollTiptapCaretIntoView(editor); if (boundedCursor === snapshotRef.current.cursor) return; snapshotRef.current = { diff --git a/apps/web/src/composer-list-split.browser.test.ts b/apps/web/src/composer-list-split.browser.test.ts index 632bb1ff4d3ef..aa430633bd595 100644 --- a/apps/web/src/composer-list-split.browser.test.ts +++ b/apps/web/src/composer-list-split.browser.test.ts @@ -60,7 +60,7 @@ function storedDraft(editor: Editor) { describe("Backspace at the start of a line", () => { it.each([ - ["- a\n```\nl1\nl2\n```", "- a\nl1\nl2"], + ["- a\n```\nl1\nl2\n```", "- a\n\nl1\nl2"], ["p\n```ts\nl1\n```", "p\nl1"], ])("removes the fence of %j rather than joining its code up", (value, expected) => { using editor = makeEditor(value); @@ -95,6 +95,6 @@ describe("blocks that a list item or quote cannot hold", () => { editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); const lines = buildTiptapContent("one\ntwo", (name) => ({ label: name, description: null })); editor.commands.insertContent(lines); - expect(storedDraft(editor)).toBe("- a\none\ntwo"); + expect(storedDraft(editor)).toBe("- a\n\none\ntwo"); }); }); diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 5a44586855dbe..f73380017c7e4 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -592,9 +592,16 @@ describe("composer rich text document model", () => { ["1. one\n2. two\nafter", "1. one\n2. two\n\nafter"], ["- [ ] task\nafter", "- [ ] task\n\nafter"], ["- a\n - b\nafter", "- a\n - b\n\nafter"], - ])("separates %j from the list, or Markdown folds it into the last item", (glued, settled) => { + ["- item\n@README.md please", "- item\n\n@README.md please"], + ["> quoted\nafter", "> quoted\n\nafter"], + ])("separates %j from the block above, which Markdown would fold it into", (glued, settled) => { expect(roundTrip(glued).value).toBe(settled); expect(roundTrip(settled).value).toBe(settled); + const blockTypes = (value: string) => + buildDocJson(value, (name) => ({ label: name, description: null })).content.map( + (block) => block.type, + ); + expect(blockTypes(settled)).toEqual(blockTypes(glued)); }); it.each([ diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index 34d478c3fb08a..7976b883abd94 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -770,7 +770,7 @@ export function buildTiptapContent( | { rule: string } | { heading: { level: number; space: string; inline: InlineJson[] } } | { quote: { prefix: string; inline: InlineJson[] } } - | { line: DocLine } + | { line: DocLine; source: string } )[] = []; const restoreSources = (line: string) => line.split(sentinel).reduce((joined, piece, index) => { @@ -784,6 +784,7 @@ export function buildTiptapContent( if (index === 0 && literal) { entries.push({ line: { list: null, inline: [textJsonForSpan(literal, []), ...buildInline(line)] }, + source: literal + line, }); continue; } @@ -806,7 +807,7 @@ export function buildTiptapContent( } const quote = blockSyntax ? /^(>[ \t]*)([^\n]*)$/.exec(line) : null; if (quote) entries.push({ quote: { prefix: quote[1]!, inline: buildInline(quote[2]!) } }); - else entries.push({ line: buildDocLine(line) }); + else entries.push({ line: buildDocLine(line), source: line }); continue; } const language = restoreSources(opening.language); @@ -857,7 +858,23 @@ export function buildTiptapContent( }); openQuote = null; }; - for (const entry of entries) { + const lazyTarget = (entry: (typeof entries)[number] | undefined) => + !entry ? null : "quote" in entry ? "quote" : "line" in entry && entry.line.list ? "list" : null; + for (const [index, entry] of entries.entries()) { + const next = entries[index + 1]; + if ( + "line" in entry && + entry.source === "" && + next && + "line" in next && + !next.line.list && + needsBlankLineBetween(lazyTarget(entries[index - 1]), { + empty: next.source === "", + indented: /^[ \t]/.test(next.source), + }) + ) { + continue; + } if ("quote" in entry) { flushLists(); if (openQuote && openQuote.prefix !== entry.quote.prefix) flushQuote(); @@ -1004,12 +1021,37 @@ function pushBreakRun(acc: RichAccumulator, position?: number): void { acc.md += 1; } -// Separate prose from quotes and lists to avoid lazy continuation; an empty paragraph already -// separates them. An indented line after a list continues the item on purpose. -function needsBlankLineBeforeParagraph(previous: ProseMirrorNode, block: ProseMirrorNode): boolean { - if (block.type.name !== "paragraph" || block.content.size === 0) return false; - if (previous.type.name === "blockquote") return true; - return LIST_NODE_NAMES.has(previous.type.name) && !/^[ \t]/.test(block.textContent); +// Markdown folds a paragraph line into the quote or list right above it, so the serializer writes +// a blank line between them and the parser drops it. An empty paragraph is that line already, and +// an indented line after a list continues the item on purpose. +function needsBlankLineBetween( + previous: "quote" | "list" | null, + paragraph: { empty: boolean; indented: boolean }, +): boolean { + if (!previous || paragraph.empty) return false; + return previous === "quote" || !paragraph.indented; +} + +function lazyContinuationTarget(block: ProseMirrorNode): "quote" | "list" | null { + if (block.type.name === "blockquote") return "quote"; + return LIST_NODE_NAMES.has(block.type.name) ? "list" : null; +} + +// Loading a draft can add the blank line Markdown needs after a quote or list. Moves an offset in +// `value` to the same text in `serialized`. +export function shiftPastAddedBlankLines( + value: string, + serialized: string, + offset: number, +): number { + if (value === serialized) return offset; + let shift = 0; + for (let index = 0; index < offset; index += 1) { + while (value[index] !== serialized[index + shift] && serialized[index + shift] === "\n") { + shift += 1; + } + } + return Math.min(serialized.length, offset + shift); } // Blank-line runs own no document characters; offsets clamp to the paragraph start. @@ -1455,7 +1497,16 @@ function serializeDoc(doc: ProseMirrorNode): RichDocMap { let pmBlockStart = 0; blocks.forEach((block, blockIndex) => { if (blockIndex > 0 && !isTrailingLineAfterRule(doc, blockIndex)) pushBreakRun(acc); - if (blockIndex > 0 && needsBlankLineBeforeParagraph(blocks[blockIndex - 1]!, block)) { + const first = block.firstChild; + if ( + blockIndex > 0 && + block.type.name === "paragraph" && + needsBlankLineBetween(lazyContinuationTarget(blocks[blockIndex - 1]!), { + empty: !first, + // Checks the first node, not textContent, which skips a leading chip. + indented: Boolean(first?.isText && !first.marks.length && /^[ \t]/.test(first.text!)), + }) + ) { pushBlankLineRun(acc, pmBlockStart + 1); } if (LIST_NODE_NAMES.has(block.type.name)) { From c381acbf6ff5d18ca10ea6158f4970a61893647c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 5 Nov 2026 04:46:24 +0000 Subject: [PATCH 3/3] fix(web): a CRLF blank line after a list or quote is kept as the separator --- apps/web/src/composer-rich-text-doc.test.ts | 3 +++ apps/web/src/composer-rich-text-doc.ts | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index f73380017c7e4..a631f08ead080 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -700,6 +700,9 @@ describe("composer rich text document model", () => { "paragraph", ]); expect(roundTrip(value).value).toBe(value); + for (const spaced of ["- item\r\n\r\npara", "> quote\r\n\r\npara"]) { + expect(roundTrip(spaced).value).toBe(spaced); + } }); it("keeps the caret inside an empty fence", () => { diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index 8455117a70aab..c704fd711f84d 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -1525,7 +1525,8 @@ function serializeDoc(doc: ProseMirrorNode): RichDocMap { blockIndex > 0 && block.type.name === "paragraph" && needsBlankLineBetween(lazyContinuationTarget(blocks[blockIndex - 1]!), { - empty: !first, + // A CRLF blank line keeps its "\r". + empty: !first || (block.childCount === 1 && first.text === "\r"), // Checks the first node, not textContent, which skips a leading chip. indented: Boolean(first?.isText && !first.marks.length && /^[ \t]/.test(first.text!)), })