From 5b505f528236c4ce88212efb48ddcec810011aac Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 6 Nov 2026 06:07:17 +0000 Subject: [PATCH 1/4] test(web): reproduce empty line reappearing after a quote on reload (#31837) A draft with a paragraph after a quote is saved as "> quoted\n\ncomment"; parsing it back adds an empty paragraph the user never typed. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/composer-rich-text-doc.test.ts | 9 +++++++++ 1 file changed, 9 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..f1cc55e5c9499 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -559,6 +559,15 @@ describe("composer rich text document model", () => { ); }); + it("reopens without an empty line between the quote and the comment", () => { + const { doc, map } = serialize(quote("quoted"), paragraph("Comment to it")); + const reopened = ProseMirrorNode.fromJSON( + schema, + buildDocJson(map.value, (name) => ({ label: name, description: null })), + ); + expect(reopened.toJSON()).toEqual(doc.toJSON()); + }); + it("settles on the blank line when a draft glued it to the quote", () => { const settled = "before\n> quoted\n\nafter"; expect(roundTrip("before\n> quoted\nafter").value).toBe(settled); From d991ef61bc0111571ec325bce4585caa167e172d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 6 Nov 2026 06:13:09 +0000 Subject: [PATCH 2/4] fix(web): reopened drafts keep quote spacing and show the caret A paragraph after a quote is saved with one blank separator line, and parsing the draft back turned that line into an empty paragraph, so an empty line reappeared on every remount. The parser now absorbs exactly that one separator before plain prose; extra empty lines survive. An unfocused composer remounting with a long draft showed its top while the caret sat at the end. On first mount the editor now scrolls its own box to the caret, without focusing or scrolling any ancestor. Fixes pingdotgg/t3code#31837 Co-Authored-By: Claude Opus 5.5 --- ...poserPromptEditorTiptap.input.browser.test.tsx | 15 +++++++++++++++ .../src/components/ComposerPromptEditorTiptap.tsx | 10 ++++++++++ apps/web/src/composer-rich-text-doc.ts | 11 ++++++++++- 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 925319680d40d..98b0ce9f1de56 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -351,6 +351,21 @@ async function focusAt(editor: Editor, position: number | "end") { vi.runOnlyPendingTimers(); } +it("reopens a long draft scrolled to its caret without taking focus", async () => { + // App CSS is not loaded here; give the editor its own scroll box. + const style = document.head.appendChild(document.createElement("style")); + style.textContent = ".composer-tiptap { max-height: 120px; overflow-y: auto; }"; + using _style = { [Symbol.dispose]: () => style.remove() }; + const value = Array.from({ length: 40 }, (_, line) => `line ${line + 1}`).join("\n"); + await using screen = await mountEditor(value); + const { editor } = screen; + const scroller = editor.view.dom; + expect(document.activeElement).not.toBe(scroller); + expect(scroller.scrollTop).toBeGreaterThan(0); + const caret = editor.view.coordsAtPos(editor.state.selection.head); + expect(caret.bottom).toBeLessThanOrEqual(scroller.getBoundingClientRect().bottom + 1); +}); + describe("Markdown markers around styled text", () => { it.each([ { keys: "{Backspace}", expected: "**** after" }, diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 8c4698210b1d5..cb3c43b5a765a 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1527,6 +1527,7 @@ function ComposerPromptEditorTiptapInner( selectMarkdownOffset(editor, normalizedExpandedCursor); const map = serializeEditorDoc(editor.state.doc); if (isFocused) scrollTiptapCaretIntoView(editor); + else if (initialSelection) revealCaretInEditor(editor); if (pendingCitation) { citationRequestRef.current = null; const target = map.runs.find( @@ -1828,6 +1829,15 @@ function scrollTiptapCaretIntoView(editor: TiptapEditor): void { editor.view.dispatch(editor.state.tr.scrollIntoView()); } +/** Scrolls only the editor's own box: a mounting composer must not move the timeline or page. */ +function revealCaretInEditor(editor: TiptapEditor): void { + const scroller = editor.view.dom; + const caret = editor.view.coordsAtPos(editor.state.selection.head); + const box = scroller.getBoundingClientRect(); + if (caret.bottom > box.bottom) scroller.scrollTop += caret.bottom - box.bottom; + else if (caret.top < box.top) scroller.scrollTop -= box.top - caret.top; +} + function caretLineRect(range: Range, edge: "start" | "end"): DOMRect | null { const collapsedRects = Array.from(range.getClientRects()).filter((rect) => rect.height > 0); const collapsedRect = edge === "start" ? collapsedRects.at(-1) : collapsedRects[0]; diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index 95975bf285745..067f37abe7ea3 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -879,7 +879,12 @@ export function buildTiptapContent( }); openQuote = null; }; - for (const entry of entries) { + const isProseLine = (entry: (typeof entries)[number] | undefined, empty: boolean) => + entry !== undefined && + "line" in entry && + !entry.line.list && + (entry.line.inline.length === 0) === empty; + for (const [entryIndex, entry] of entries.entries()) { if ("quote" in entry) { flushLists(); if (openQuote && openQuote.prefix !== entry.quote.prefix) flushQuote(); @@ -887,7 +892,11 @@ export function buildTiptapContent( openQuote.content.push(entry.quote.inline); continue; } + // The serializer separates a quote from the prose after it with one blank line; it is not an empty paragraph. + const quoteSeparator = + openQuote !== null && isProseLine(entry, true) && isProseLine(entries[entryIndex + 1], false); flushQuote(); + if (quoteSeparator) continue; if ("code" in entry) { flushLists(); blocks.push(entry.code); From 1e1c72d2cbc2659a088d08ab23c9809a309d44a6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 6 Nov 2026 07:24:53 +0000 Subject: [PATCH 3/4] fix(web): reveal the caret after the mount redraw settles The editor's DOM is redrawn later in the same commit, which drops the scroll offset set during the initial controlled sync. Reveal the caret on the next frame instead. Co-Authored-By: Claude Opus 5.5 --- .../ComposerPromptEditorTiptap.input.browser.test.tsx | 2 +- apps/web/src/components/ComposerPromptEditorTiptap.tsx | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 98b0ce9f1de56..2ac28cfb1d8f3 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -360,8 +360,8 @@ it("reopens a long draft scrolled to its caret without taking focus", async () = await using screen = await mountEditor(value); const { editor } = screen; const scroller = editor.view.dom; + await vi.waitFor(() => expect(scroller.scrollTop).toBeGreaterThan(0)); expect(document.activeElement).not.toBe(scroller); - expect(scroller.scrollTop).toBeGreaterThan(0); const caret = editor.view.coordsAtPos(editor.state.selection.head); expect(caret.bottom).toBeLessThanOrEqual(scroller.getBoundingClientRect().bottom + 1); }); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index cb3c43b5a765a..08727e832df2d 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1527,7 +1527,12 @@ function ComposerPromptEditorTiptapInner( selectMarkdownOffset(editor, normalizedExpandedCursor); const map = serializeEditorDoc(editor.state.doc); if (isFocused) scrollTiptapCaretIntoView(editor); - else if (initialSelection) revealCaretInEditor(editor); + // A redraw later in this commit drops the editor's scroll offset, so reveal on the next frame. + else if (initialSelection) { + window.requestAnimationFrame(() => { + if (!editor.isDestroyed) revealCaretInEditor(editor); + }); + } if (pendingCitation) { citationRequestRef.current = null; const target = map.runs.find( From 15f365f81a74a3e73d0ecbf6b7cf2f28fd32214e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 6 Nov 2026 08:29:22 +0000 Subject: [PATCH 4/4] fix(web): keep the revealed caret clear of reserved bottom padding Mobile pending-answer actions sit over the composer's bottom padding, so reveal the caret against the padded content edge, not the border edge. Co-Authored-By: Claude Opus 5.5 --- .../ComposerPromptEditorTiptap.input.browser.test.tsx | 6 ++++-- apps/web/src/components/ComposerPromptEditorTiptap.tsx | 8 ++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 2ac28cfb1d8f3..465481f7b0260 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -354,7 +354,9 @@ async function focusAt(editor: Editor, position: number | "end") { it("reopens a long draft scrolled to its caret without taking focus", async () => { // App CSS is not loaded here; give the editor its own scroll box. const style = document.head.appendChild(document.createElement("style")); - style.textContent = ".composer-tiptap { max-height: 120px; overflow-y: auto; }"; + // The bottom padding stands in for space reserved under overlaid controls. + style.textContent = + ".composer-tiptap { max-height: 120px; overflow-y: auto; padding-bottom: 48px; }"; using _style = { [Symbol.dispose]: () => style.remove() }; const value = Array.from({ length: 40 }, (_, line) => `line ${line + 1}`).join("\n"); await using screen = await mountEditor(value); @@ -363,7 +365,7 @@ it("reopens a long draft scrolled to its caret without taking focus", async () = await vi.waitFor(() => expect(scroller.scrollTop).toBeGreaterThan(0)); expect(document.activeElement).not.toBe(scroller); const caret = editor.view.coordsAtPos(editor.state.selection.head); - expect(caret.bottom).toBeLessThanOrEqual(scroller.getBoundingClientRect().bottom + 1); + expect(caret.bottom).toBeLessThanOrEqual(scroller.getBoundingClientRect().bottom - 48 + 1); }); describe("Markdown markers around styled text", () => { diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 08727e832df2d..7820df72b4f4c 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1839,8 +1839,12 @@ function revealCaretInEditor(editor: TiptapEditor): void { const scroller = editor.view.dom; const caret = editor.view.coordsAtPos(editor.state.selection.head); const box = scroller.getBoundingClientRect(); - if (caret.bottom > box.bottom) scroller.scrollTop += caret.bottom - box.bottom; - else if (caret.top < box.top) scroller.scrollTop -= box.top - caret.top; + // Padding can be reserved for controls drawn over the box, such as mobile pending-answer actions. + const style = getComputedStyle(scroller); + const bottom = box.bottom - parseFloat(style.paddingBottom); + const top = box.top + parseFloat(style.paddingTop); + if (caret.bottom > bottom) scroller.scrollTop += caret.bottom - bottom; + else if (caret.top < top) scroller.scrollTop -= top - caret.top; } function caretLineRect(range: Range, edge: "start" | "end"): DOMRect | null {