From 36c4d7a95d5a53b56a46f91b8b9793173555d19f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Nov 2026 21:39:32 +0000 Subject: [PATCH 1/5] test(web): reproduce #29611, a dash rule under a paragraph turns it into a heading The composer serializes a paragraph followed by a `---` rule with a single newline, which Markdown reads as a setext heading underline. This test reproduces pingdotgg/t3code#29611 and fails on main. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/composer-rich-text-doc.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 77383df61e8f7..28a06654165bd 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -587,6 +587,26 @@ describe("composer rich text document model", () => { }); }); + describe("a dash rule after a paragraph", () => { + const paragraph = (text: string) => ({ type: "paragraph", content: [{ type: "text", text }] }); + const rule = (source: string) => ({ type: "horizontalRule", attrs: { source } }); + + it("is written after a blank line, or Markdown reads the paragraph as a heading", () => { + const doc = ProseMirrorNode.fromJSON(schema, { + type: "doc", + content: [ + paragraph("Please review the clamp helper changes"), + rule("---"), + paragraph("Then run the tests"), + ], + }); + doc.check(); + expect(serializeEditorDoc(doc).value).toBe( + "Please review the clamp helper changes\n\n---\nThen run the tests", + ); + }); + }); + it.each([ "---", "***", From 712994b69216a7e637029cc5956e2402f358bd14 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Nov 2026 21:44:38 +0000 Subject: [PATCH 2/5] fix(web): a rule typed under a line stays a rule when sent The composer wrote a paragraph followed by a `---` rule as `line\n---`, which Markdown reads as a setext heading, so the line became a heading and the rule disappeared. Write a blank line between a non-empty paragraph and a dash rule, the way a paragraph after a quote is already separated. Only all-dash sources can be setext underlines; `***`, `___` and `- - -` already interrupt a paragraph and keep their single newline. Fixes pingdotgg/t3code#29611 Co-Authored-By: Claude Opus 5.5 --- apps/web/src/composer-rich-text-doc.test.ts | 29 +++++++++++++++++++-- apps/web/src/composer-rich-text-doc.ts | 25 +++++++++++++----- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 28a06654165bd..f7b55c5e9a2eb 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -590,6 +590,11 @@ describe("composer rich text document model", () => { describe("a dash rule after a paragraph", () => { const paragraph = (text: string) => ({ type: "paragraph", content: [{ type: "text", text }] }); const rule = (source: string) => ({ type: "horizontalRule", attrs: { source } }); + const serialize = (...content: object[]) => { + const doc = ProseMirrorNode.fromJSON(schema, { type: "doc", content }); + doc.check(); + return { doc, map: serializeEditorDoc(doc) }; + }; it("is written after a blank line, or Markdown reads the paragraph as a heading", () => { const doc = ProseMirrorNode.fromJSON(schema, { @@ -605,6 +610,25 @@ describe("composer rich text document model", () => { "Please review the clamp helper changes\n\n---\nThen run the tests", ); }); + + it("settles on the blank line when a draft glued it to the paragraph", () => { + const settled = "text\n\n---\nmore"; + expect(roundTrip("text\n---\nmore").value).toBe(settled); + expect(roundTrip(settled).value).toBe(settled); + }); + + it("keeps every offset mapped through the blank line", () => { + const { doc, map } = serialize(paragraph("text"), rule("---"), paragraph("more")); + expect(map.value).toBe("text\n\n---\nmore"); + for (let flat = 0; flat <= map.docLength; flat += 1) { + expect(markdownToFlat(map, flatToMarkdown(map, flat))).toBe(flat); + } + // The blank line is not a place to type: it clamps to the rule. + expect(flatToMarkdown(map, "text".length)).toBe("text".length); + expect(markdownToFlat(map, "text\n".length)).toBe("text".length + 1); + expect(flatToMarkdown(map, "text".length + 1)).toBe("text\n\n---".length); + expect(flatToPm(map, "text".length + 1)).toBe(doc.child(0).nodeSize + doc.child(1).nodeSize); + }); }); it.each([ @@ -615,7 +639,8 @@ describe("composer rich text document model", () => { "* * *", "-----", "--- ", - "text\n---\nmore", + "text\n***\nmore", + "text\n- - -\nmore", "- item\n---\n- item two", "```\n---\n```", "--", @@ -661,7 +686,7 @@ describe("composer rich text document model", () => { it.each([ ["---", "---"], - ["a\n---", "a\n---"], + ["a\n---", "a\n\n---"], ["---\n", "---"], ])("gives the caret a line after the final rule of %j, written %j", (value, expected) => { const map = roundTrip(value); diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index ae0c9441625b4..d46a12f5c09d9 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -1004,14 +1004,26 @@ 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 { +function horizontalRuleSource(block: ProseMirrorNode): string { + const source = (block.attrs as Record).source; + return typeof source === "string" && source ? source : "---"; +} + +// A blank line keeps prose out of a quote (lazy continuation) and a dash rule from +// underlining the line above into a setext heading; an empty paragraph already separates them. +function needsBlankLineBetween(previous: ProseMirrorNode, block: ProseMirrorNode): boolean { + if (previous.type.name === "blockquote") { + return block.type.name === "paragraph" && block.content.size > 0; + } return ( - previous.type.name === "blockquote" && block.type.name === "paragraph" && block.content.size > 0 + block.type.name === "horizontalRule" && + previous.type.name === "paragraph" && + previous.content.size > 0 && + /^-+[ \t]*\r?$/.test(horizontalRuleSource(block)) ); } -// Blank-line runs own no document characters; offsets clamp to the paragraph start. +// Blank-line runs own no document characters; offsets clamp to the start of the next block. function pushBlankLineRun(acc: RichAccumulator, pmPos: number): void { acc.runs.push({ kind: "prefix", @@ -1454,7 +1466,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 && needsBlankLineBetween(blocks[blockIndex - 1]!, block)) { pushBlankLineRun(acc, pmBlockStart + 1); } if (LIST_NODE_NAMES.has(block.type.name)) { @@ -1482,8 +1494,7 @@ function serializeDoc(doc: ProseMirrorNode): RichDocMap { acc.md += prefix.length; appendInlineRuns(block, pmBlockStart + 1, acc); } else if (block.type.name === "horizontalRule") { - const attrs = block.attrs as Record; - const source = typeof attrs.source === "string" && attrs.source ? attrs.source : "---"; + const source = horizontalRuleSource(block); acc.runs.push({ kind: "prefix", flatStart: acc.flat, From bfeed85a446ca5758050ade1eb44fe081b608ca9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Nov 2026 21:58:10 +0000 Subject: [PATCH 3/5] fix(web): restored drafts keep the caret after a normalized rule A draft saved before the blank-line fix (`text\n---\nmore`, or a quote glued to the next line) loads into a document that serializes one newline longer, so the cursor offset into the stored value landed one character early. Map the offset past the inserted blank lines before converting it, both when the controlled value is applied and in focusAt. Co-Authored-By: Claude Opus 5.5 --- .../components/ComposerPromptEditorTiptap.tsx | 13 ++++++++++-- apps/web/src/composer-rich-text-doc.test.ts | 13 ++++++++++++ apps/web/src/composer-rich-text-doc.ts | 20 +++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 3df1e47613370..66080e5ba2e81 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -83,6 +83,7 @@ import { markdownToFlat, pmToFlat, serializeEditorDoc, + serializedMarkdownOffset, serializeSelection, skillChipReplacements, splitOrLiftListItem, @@ -1563,7 +1564,10 @@ function ComposerPromptEditorTiptapInner( ); } const map = serializeEditorDoc(editor.state.doc); - const flat = markdownToFlat(map, normalizedExpandedCursor); + const flat = markdownToFlat( + map, + serializedMarkdownOffset(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 = serializedMarkdownOffset( + 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-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index f7b55c5e9a2eb..19fd02beb5e89 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -30,6 +30,7 @@ import { markdownToFlat, pmToFlat, serializeEditorDoc, + serializedMarkdownOffset, serializeSelection, skillChipReplacements, stepCaretAcrossStyledEdge, @@ -631,6 +632,18 @@ describe("composer rich text document model", () => { }); }); + it.each([ + ["text\n---\nmore", "text\n---\nm".length, "text\n\n---\nm".length], + ["before\n> quoted\nafter", "before\n> quoted\na".length, "before\n> quoted\n\na".length], + ["a\n---\n", "a".length, "a".length], + ])("restores a caret in the glued draft %j on the same character", (value, caret, settled) => { + const map = roundTrip(value); + const restore = (offset: number) => + markdownToFlat(map, serializedMarkdownOffset(value, map.value, offset)); + expect(restore(value.length)).toBe(map.docLength); + expect(restore(caret)).toBe(markdownToFlat(map, settled)); + }); + it.each([ "---", "***", diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index d46a12f5c09d9..0e70e817b961f 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -1039,6 +1039,26 @@ function pushBlankLineRun(acc: RichAccumulator, pmPos: number): void { acc.md += 1; } +// Moves an offset into a parsed `value` past the blank lines its serialization inserted. +// Any other difference keeps the rest of the offset one-to-one, as if unmapped. +export function serializedMarkdownOffset( + value: string, + serialized: string, + offset: number, +): number { + let index = 0; + let mapped = 0; + while (index < offset && index < value.length) { + if (value[index] === serialized[mapped]) { + index += 1; + } else if (serialized[mapped] !== "\n") { + break; + } + mapped += 1; + } + return mapped + (offset - index); +} + function appendInlineRuns( container: ProseMirrorNode, contentStart: number, From 474b859f81fb0ffdf34667b3d9e76980b926a3fd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Nov 2026 22:07:29 +0000 Subject: [PATCH 4/5] fix(web): restored caret follows separators after rewritten markers serializedMarkdownOffset stopped at the first difference that was not an inserted newline, so a draft like `__x__\n---\nmore` (serialized as `**x**\n\n---\nmore`) lost the separator after it and put the caret one character early. Count other differences as same-length rewrites and keep walking. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/composer-rich-text-doc.test.ts | 1 + apps/web/src/composer-rich-text-doc.ts | 10 +++------- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 19fd02beb5e89..31788b476639a 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -635,6 +635,7 @@ describe("composer rich text document model", () => { it.each([ ["text\n---\nmore", "text\n---\nm".length, "text\n\n---\nm".length], ["before\n> quoted\nafter", "before\n> quoted\na".length, "before\n> quoted\n\na".length], + ["__x__\n---\nmore", "__x__\n---\nm".length, "**x**\n\n---\nm".length], ["a\n---\n", "a".length, "a".length], ])("restores a caret in the glued draft %j on the same character", (value, caret, settled) => { const map = roundTrip(value); diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index 0e70e817b961f..c0059872e9697 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -1040,7 +1040,7 @@ function pushBlankLineRun(acc: RichAccumulator, pmPos: number): void { } // Moves an offset into a parsed `value` past the blank lines its serialization inserted. -// Any other difference keeps the rest of the offset one-to-one, as if unmapped. +// Any other difference, such as `__` written as `**`, counts as a same-length rewrite. export function serializedMarkdownOffset( value: string, serialized: string, @@ -1048,12 +1048,8 @@ export function serializedMarkdownOffset( ): number { let index = 0; let mapped = 0; - while (index < offset && index < value.length) { - if (value[index] === serialized[mapped]) { - index += 1; - } else if (serialized[mapped] !== "\n") { - break; - } + while (index < offset && index < value.length && mapped < serialized.length) { + if (value[index] === serialized[mapped] || serialized[mapped] !== "\n") index += 1; mapped += 1; } return mapped + (offset - index); From e7594cd0c10ac609b031f6f47b048b63a09b54ed Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Nov 2026 22:19:49 +0000 Subject: [PATCH 5/5] fix(web): caret remap falls back when a draft does not align The restored-caret remap walked greedily and could count newlines as inserted separators after an earlier rewrite shortened the text (for example `*a*_b_` serialized as `*ab*`), pushing the caret past where it was. Remap only when the stored value and its serialization align end to end as same-length rewrites plus inserted newlines; otherwise keep the offset as main did. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/composer-rich-text-doc.test.ts | 5 +++++ apps/web/src/composer-rich-text-doc.ts | 13 +++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 31788b476639a..62ce06bd86bff 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -636,6 +636,11 @@ describe("composer rich text document model", () => { ["text\n---\nmore", "text\n---\nm".length, "text\n\n---\nm".length], ["before\n> quoted\nafter", "before\n> quoted\na".length, "before\n> quoted\n\na".length], ["__x__\n---\nmore", "__x__\n---\nm".length, "**x**\n\n---\nm".length], + [ + "*a*_b_\n> q\nx\n> q\nabcde", + "*a*_b_\n> q\nx\n> q\na".length, + "*a*_b_\n> q\nx\n> q\na".length, + ], ["a\n---\n", "a".length, "a".length], ])("restores a caret in the glued draft %j on the same character", (value, caret, settled) => { const map = roundTrip(value); diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index c0059872e9697..72f883af3f4a1 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -1039,20 +1039,25 @@ function pushBlankLineRun(acc: RichAccumulator, pmPos: number): void { acc.md += 1; } -// Moves an offset into a parsed `value` past the blank lines its serialization inserted. -// Any other difference, such as `__` written as `**`, counts as a same-length rewrite. +// Moves an offset into a parsed `value` past the blank lines its serialization inserted, +// treating other differences (`__` written as `**`) as same-length rewrites. When the two +// strings do not align that way end to end, the offset is returned unmapped. export function serializedMarkdownOffset( value: string, serialized: string, offset: number, ): number { + if (value === serialized) return offset; let index = 0; let mapped = 0; - while (index < offset && index < value.length && mapped < serialized.length) { + let target = -1; + while (index < value.length && mapped < serialized.length) { + if (index === offset && target < 0) target = mapped; if (value[index] === serialized[mapped] || serialized[mapped] !== "\n") index += 1; mapped += 1; } - return mapped + (offset - index); + if (index !== value.length || mapped !== serialized.length) return offset; + return target < 0 ? mapped + (offset - index) : target; } function appendInlineRuns(