From e7b8feb43d4e5625497d3ebe0247ac7b8c3e4929 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:44:48 +1000 Subject: [PATCH 1/2] fix(web): renumber following list items when Enter inserts an item in the composer Co-Authored-By: Claude Sonnet 5.5 --- apps/web/src/components/chat/ChatComposer.tsx | 11 +-- .../src/composer-list-continuation.test.ts | 35 ++++++++ apps/web/src/composer-list-continuation.ts | 89 +++++++++++++++++-- 3 files changed, 120 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d168c6db4e5f..870658a007be 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -4051,14 +4051,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : listIndentForTab(snapshot.value, selection.start, selection.end); if ( edit && - applyPromptReplacement( - edit.start, - edit.end, - edit.replacement, - key === "Tab" - ? { expandedCursorAfterReplace: selection.start + edit.replacement.length } - : undefined, - ) + applyPromptReplacement(edit.start, edit.end, edit.replacement, { + expandedCursorAfterReplace: edit.cursorAfter, + }) ) { return true; } diff --git a/apps/web/src/composer-list-continuation.test.ts b/apps/web/src/composer-list-continuation.test.ts index b92514dd2714..656e9177fcff 100644 --- a/apps/web/src/composer-list-continuation.test.ts +++ b/apps/web/src/composer-list-continuation.test.ts @@ -66,3 +66,38 @@ describe("composer list continuation", () => { expect(listIndentForTab("- foo", 1, 3)).toBeNull(); }); }); + +describe("composer list renumbering", () => { + const enterAt = (value: string, cursor: number) => { + const edit = listContinuationForEnter(value, cursor)!; + return applyEdit(value, edit); + }; + + it("renumbers following items when an item is inserted mid-list", () => { + const value = "1. one\n2. two\n3. three"; + // Enter at the start of "two" text pushes it down to a new item 3. + expect(enterAt(value, value.indexOf("two"))).toBe("1. one\n2. \n3. two\n4. three"); + expect(enterAt(value, 6)).toBe("1. one\n2. \n3. two\n4. three"); + }); + + it("puts the caret after the new marker when renumbering", () => { + const value = "1. one\n2. two"; + const edit = listContinuationForEnter(value, 6)!; + expect(edit.cursorAfter).toBe(6 + "\n2. ".length); + }); + + it("leaves items that are already in sequence and other content alone", () => { + expect(enterAt("1. a\n\n5. b", 4)).toBe("1. a\n2. \n\n5. b"); + expect(enterAt("1. a\ntext\n2. b", 4)).toBe("1. a\n2. \ntext\n2. b"); + expect(enterAt("1. a\n1) b", 4)).toBe("1. a\n2. \n1) b"); + }); + + it("renumbers past nested children but not past shallower lines", () => { + expect(enterAt("1. a\n - x\n2. b", 4)).toBe("1. a\n2. \n - x\n3. b"); + expect(enterAt(" 1. a\n2. b", 6)).toBe(" 1. a\n 2. \n2. b"); + }); + + it("keeps zero padding when renumbering", () => { + expect(enterAt("01. a\n02. b", 5)).toBe("01. a\n02. \n03. b"); + }); +}); diff --git a/apps/web/src/composer-list-continuation.ts b/apps/web/src/composer-list-continuation.ts index 952dfa549475..6434e5869a07 100644 --- a/apps/web/src/composer-list-continuation.ts +++ b/apps/web/src/composer-list-continuation.ts @@ -15,6 +15,8 @@ export interface ComposerListEdit { start: number; end: number; replacement: string; + /** Expanded caret offset after the edit is applied. */ + cursorAfter: number; } type ListMarker = @@ -69,6 +71,59 @@ function nextMarkerText(marker: ListMarker): string { return `${marker.indent}${marker.bullet} `; } +function formatOrderedNumber(value: number, numberText: string): string { + return Number.isSafeInteger(value) ? String(value).padStart(numberText.length, "0") : numberText; +} + +/** + * Renumber the ordered items that follow `afterLineEnd` so a newly inserted + * item does not leave duplicates below it. Walks the contiguous run of + * same-indent, same-delimiter items (skipping deeper-indented children) and + * stops at the first line that is not part of that run. Returns the end + * offset of the last renumbered marker and the replacement for the text + * between `afterLineEnd` and that offset. + */ +function renumberFollowingItems( + value: string, + afterLineEnd: number, + marker: Extract, + firstNumber: number, +): { end: number; text: string } | null { + let expected = firstNumber; + let position = afterLineEnd; + let end = -1; + let text = ""; + let copiedUntil = afterLineEnd; + while (position < value.length) { + const lineStart = position + 1; + const nextBreak = value.indexOf("\n", lineStart); + const lineEnd = nextBreak === -1 ? value.length : nextBreak; + const line = value.slice(lineStart, lineEnd); + const parsed = parseListMarker(line); + const lineIndent = line.match(/^[ \t]*/)?.[0] ?? ""; + if ( + parsed?.marker.kind === "ordered" && + parsed.marker.indent === marker.indent && + parsed.marker.delimiter === marker.delimiter + ) { + const current = parsed.marker; + const numberStart = lineStart + current.indent.length; + const numberEnd = numberStart + current.numberText.length; + const replacement = formatOrderedNumber(expected, current.numberText); + if (replacement !== current.numberText) { + text += value.slice(copiedUntil, numberStart) + replacement; + copiedUntil = numberEnd; + end = numberEnd; + } + expected += 1; + } else if (line.trim() === "" || lineIndent.length <= marker.indent.length) { + break; + } + position = lineEnd; + } + return end === -1 ? null : { end, text }; +} + function segmentSource( segment: ReturnType[number], ): string { @@ -109,13 +164,33 @@ export function listContinuationForEnter(value: string, cursor: number): Compose if (isInsideInlineToken(value, cursor)) return null; if (value.slice(markerEnd, line.end).trim() === "") { // Empty item: Enter exits the list by removing the marker. - return { start: line.start, end: Math.max(cursor, markerEnd), replacement: "" }; + return { + start: line.start, + end: Math.max(cursor, markerEnd), + replacement: "", + cursorAfter: line.start, + }; + } + const insertion = `\n${nextMarkerText(parsed.marker)}`; + const cursorAfter = cursor + insertion.length; + const { marker } = parsed; + if (marker.kind === "ordered") { + const number = Number.parseInt(marker.numberText, 10); + const renumbered = Number.isSafeInteger(number) + ? renumberFollowingItems(value, line.end, marker, number + 2) + : null; + if (renumbered) { + // The caret splits the current line, so its remainder is re-emitted + // after the new marker, followed by the renumbered lines below. + return { + start: cursor, + end: renumbered.end, + replacement: insertion + value.slice(cursor, line.end) + renumbered.text, + cursorAfter, + }; + } } - return { - start: cursor, - end: cursor, - replacement: `\n${nextMarkerText(parsed.marker)}`, - }; + return { start: cursor, end: cursor, replacement: insertion, cursorAfter }; } /** @@ -132,5 +207,5 @@ export function listIndentForTab( const line = currentLine(value, start); if (!parseListMarker(line.text)) return null; if (isInsideInlineToken(value, start)) return null; - return { start: line.start, end: line.start, replacement: " " }; + return { start: line.start, end: line.start, replacement: " ", cursorAfter: start + 2 }; } From 3ba1166522f8963b85e91c9ee00c511ddae5ac2e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:42:25 +1000 Subject: [PATCH 2/2] fix(web): renumber by indent width, skip chip text, keep unpadded numbers unpadded Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/composer-list-continuation.test.ts | 20 +++++++++ apps/web/src/composer-list-continuation.ts | 43 ++++++++++++++----- 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/apps/web/src/composer-list-continuation.test.ts b/apps/web/src/composer-list-continuation.test.ts index 656e9177fcff..7a9acfa7732d 100644 --- a/apps/web/src/composer-list-continuation.test.ts +++ b/apps/web/src/composer-list-continuation.test.ts @@ -100,4 +100,24 @@ describe("composer list renumbering", () => { it("keeps zero padding when renumbering", () => { expect(enterAt("01. a\n02. b", 5)).toBe("01. a\n02. \n03. b"); }); + + it("does not add zero padding to wider unpadded numbers", () => { + expect(enterAt("1. a\n10. b", 4)).toBe("1. a\n2. \n3. b"); + }); + + it("compares indentation by width so tab-indented children are skipped", () => { + expect(enterAt(" 1. a\n\t- child\n 2. b", 6)).toBe(" 1. a\n 2. \n\t- child\n 3. b"); + }); + + it("leaves lines inside a multiline quoted mention alone", () => { + const value = '1. @"file\n2. name" \n2. b'; + expect(enterAt(value, 3)).toBe('1. \n2. @"file\n2. name" \n3. b'); + }); + + it("returns an expanded caret offset when a chip precedes the caret", () => { + const value = "1. @README.md go\n2. next"; + const edit = listContinuationForEnter(value, value.indexOf(" go"))!; + expect(applyEdit(value, edit)).toBe("1. @README.md\n2. go\n3. next"); + expect(edit.cursorAfter).toBe(value.indexOf(" go") + "\n2. ".length); + }); }); diff --git a/apps/web/src/composer-list-continuation.ts b/apps/web/src/composer-list-continuation.ts index 6434e5869a07..4cb05d490a9e 100644 --- a/apps/web/src/composer-list-continuation.ts +++ b/apps/web/src/composer-list-continuation.ts @@ -72,7 +72,18 @@ function nextMarkerText(marker: ListMarker): string { } function formatOrderedNumber(value: number, numberText: string): string { - return Number.isSafeInteger(value) ? String(value).padStart(numberText.length, "0") : numberText; + if (!Number.isSafeInteger(value)) return numberText; + // Keep the width only for explicitly zero-padded markers ("01."). + return numberText.startsWith("0") + ? String(value).padStart(numberText.length, "0") + : String(value); +} + +/** Visual indent width with tabs expanded to four-column stops, as in CommonMark. */ +function indentWidth(indent: string): number { + let width = 0; + for (const char of indent) width = char === "\t" ? width + 4 - (width % 4) : width + 1; + return width; } /** @@ -89,6 +100,8 @@ function renumberFollowingItems( marker: Extract, firstNumber: number, ): { end: number; text: string } | null { + const tokens = inlineTokenRanges(value); + const baseWidth = indentWidth(marker.indent); let expected = firstNumber; let position = afterLineEnd; let end = -1; @@ -98,12 +111,15 @@ function renumberFollowingItems( const lineStart = position + 1; const nextBreak = value.indexOf("\n", lineStart); const lineEnd = nextBreak === -1 ? value.length : nextBreak; + position = lineEnd; + // A line that starts inside a multiline chip is part of that chip's text. + if (tokens.some((token) => lineStart > token.start && lineStart < token.end)) continue; const line = value.slice(lineStart, lineEnd); const parsed = parseListMarker(line); - const lineIndent = line.match(/^[ \t]*/)?.[0] ?? ""; + const width = indentWidth(line.match(/^[ \t]*/)?.[0] ?? ""); if ( parsed?.marker.kind === "ordered" && - parsed.marker.indent === marker.indent && + width === baseWidth && parsed.marker.delimiter === marker.delimiter ) { const current = parsed.marker; @@ -116,10 +132,9 @@ function renumberFollowingItems( end = numberEnd; } expected += 1; - } else if (line.trim() === "" || lineIndent.length <= marker.indent.length) { + } else if (line.trim() === "" || width <= baseWidth) { break; } - position = lineEnd; } return end === -1 ? null : { end, text }; } @@ -131,15 +146,21 @@ function segmentSource( return segment.source; } -/** True when splitting at the caret would cut an inline chip in two. */ -function isInsideInlineToken(value: string, cursor: number): boolean { +/** Expanded offsets of every inline chip in the prompt. */ +function inlineTokenRanges(value: string): Array<{ start: number; end: number }> { + const ranges: Array<{ start: number; end: number }> = []; let offset = 0; for (const segment of splitPromptIntoComposerSegments(value)) { const end = offset + segmentSource(segment).length; - if (segment.type !== "text" && cursor > offset && cursor < end) return true; + if (segment.type !== "text") ranges.push({ start: offset, end }); offset = end; } - return false; + return ranges; +} + +/** True when splitting at the caret would cut an inline chip in two. */ +function isInsideInlineToken(value: string, cursor: number): boolean { + return inlineTokenRanges(value).some((range) => cursor > range.start && cursor < range.end); } function currentLine(value: string, cursor: number): { start: number; end: number; text: string } { @@ -150,8 +171,8 @@ function currentLine(value: string, cursor: number): { start: number; end: numbe } /** - * Enter on a list item line: continue the list, or exit it when the item is - * empty. Returns null for non-list lines, carets inside the marker, and + * Enter on a list item line: continue the list (renumbering the ordered items + * below), or exit it when the item is empty. Returns null for non-list lines, carets inside the marker, and * carets inside an inline chip — all fall through to a plain newline. */ export function listContinuationForEnter(value: string, cursor: number): ComposerListEdit | null {