From de70f4002a1975e03d70b5e9e6e0f68e5d1aecda Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Thu, 17 Sep 2026 12:55:09 +0100 Subject: [PATCH 01/43] feat(web): fenced code blocks in the rich text composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rich text composer could style words but not hold a code block, which is the one thing people paste into a prompt box all day. Fences stayed literal text because every block node was turned off. Fenced blocks are now real code block nodes. They keep their exact delimiters in attributes, so a fence round-trips byte-identically — tilde fences, long fences and a fence still being typed included — and fence offsets clamp to the edge of the code the way checkbox and style markers already do. Inside a fence Enter keeps the indent, Tab shifts whole lines, two trailing blank lines leave the block, and a paste lands verbatim. Code is highlighted with the same Shiki instance the chat view uses, per block and cached by content. The block reuses the chat view's code block markup, class names and language icon so a draft looks like the message it is about to become. Chat's wrap and copy buttons are left out: they act on text the reader cannot change, and here the text is the draft. --- apps/web/package.json | 1 + apps/web/src/components/ChatMarkdown.tsx | 2 +- .../components/ComposerPromptEditorTiptap.tsx | 107 +++++++- .../chat/ComposerCodeBlockNodeView.tsx | 46 ++++ .../composerCodeBlockHighlight.test.ts | 64 +++++ .../components/composerCodeBlockHighlight.ts | 232 ++++++++++++++++++ apps/web/src/composer-code-block.test.ts | 219 +++++++++++++++++ apps/web/src/composer-code-block.ts | 171 +++++++++++++ apps/web/src/composer-rich-text-doc.test.ts | 87 +++++++ apps/web/src/composer-rich-text-doc.ts | 168 ++++++++++++- apps/web/src/index.css | 36 ++- docs/internals/composer-editors.md | 28 +++ docs/user/composer.md | 18 ++ pnpm-lock.yaml | 3 + 14 files changed, 1159 insertions(+), 23 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx create mode 100644 apps/web/src/components/composerCodeBlockHighlight.test.ts create mode 100644 apps/web/src/components/composerCodeBlockHighlight.ts create mode 100644 apps/web/src/composer-code-block.test.ts create mode 100644 apps/web/src/composer-code-block.ts diff --git a/apps/web/package.json b/apps/web/package.json index 665cdbf71736..b9e51d28bee8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -33,6 +33,7 @@ "@tanstack/react-router": "^1.160.2", "@tiptap/core": "^3.31.3", "@tiptap/extension-code": "^3.31.3", + "@tiptap/extension-code-block": "^3.31.3", "@tiptap/extension-task-item": "^3.31.3", "@tiptap/extension-task-list": "^3.31.3", "@tiptap/pm": "^3.31.3", diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 4a1f616aed3b..97d38a9c224e 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -898,7 +898,7 @@ function MarkdownDetails({ * icon (redundant next to its own name) and fall back to the language text * when no specific icon exists or it fails to load. */ -function MarkdownCodeBlockTitleContent({ +export function MarkdownCodeBlockTitleContent({ fenceTitle, language, theme, diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 2a5fe2ba0588..5c4eb6b139fd 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -3,9 +3,9 @@ import { TaskList } from "@tiptap/extension-task-list"; import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { type Node as ProseMirrorNode } from "@tiptap/pm/model"; -import { splitBlockKeepMarks } from "@tiptap/pm/commands"; +import { exitCode, newlineInCode, splitBlockKeepMarks } from "@tiptap/pm/commands"; import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; -import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view"; import type { AssistantCitation, ComposerContextClipboardFragment, @@ -45,6 +45,7 @@ import { } from "~/composer-editor-mentions"; import { buildDocJson, + ComposerCodeBlockExtension, buildTiptapContent, collapsedToFlat, ComposerCodeExtension, @@ -56,7 +57,13 @@ import { serializeEditorDoc, type SkillMeta, } from "~/composer-rich-text-doc"; +import { + convertCodeFenceOnEnter, + indentCodeBlock, + indentedNewlineInCodeBlock, +} from "~/composer-code-block"; import { collectInlineContextIds } from "~/lib/composerContextReferences"; +import { resolveDiffThemeName } from "~/lib/diffRendering"; import { cn, isMacPlatform } from "~/lib/utils"; import { basenameOfPath } from "~/pierre-icons"; import { FileTagChipContent } from "./chat/FileTagChip"; @@ -74,6 +81,8 @@ import { import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView"; +import { composerCodeBlockHighlight } from "./composerCodeBlockHighlight"; import { importPastedComposerText } from "./composerInlineTokenPaste"; import { didComposerSelectionChangeVisibly } from "./composerSelection"; import type { ComposerDraftContextRecords } from "./composerContextPresentation"; @@ -482,6 +491,9 @@ function collectStyledRanges(doc: ProseMirrorNode): StyledRange[] { let range: StyledRange | null = null; let openLength = 0; for (const run of map.runs) { + // A fence is drawn as a block, not revealed a character at a time, so its + // delimiters never become marker widgets. + if (run.nodeName === "codeBlock") continue; if (run.openLen > 0) { range ??= { from: run.pmPos, to: run.pmPos, markers: [] }; range.markers.push({ @@ -509,6 +521,25 @@ function collectStyledRanges(doc: ProseMirrorNode): StyledRange[] { return ranges; } +/** Whether the caret sits inside a fenced code block. */ +function isInCodeBlock(view: EditorView): boolean { + return view.state.selection.$from.parent.type.spec.code === true; +} + +/** + * Two blank lines at the end of a fence leave it, the way every code editor + * does. Without this a fence at the end of the prompt is a trap: Enter only + * ever adds another line and there is no way back to prose. + */ +function exitCodeBlockOnTrailingBlankLines(view: EditorView): boolean { + const { $from, empty } = view.state.selection; + if (!empty || $from.parent.type.spec.code !== true) return false; + if ($from.parentOffset !== $from.parent.content.size) return false; + if (!$from.parent.textContent.endsWith("\n\n")) return false; + view.dispatch(view.state.tr.delete($from.pos - 2, $from.pos)); + return exitCode(view.state, (tr) => view.dispatch(tr.scrollIntoView())); +} + const MarkerPluginKey = new PluginKey("composer-rich-markers"); const ComposerMarkerPlugin = new Plugin({ @@ -732,16 +763,16 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { expandedCursor: nextExpandedCursor, contextIds: map.contextIds, }; - const cursorAdjacentToMention = + // A fence holds no chips, so nothing in it should summon the mention or + // command menu: `@` in code is a decorator, not a file. Suppressing the + // trigger here also keeps the store from inserting a link the block can + // only show as literal text, which the store would then count as a chip. + const inCodeBlock = updated.state.selection.$from.parent.type.spec.code === true; + const suppressTrigger = + inCodeBlock || isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); - onChangeRef.current( - nextValue, - nextCursor, - nextExpandedCursor, - cursorAdjacentToMention, - map.contextIds, - ); + onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, suppressTrigger, map.contextIds); }, []); const editorAttributes = useMemo( @@ -785,6 +816,17 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ...(richText ? [ ComposerCodeExtension, + ComposerCodeBlockExtension.extend({ + addNodeView() { + return ReactNodeViewRenderer(ComposerCodeBlockNodeView); + }, + }), + composerCodeBlockHighlight({ + resolveTheme: () => + resolveDiffThemeName( + document.documentElement.classList.contains("dark") ? "dark" : "light", + ), + }), TaskList, ComposerTaskItemExtension.extend({ addInputRules() { @@ -921,6 +963,43 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { event.preventDefault(); return true; } + // Inside a fence Tab belongs to the code, not to the composer's + // focus order or its autocomplete. + if (event.key === "Tab" && !event.metaKey && !event.ctrlKey && isInCodeBlock(view)) { + event.preventDefault(); + event.stopPropagation(); + return indentCodeBlock(view.state, event.shiftKey ? "out" : "in", (tr) => + view.dispatch(tr), + ); + } + // A fence is multi-line by definition, so Enter belongs to the code + // rather than to sending: inside a block it makes a line, and on a + // line that is only an opening fence it opens the block. Sending + // from inside a fence is still Cmd/Ctrl+Enter, which falls through. + if ( + event.key === "Enter" && + richText && + !event.shiftKey && + !event.metaKey && + !event.ctrlKey && + !event.isComposing + ) { + if (isInCodeBlock(view)) { + event.preventDefault(); + event.stopPropagation(); + const dispatch = (tr: typeof view.state.tr) => view.dispatch(tr.scrollIntoView()); + return ( + exitCodeBlockOnTrailingBlankLines(view) || + indentedNewlineInCodeBlock(view.state, dispatch) || + newlineInCode(view.state, dispatch) + ); + } + if (convertCodeFenceOnEnter(view.state, (tr) => view.dispatch(tr))) { + event.preventDefault(); + event.stopPropagation(); + return true; + } + } const handler = onCommandKeyDownRef.current; if (event.key === "Enter") { const instance = editorHolder.current; @@ -1000,6 +1079,14 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const pastedText = clipboardData.getData("text/plain"); if (!pastedText) return false; event.preventDefault(); + // A fence takes the clipboard verbatim. Running the markdown path + // here would split the block on newlines, nest a pasted fence and + // build chips the code block's schema cannot hold anyway. + if (isInCodeBlock(view)) { + const { from, to } = view.state.selection; + view.dispatch(view.state.tr.insertText(pastedText, from, to).scrollIntoView()); + return true; + } const importFragment = importFragmentRef.current; let text = importFragment ? importPastedComposerText(clipboardData, importFragment) diff --git a/apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx b/apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx new file mode 100644 index 000000000000..13ff3035c724 --- /dev/null +++ b/apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx @@ -0,0 +1,46 @@ +import { NodeViewContent, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; + +import { useTheme } from "../../hooks/useTheme"; +import { MarkdownCodeBlockTitleContent } from "../ChatMarkdown"; + +/** + * Draws a composer fence as the chat view draws a rendered one, down to the + * class names, so a draft looks like the message it is about to become. + * + * The chrome stops at the language icon. Chat's wrap and copy buttons act on + * text the reader cannot change; here the text is the draft, and both are + * already a selection away. + */ +export function ComposerCodeBlockNodeView({ node }: NodeViewProps) { + const { resolvedTheme } = useTheme(); + const declared = typeof node.attrs.language === "string" ? node.attrs.language.trim() : ""; + // An undeclared fence reads "text", the same fallback the chat view applies + // when a fence arrives without an info string. + const language = declared || "text"; + return ( + +
+ + + +
+
+
+           as="code" />
+        
+
+
+ ); +} diff --git a/apps/web/src/components/composerCodeBlockHighlight.test.ts b/apps/web/src/components/composerCodeBlockHighlight.test.ts new file mode 100644 index 000000000000..7d616166f6b8 --- /dev/null +++ b/apps/web/src/components/composerCodeBlockHighlight.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { DiffThemeName } from "~/lib/diffRendering"; + +import { tokenizeBlock } from "./composerCodeBlockHighlight"; + +const THEME = "github-dark" as DiffThemeName; + +/** + * Stands in for the Shiki highlighter so the offset arithmetic can be checked + * without loading a real grammar. Splits each line into whitespace-delimited + * tokens, which is enough shape for the flattening logic under test. + */ +function fakeHighlighter(colorFor: (content: string) => string | undefined) { + return { + codeToTokens(code: string) { + return { + tokens: code.split("\n").map((line) => + line + .split(/(\s+)/) + .filter((part) => part.length > 0) + .map((content) => ({ content, color: colorFor(content) })), + ), + }; + }, + } as unknown as Parameters[0]; +} + +describe("composer code block highlighting", () => { + it("maps token offsets to positions within the block text", () => { + const highlighter = fakeHighlighter((content) => (content === "const" ? "#ff0000" : undefined)); + + const decorations = tokenizeBlock(highlighter, "const answer = 42", "ts", THEME); + + expect(decorations).toEqual([{ from: 0, to: 5, color: "#ff0000" }]); + }); + + it("accounts for the newline between lines", () => { + const highlighter = fakeHighlighter((content) => (content === "two" ? "#00ff00" : undefined)); + + const decorations = tokenizeBlock(highlighter, "one\ntwo", "ts", THEME); + + // "one" is 3 characters, then the newline, so "two" starts at 4. + expect(decorations).toEqual([{ from: 4, to: 7, color: "#00ff00" }]); + }); + + it("skips whitespace-only tokens so indentation is not decorated", () => { + const highlighter = fakeHighlighter(() => "#0000ff"); + + const decorations = tokenizeBlock(highlighter, " indented", "ts", THEME); + + expect(decorations).toEqual([{ from: 2, to: 10, color: "#0000ff" }]); + }); + + it("falls back to plain text when the grammar throws", () => { + const throwing = { + codeToTokens() { + throw new Error("unsupported language"); + }, + } as unknown as Parameters[0]; + + expect(tokenizeBlock(throwing, "const answer = 42", "nope", THEME)).toEqual([]); + }); +}); diff --git a/apps/web/src/components/composerCodeBlockHighlight.ts b/apps/web/src/components/composerCodeBlockHighlight.ts new file mode 100644 index 000000000000..a61fd7ff1593 --- /dev/null +++ b/apps/web/src/components/composerCodeBlockHighlight.ts @@ -0,0 +1,232 @@ +import { Extension } from "@tiptap/core"; +import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; + +import type { DiffThemeName } from "~/lib/diffRendering"; +import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting"; + +interface HighlightedBlock { + /** Guards against repainting a block whose text and language are unchanged. */ + readonly signature: string; + readonly decorations: ReadonlyArray<{ from: number; to: number; color: string }>; +} + +export const composerCodeBlockHighlightKey = new PluginKey( + "composerCodeBlockHighlight", +); + +const MAX_CACHED_BLOCKS = 64; +/** + * Bounded so a long session cannot grow the set without limit, but far above + * the number of fences any composer prompt realistically holds. This set alone + * only raises the threshold at which repeated eviction would keep work looking + * pending; the unconditional loop breaker is the `scannedDoc` check below. + */ +const MAX_ATTEMPTED_SIGNATURES = 2048; + +function blockSignature(node: ProseMirrorNode, theme: DiffThemeName): string { + // A separator that cannot occur in a language name keeps the parts distinct. + return [theme, String(node.attrs.language ?? ""), node.textContent].join("\u0000"); +} + +function collectCodeBlocks(state: EditorState): Array<{ node: ProseMirrorNode; pos: number }> { + const blocks: Array<{ node: ProseMirrorNode; pos: number }> = []; + state.doc.descendants((node, pos) => { + if (node.type.name === "codeBlock") blocks.push({ node, pos }); + }); + return blocks; +} + +/** + * Highlights composer code blocks with the same Shiki instance the chat view + * uses, painted as inline decorations so the text nodes stay editable. + * + * Highlighting is asynchronous and per block: a keystroke re-tokenizes only the + * block that changed, and only after its highlighter has resolved. Blocks whose + * text and language are unchanged reuse their previous decorations. + */ +export function composerCodeBlockHighlight(options: { + resolveTheme: () => DiffThemeName; +}): Extension { + return Extension.create({ + name: "composerCodeBlockHighlight", + + addProseMirrorPlugins() { + // Every keystroke in a code block mints a new signature, so the cache is + // bounded and evicts oldest-first rather than growing with the session. + const cache = new Map(); + // Which signatures have been tokenized, whether or not their decorations + // survived eviction. Without this, a document with more blocks than the + // cache holds would always report work as pending: each pass would evict + // the entries the previous pass added, and the repaint would never settle. + const attempted = new Set(); + const rememberAttempt = (signature: string) => { + attempted.add(signature); + // Generous next to the decoration cache so a large document still + // settles, but bounded so a long editing session cannot grow it without + // limit. Eviction here can only cost a re-tokenize, never a loop. + while (attempted.size > MAX_ATTEMPTED_SIGNATURES) { + const oldest = attempted.values().next(); + if (oldest.done) break; + attempted.delete(oldest.value); + } + }; + const remember = (signature: string, block: HighlightedBlock) => { + cache.set(signature, block); + while (cache.size > MAX_CACHED_BLOCKS) { + const oldest = cache.keys().next(); + if (oldest.done) break; + cache.delete(oldest.value); + } + }; + + return [ + new Plugin({ + key: composerCodeBlockHighlightKey, + + state: { + init: () => DecorationSet.empty, + apply(transaction, value, _oldState, newState) { + if (!transaction.docChanged && !transaction.getMeta(composerCodeBlockHighlightKey)) { + return value.map(transaction.mapping, transaction.doc); + } + return buildDecorations(newState, cache, options.resolveTheme()); + }, + }, + + props: { + decorations(state) { + return composerCodeBlockHighlightKey.getState(state); + }, + }, + + view(view) { + let disposed = false; + let paintedTheme = options.resolveTheme(); + let scannedDoc: ProseMirrorNode | null = null; + + /** + * Tokenizes any block missing from the cache, then repaints once. + * Returning early when nothing is pending is what stops the + * dispatch below from re-triggering this on its own update. + */ + const refresh = () => { + const theme = options.resolveTheme(); + const themeChanged = theme !== paintedTheme; + paintedTheme = theme; + // A selection change cannot alter what needs tokenizing, and + // building a signature means concatenating every block's text. + if (!themeChanged && view.state.doc === scannedDoc) return; + scannedDoc = view.state.doc; + const pending = collectCodeBlocks(view.state).filter( + ({ node }) => !attempted.has(blockSignature(node, theme)), + ); + if (pending.length === 0) { + // A theme switch keeps every signature but changes which one + // applies, so the painted decorations still have to be rebuilt. + if (themeChanged) { + view.dispatch(view.state.tr.setMeta(composerCodeBlockHighlightKey, true)); + } + return; + } + + void Promise.all( + pending.map(async ({ node }) => { + const language = String(node.attrs.language ?? "") || "text"; + const signature = blockSignature(node, theme); + const highlighter = await getSyntaxHighlighterPromise(language); + if (disposed) return; + rememberAttempt(signature); + if (cache.has(signature)) return; + remember(signature, { + signature, + decorations: tokenizeBlock(highlighter, node.textContent, language, theme), + }); + }), + ).then(() => { + if (disposed) return; + view.dispatch(view.state.tr.setMeta(composerCodeBlockHighlightKey, true)); + }); + }; + + refresh(); + // The theme lives on , outside the editor, so nothing would + // otherwise tell the view a repaint is due. + const themeObserver = new MutationObserver(refresh); + themeObserver.observe(document.documentElement, { + attributeFilter: ["class"], + }); + + return { + update: refresh, + destroy() { + disposed = true; + themeObserver.disconnect(); + }, + }; + }, + }), + ]; + }, + }); +} + +type Highlighter = Awaited>; + +/** Flattens Shiki's line/token structure into offsets within the block's text. */ +export function tokenizeBlock( + highlighter: Highlighter, + code: string, + language: string, + theme: DiffThemeName, +): ReadonlyArray<{ from: number; to: number; color: string }> { + let tokens; + try { + tokens = highlighter.codeToTokens(code, { lang: language, theme }).tokens; + } catch { + // An unsupported language should read as plain text, not break the editor. + return []; + } + + const decorations: Array<{ from: number; to: number; color: string }> = []; + let offset = 0; + for (const [lineIndex, line] of tokens.entries()) { + if (lineIndex > 0) offset += 1; // the newline between lines + for (const token of line) { + const length = token.content.length; + if (token.color && token.content.trim()) { + decorations.push({ from: offset, to: offset + length, color: token.color }); + } + offset += length; + } + } + return decorations; +} + +function buildDecorations( + state: EditorState, + cache: Map, + theme: DiffThemeName, +): DecorationSet { + const decorations: Decoration[] = []; + for (const { node, pos } of collectCodeBlocks(state)) { + const highlighted = cache.get(blockSignature(node, theme)); + if (!highlighted) continue; + // Token offsets index the block's text, which lines up with document + // positions only because `codeBlock` is `text*` with no marks — one + // unmarked text node, so `content.size === textContent.length`. Allowing + // marks or hard breaks in the code block would desync these silently. + if (node.content.size !== node.textContent.length) continue; + // +1 steps past the code block's opening token into its text. + const start = pos + 1; + for (const decoration of highlighted.decorations) { + decorations.push( + Decoration.inline(start + decoration.from, start + decoration.to, { + style: `color:${decoration.color}`, + }), + ); + } + } + return DecorationSet.create(state.doc, decorations); +} diff --git a/apps/web/src/composer-code-block.test.ts b/apps/web/src/composer-code-block.test.ts new file mode 100644 index 000000000000..b08f9eebf4ad --- /dev/null +++ b/apps/web/src/composer-code-block.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from "vite-plus/test"; +import { Editor } from "@tiptap/core"; +import { TextSelection } from "@tiptap/pm/state"; + +import { + CODE_BLOCK_INDENT, + indentCodeBlock, + indentLines, + convertCodeFenceOnEnter, + indentedNewlineInCodeBlock, + leadingWhitespace, + outdentLine, +} from "./composer-code-block"; +import StarterKit from "@tiptap/starter-kit"; + +import { ComposerCodeBlockExtension } from "./composer-rich-text-doc"; + +const extensions = [ + StarterKit.configure({ codeBlock: false, trailingNode: false }), + ComposerCodeBlockExtension, +]; + +describe("leadingWhitespace", () => { + it("reads the indent a new line should inherit", () => { + expect(leadingWhitespace(" nested()")).toBe(" "); + expect(leadingWhitespace("\tnested()")).toBe("\t"); + expect(leadingWhitespace("flush()")).toBe(""); + }); + + it("stops at the first non-space so code is never treated as indent", () => { + expect(leadingWhitespace(" a b")).toBe(" "); + }); +}); + +describe("outdentLine", () => { + it("removes a full indent when there is one", () => { + expect(outdentLine(" deep")).toBe(" deep"); + }); + + /** Hand-indented lines should still outdent rather than refuse to move. */ + it("falls back to a single space or tab", () => { + expect(outdentLine(" odd")).toBe("odd"); + expect(outdentLine("\ttabbed")).toBe("tabbed"); + }); + + it("bottoms out instead of eating code", () => { + expect(outdentLine("flush()")).toBe("flush()"); + }); +}); + +describe("indentLines", () => { + it("indents every line it is given", () => { + expect(indentLines(["a", "b"], "in")).toEqual([ + `${CODE_BLOCK_INDENT}a`, + `${CODE_BLOCK_INDENT}b`, + ]); + }); + + /** Indenting a blank line would leave whitespace the user cannot see. */ + it("leaves blank lines alone", () => { + expect(indentLines(["a", "", "b"], "in")).toEqual([ + `${CODE_BLOCK_INDENT}a`, + "", + `${CODE_BLOCK_INDENT}b`, + ]); + }); + + it("outdents every line it is given", () => { + expect(indentLines([" a", " b", "c"], "out")).toEqual([" a", "b", "c"]); + }); +}); + +/** Builds an editor holding a single fenced block, with the caret placed by offset. */ +function codeEditor(code: string, at?: { from: number; to?: number }) { + const editor = new Editor({ + extensions, + content: { + type: "doc", + content: [{ type: "codeBlock", content: [{ type: "text", text: code }] }], + }, + }); + const start = 1; + const from = start + (at?.from ?? code.length); + const to = start + (at?.to ?? at?.from ?? code.length); + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, from, to)), + ); + return editor; +} + +const textOf = (editor: Editor) => editor.state.doc.textContent; + +describe("indentedNewlineInCodeBlock", () => { + it("carries the current indentation onto the new line", () => { + const editor = codeEditor("function f() {\n body()"); + const handled = indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr)); + expect(handled).toBe(true); + expect(textOf(editor)).toBe("function f() {\n body()\n "); + }); + + /** + * With no indent to carry there is nothing to add, so the default newline + * should handle it and stay a single undo step. + */ + it("declines an unindented line", () => { + const editor = codeEditor("flush()"); + expect(indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); + expect(textOf(editor)).toBe("flush()"); + }); + + it("indents from the line the caret is on, not the last line", () => { + const editor = codeEditor(" deep()\nflush()", { from: 10 }); + indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr)); + expect(textOf(editor)).toBe(" deep()\n \nflush()"); + }); + + it("replaces the selected text rather than keeping it", () => { + const editor = codeEditor(" keep()DROP", { from: 8, to: 12 }); + indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr)); + expect(textOf(editor)).toBe(" keep()\n "); + }); + + it("does nothing outside a code block", () => { + const editor = new Editor({ + extensions, + content: { + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: " hi" }] }], + }, + }); + expect(indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); + }); +}); + +describe("indentCodeBlock", () => { + it("inserts an indent at a collapsed caret", () => { + const editor = codeEditor("ab", { from: 1 }); + expect(indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr))).toBe(true); + expect(textOf(editor)).toBe(`a${CODE_BLOCK_INDENT}b`); + }); + + it("indents every line a selection touches", () => { + const editor = codeEditor("one\ntwo\nthree", { from: 1, to: 9 }); + indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr)); + expect(textOf(editor)).toBe(" one\n two\n three"); + }); + + it("outdents every line a selection touches", () => { + const editor = codeEditor(" one\n two", { from: 2, to: 9 }); + indentCodeBlock(editor.state, "out", (tr) => editor.view.dispatch(tr)); + expect(textOf(editor)).toBe("one\ntwo"); + }); + + /** Repeated Tab presses should keep working on the same block. */ + it("keeps the selection across the shifted lines", () => { + const editor = codeEditor("one\ntwo", { from: 0, to: 7 }); + indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr)); + indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr)); + expect(textOf(editor)).toBe(" one\n two"); + }); + + it("reports handled when outdenting can go no further, so Tab never escapes the editor", () => { + const editor = codeEditor("one\ntwo", { from: 0, to: 7 }); + expect(indentCodeBlock(editor.state, "out", (tr) => editor.view.dispatch(tr))).toBe(true); + expect(textOf(editor)).toBe("one\ntwo"); + }); + + it("does nothing outside a code block", () => { + const editor = new Editor({ + extensions, + content: { + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: "hi" }] }], + }, + }); + expect(indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr))).toBe(false); + }); +}); + +describe("convertCodeFenceOnEnter", () => { + /** Builds an editor holding one paragraph with the caret at its end. */ + function paragraphEditor(text: string) { + const editor = new Editor({ + extensions, + content: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text }] }] }, + }); + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 1 + text.length)), + ); + return editor; + } + + it.each([ + ["```", "```", ""], + ["```ts", "```", "ts"], + ["~~~py", "~~~", "py"], + ["````", "````", ""], + ])("turns %s into a code block", (text, fence, language) => { + const editor = paragraphEditor(text); + expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(true); + const block = editor.state.doc.firstChild!; + expect(block.type.name).toBe("codeBlock"); + expect(block.attrs).toMatchObject({ fence, language, close: `\n${fence}` }); + expect(block.textContent).toBe(""); + // The caret belongs inside the new block, ready for the first line. + expect(editor.state.selection.from).toBe(1); + }); + + it.each(["``", "```ts extra", "text ```", "```ts trailing"])("leaves %s alone", (text) => { + const editor = paragraphEditor(text); + expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); + }); + + it("ignores a fence with the caret before its end", () => { + const editor = paragraphEditor("```ts"); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 3))); + expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); + }); +}); diff --git a/apps/web/src/composer-code-block.ts b/apps/web/src/composer-code-block.ts new file mode 100644 index 000000000000..23ae25a6c4d7 --- /dev/null +++ b/apps/web/src/composer-code-block.ts @@ -0,0 +1,171 @@ +import type { EditorState, Transaction } from "@tiptap/pm/state"; +import { TextSelection } from "@tiptap/pm/state"; + +/** + * Spaces rather than a tab: the fence round-trips through Markdown on its way + * to the agent, and a literal tab there renders at whatever width the reader + * happens to use. Two matches how this repo indents its own source. + */ +export const CODE_BLOCK_INDENT = " "; + +/** The leading run of spaces and tabs on a line, which Enter carries forward. */ +export function leadingWhitespace(line: string): string { + return /^[ \t]*/.exec(line)?.[0] ?? ""; +} + +/** + * Removes one indent's worth of leading whitespace, tolerating lines that were + * indented by hand with an odd number of spaces or with tabs. A line with no + * leading whitespace is returned unchanged, so outdenting bottoms out quietly + * rather than eating the first character of code. + */ +export function outdentLine(line: string): string { + if (line.startsWith(CODE_BLOCK_INDENT)) return line.slice(CODE_BLOCK_INDENT.length); + if (line.startsWith("\t")) return line.slice(1); + if (line.startsWith(" ")) return line.slice(1); + return line; +} + +/** Applies Tab/Shift+Tab to every line a selection touches, whole lines at a time. */ +export function indentLines(lines: ReadonlyArray, direction: "in" | "out"): string[] { + return lines.map((line) => { + // Indenting a blank line would leave trailing whitespace behind on a line + // the user cannot see the caret move on, so leave it be. + if (direction === "in") return line.length === 0 ? line : `${CODE_BLOCK_INDENT}${line}`; + return outdentLine(line); + }); +} + +/** + * Where the code block containing the selection starts, or null when the + * selection is outside a fence or spans out of one. Both ends must sit in the + * same block: a selection that reaches past the fence belongs to the document, + * not to the code. + */ +function codeBlockRange( + state: EditorState, +): { readonly from: number; readonly to: number; readonly text: string } | null { + const { $from, $to } = state.selection; + const parent = $from.parent; + if (parent.type.spec.code !== true) return null; + if (!$to.parent.eq(parent)) return null; + const from = $from.start(); + return { from, to: from + parent.content.size, text: parent.textContent }; +} + +/** + * Enter inside a fence, carrying the current line's indentation onto the new + * line. ProseMirror's default splits the text but starts the new line at column + * zero, which means re-typing the indent on every line of a nested block. + */ +export function indentedNewlineInCodeBlock( + state: EditorState, + dispatch?: (transaction: Transaction) => void, +): boolean { + const block = codeBlockRange(state); + if (!block) return false; + + const { from, to } = state.selection; + const beforeCursor = block.text.slice(0, from - block.from); + const currentLine = beforeCursor.slice(beforeCursor.lastIndexOf("\n") + 1); + const indent = leadingWhitespace(currentLine); + + // Nothing to carry, so let the default newline handle it and keep this path + // out of the undo history. + if (indent.length === 0) return false; + + if (dispatch) { + const inserted = `\n${indent}`; + const transaction = state.tr.insertText(inserted, from, to); + const caret = from + inserted.length; + transaction.setSelection(TextSelection.create(transaction.doc, caret)); + dispatch(transaction.scrollIntoView()); + } + return true; +} + +/** + * Tab and Shift+Tab inside a fence. A collapsed cursor indents at the caret so + * Tab works mid-line the way typing does; any selection spanning text indents + * the whole lines it touches, which is what makes re-indenting a block possible. + */ +export function indentCodeBlock( + state: EditorState, + direction: "in" | "out", + dispatch?: (transaction: Transaction) => void, +): boolean { + const block = codeBlockRange(state); + if (!block) return false; + + const { from, to } = state.selection; + const startOffset = from - block.from; + const endOffset = to - block.from; + + if (from === to && direction === "in") { + if (dispatch) { + const transaction = state.tr.insertText(CODE_BLOCK_INDENT, from, to); + dispatch(transaction.scrollIntoView()); + } + return true; + } + + // Grow the range to whole lines so indenting is stable no matter where in the + // first and last lines the selection happens to start and end. + const lineStart = block.text.lastIndexOf("\n", Math.max(0, startOffset - 1)) + 1; + const newlineAfter = block.text.indexOf("\n", endOffset); + const lineEnd = newlineAfter === -1 ? block.text.length : newlineAfter; + + const originalLines = block.text.slice(lineStart, lineEnd).split("\n"); + const nextLines = indentLines(originalLines, direction); + if (nextLines.join("\n") === originalLines.join("\n")) return true; + + if (dispatch) { + const transaction = state.tr.insertText( + nextLines.join("\n"), + block.from + lineStart, + block.from + lineEnd, + ); + // Keep the same lines selected after the shift, so repeated Tab presses + // keep indenting the block rather than collapsing the selection. + // `split` always yields at least one entry, so these stand in only for the + // compiler's benefit. + const firstDelta = (nextLines[0]?.length ?? 0) - (originalLines[0]?.length ?? 0); + const totalDelta = nextLines.join("\n").length - originalLines.join("\n").length; + const nextFrom = Math.max(block.from + lineStart, from + firstDelta); + const nextTo = Math.max(nextFrom, to + totalDelta); + transaction.setSelection(TextSelection.create(transaction.doc, nextFrom, nextTo)); + dispatch(transaction.scrollIntoView()); + } + return true; +} + +/** + * Enter on a line that is nothing but an opening fence turns it into a code + * block. Tiptap's own input rules only fire on a space, so without this the + * gesture every markdown editor has — ```` ```ts ```` then Enter — leaves the + * fence as literal text. + */ +export function convertCodeFenceOnEnter( + state: EditorState, + dispatch?: (transaction: Transaction) => void, +): boolean { + const { $from, empty } = state.selection; + if (!empty || $from.parent.type.name !== "paragraph") return false; + if ($from.parentOffset !== $from.parent.content.size) return false; + const match = /^(`{3,}|~{3,})([A-Za-z0-9_+#.-]*)$/.exec($from.parent.textContent); + const codeBlock = state.schema.nodes.codeBlock; + if (!match || !codeBlock) return false; + + if (dispatch) { + const blockStart = $from.before(); + const fence = match[1]!; + const transaction = state.tr.replaceWith( + blockStart, + blockStart + $from.parent.nodeSize, + codeBlock.create({ language: match[2] ?? "", fence, close: `\n${fence}` }), + ); + transaction.setSelection(TextSelection.create(transaction.doc, blockStart + 1)); + dispatch(transaction.scrollIntoView()); + } + return true; +} diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 8fa311b1999c..321bb8de50e6 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildDocJson, + ComposerCodeBlockExtension, collapsedToFlat, ComposerCodeExtension, ComposerTaskItemExtension, @@ -61,6 +62,7 @@ const schema = getSchemaByResolvedExtensions( }), TaskList, ComposerTaskItemExtension, + ComposerCodeBlockExtension, ]), ); @@ -341,6 +343,91 @@ describe("composer rich text document model", () => { expect(roundTripPlain(value).value).toBe(value); }); + it.each([ + "```\ncode\n```", + "```ts\nconst a = 1;\n```", + "```ts\nconst a = 1;\n```\n", + "before\n```ts\nconst a = 1;\n```\nafter", + "```\n```", + "```ts\nline one\nline two\nline three\n```", + "```ts\n indented\n deeper\n```", + "```js title=example\ncode\n```", + "~~~py\ncode\n~~~", + "````\n```\n````", + "```ts\ncode without a closing fence", + "```", + "```ts\n**not bold** and @README.md stay literal\n```", + "```ts\ncode\n````", + "- [ ] task\n```ts\ncode\n```\n- [ ] after", + " ```ts\nindented fence stays a paragraph\n ```", + ])("round-trips the fenced block %s", (value) => { + expect(roundTrip(value).value).toBe(value); + }); + + it.each(["```ts\nconst a = 1;\n```", "```\n```", "before\n```ts\ncode\n```\nafter"])( + "maps every document offset of %s through collapsed coordinates and back", + (value) => { + const map = roundTrip(value); + expect(map.value).toBe(value); + for (let flat = 0; flat <= map.docLength; flat += 1) { + expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); + } + }, + ); + + it("keeps the end of the code inside the fence rather than after it", () => { + const value = "```ts\nfunc();\n```"; + const map = roundTrip(value); + const endOfCode = "func();".length; + // Not the end of the string: the closing fence is a line of its own. + expect(flatToCollapsed(map, endOfCode)).toBe("```ts\nfunc();".length); + expect(flatToMarkdown(map, endOfCode)).toBe("```ts\nfunc();".length); + expect(collapsedToFlat(map, flatToCollapsed(map, endOfCode))).toBe(endOfCode); + }); + + it("keeps the caret inside an empty fence", () => { + const value = "before\n```\n```"; + const map = roundTrip(value); + const inside = "before\n".length; + expect(flatToCollapsed(map, inside)).toBe("before\n```".length); + expect(collapsedToFlat(map, flatToCollapsed(map, inside))).toBe(inside); + }); + + it("still places the end of an inline mark after its markers", () => { + // The fence rule must not leak into inline marks, whose trailing edge is + // deliberately the position after the closing delimiter. + const map = roundTrip("a **bold** c"); + expect(flatToMarkdown(map, 6)).toBe(10); + }); + + it("clamps offsets inside a fence to the edge of the code", () => { + const value = "```ts\nab\n```"; + const map = roundTrip(value); + expect(map.value).toBe(value); + // The opening fence owns no document characters, so every offset in it + // lands on the first character of the code. + for (let collapsed = 0; collapsed <= "```ts\n".length; collapsed += 1) { + expect(collapsedToFlat(map, collapsed)).toBe(0); + } + expect(collapsedToFlat(map, "```ts\na".length)).toBe(1); + // Everything from the closing newline onwards clamps to the code's end. + for (let collapsed = "```ts\nab".length; collapsed <= value.length; collapsed += 1) { + expect(collapsedToFlat(map, collapsed)).toBe(2); + } + }); + + it.each([ + ["```\n\n```", "```\n```"], + ["```\n", "```"], + ])("canonicalizes the empty fence %s", (value, expected) => { + expect(roundTrip(value).value).toBe(expected); + }); + + it("keeps fences literal in plain mode", () => { + const value = "```ts\nconst a = 1;\n```"; + expect(roundTripPlain(value).value).toBe(value); + }); + it("maps every document offset through collapsed coordinates and back", () => { const value = "hi **bold** @README.md bye"; 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 ed83c35b5f5b..13cf0e5edd4a 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -1,5 +1,6 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { Code } from "@tiptap/extension-code"; +import { CodeBlock } from "@tiptap/extension-code-block"; import { TaskItem } from "@tiptap/extension-task-item"; import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; @@ -65,6 +66,44 @@ export const ComposerTaskItemExtension = TaskItem.extend({ }, }).configure({ nested: true }); +/** + * Fenced code blocks keep their exact source delimiters so a fence round-trips + * byte-identically: `fence` is the opening run of backticks or tildes, + * `language` its info string, and `close` the closing newline and fence, or + * the empty string when the fence was never closed. + * + * Neither delimiter owns a document character, so the caret can never land + * inside a fence — the same deal as a checkbox marker. + * + * An empty block written with a blank line (```` ```\n\n``` ````) canonicalizes + * to the blank-free form, the same fixed-point deal as `__bold__` becoming + * `**bold**`. + */ +export const ComposerCodeBlockExtension = CodeBlock.extend({ + addAttributes() { + return { + ...this.parent?.(), + language: { default: "" }, + fence: { default: "```" }, + close: { default: "\n```" }, + }; + }, +}); + +/** The markdown a code block node serializes to, delimiters included. */ +export function codeBlockSource(node: ProseMirrorNode): { + open: string; + content: string; + close: string; +} { + const attrs = node.attrs as Record; + const fence = typeof attrs.fence === "string" && attrs.fence ? attrs.fence : "```"; + const language = typeof attrs.language === "string" ? attrs.language : ""; + const close = typeof attrs.close === "string" ? attrs.close : ""; + const content = node.textContent; + return { open: `${fence}${language}${content ? "\n" : ""}`, content, close }; +} + function randomNodeKey(): string { return `tiptap-${Math.random().toString(36).slice(2)}`; } @@ -92,6 +131,31 @@ function parseTaskPrefix(head: string): { prefix: TaskLinePrefix; markerLength: }; } +/** + * An opening fence: three or more backticks or tildes at the start of a line, + * followed by an info string. A backtick fence cannot carry a backtick in its + * info string, which is what keeps `` `code` `` on its own line literal. + * Indented fences stay paragraphs — the composer is a prompt box, not a + * CommonMark renderer, and honoring indentation would cost another attribute + * for no case anyone writes. + */ +function parseOpeningFence(line: string): { fence: string; language: string } | null { + const match = /^(`{3,}|~{3,})(.*)$/.exec(line); + if (!match) return null; + const fence = match[1]!; + const language = match[2]!; + if (fence.startsWith("`") && language.includes("`")) return null; + return { fence, language }; +} + +/** A closing fence matches the opening run's character and is at least as long. */ +function isClosingFence(line: string, fence: string): boolean { + const match = /^(`{3,}|~{3,})[ \t]*$/.exec(line); + if (!match) return false; + const run = match[1]!; + return run[0] === fence[0] && run.length >= fence.length; +} + type InlineJson = Record; interface DocLine { @@ -184,15 +248,19 @@ export function buildTiptapContent( sentinel = String.fromCodePoint(codePoint); } const atoms: InlineJson[] = []; + // Code fences hold no chips, so their lines put the original source back in + // place of the sentinel rather than building an atom for it. + const atomSources: string[] = []; const text = splitPromptIntoComposerSegments(value) .map((segment) => { if (segment.type === "text") return segment.text; atoms.push(atomJsonForSegment(segment, skillLabelFor)); + atomSources.push(segment.source); return sentinel; }) .join(""); let atomIndex = 0; - const lines: DocLine[] = text.split("\n").map((line) => { + const buildDocLine = (line: string): DocLine => { const parsed = styling ? parseTaskPrefix(line) : null; const content = parsed ? line.slice(parsed.markerLength) : line; const spans = styling ? parseInlineMarkdown(content) : [{ text: content, marks: [] }]; @@ -207,7 +275,51 @@ export function buildTiptapContent( }); } return { task: parsed?.prefix ?? null, inline }; - }); + }; + + // Pass 1: fenced blocks claim their lines whole; everything else becomes an + // inline-parsed line. Fence bodies restore chip sources as literal text. + const sourceLines = text.split("\n"); + const entries: ({ code: Record } | { line: DocLine })[] = []; + const restoreSources = (line: string) => + line.split(sentinel).reduce((joined, piece, index) => { + if (index === 0) return piece; + atomIndex += 1; + return joined + atomSources[atomIndex - 1]! + piece; + }, ""); + + for (let index = 0; index < sourceLines.length; index += 1) { + const line = sourceLines[index]!; + const opening = styling ? parseOpeningFence(line) : null; + if (!opening) { + entries.push({ line: buildDocLine(line) }); + continue; + } + const body: string[] = []; + let cursor = index + 1; + let close = ""; + while (cursor < sourceLines.length) { + const candidate = sourceLines[cursor]!; + if (isClosingFence(candidate, opening.fence)) { + close = `\n${candidate}`; + break; + } + body.push(restoreSources(candidate)); + cursor += 1; + } + // An unclosed fence runs to the end of the prompt, which is what the user + // is looking at while they are still typing the block. + const closed = cursor < sourceLines.length; + const content = body.join("\n"); + entries.push({ + code: { + type: "codeBlock", + attrs: { language: opening.language, fence: opening.fence, close }, + ...(content ? { content: [{ type: "text", text: content }] } : {}), + }, + }); + index = closed ? cursor : sourceLines.length; + } // Pass 2: consecutive task lines group into (possibly nested) task lists // by indent prefix; everything else stays a paragraph. @@ -219,7 +331,13 @@ export function buildTiptapContent( stack = []; } }; - for (const line of lines) { + for (const entry of entries) { + if ("code" in entry) { + flushTasks(); + blocks.push(entry.code); + continue; + } + const line = entry.line; if (!line.task) { flushTasks(); blocks.push({ type: "paragraph", content: line.inline }); @@ -542,6 +660,33 @@ function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumul }); } +/** + * A fence becomes one run whose open and close lengths are the delimiters, so + * every cursor rule that already clamps out of a style marker clamps out of a + * fence too. `nodeName` keeps the marker decorations off it: a code block is + * drawn as a block, not revealed a character at a time. + */ +function appendCodeBlockRun(block: ProseMirrorNode, pmPos: number, acc: RichAccumulator): void { + const { open, content, close } = codeBlockSource(block); + acc.runs.push({ + kind: "text", + flatStart: acc.flat, + docLen: content.length, + collapsedLen: open.length + content.length + close.length, + mdLen: open.length + content.length + close.length, + openLen: open.length, + closeLen: close.length, + pmPos, + mdStart: acc.md, + collapsedStart: acc.collapsed, + nodeName: "codeBlock", + }); + acc.value += open + content + close; + acc.flat += content.length; + acc.collapsed += open.length + content.length + close.length; + acc.md += open.length + content.length + close.length; +} + export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap { const acc: RichAccumulator = { runs: [], value: "", flat: 0, collapsed: 0, md: 0 }; const blocks: ProseMirrorNode[] = []; @@ -554,6 +699,8 @@ export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap { if (blockIndex > 0) pushBreakRun(acc); if (block.type.name === "taskList") { walkTaskList(block, pmBlockStart, acc); + } else if (block.type.name === "codeBlock") { + appendCodeBlockRun(block, pmBlockStart + 1, acc); } else if (block.type.name === "paragraph") { appendInlineRuns(block, pmBlockStart + 1, acc); } @@ -576,10 +723,21 @@ function lastRunEnd(map: RichDocMap, space: "collapsed" | "md"): number { : last.mdStart + last.mdLen; } +/** + * The end of a run belongs to the next run, which is how the end of `**bold**` + * lands after its markers. A fence is the exception: its close is a line of + * its own, so the end of the code stays inside the block rather than jumping + * past the closing fence. Inline marks keep their trailing position. + */ +function runOwnsOffset(run: RichRun, flatOffset: number): boolean { + const end = run.flatStart + run.docLen; + return flatOffset < end || (flatOffset === end && run.nodeName === "codeBlock"); +} + export function flatToCollapsed(map: RichDocMap, flatOffset: number): number { const bounded = Math.max(0, Math.min(flatOffset, map.docLength)); for (const run of map.runs) { - if (bounded < run.flatStart + run.docLen) { + if (runOwnsOffset(run, bounded)) { if (run.kind === "text" || run.kind === "token") { return run.collapsedStart + run.openLen + (bounded - run.flatStart); } @@ -592,7 +750,7 @@ export function flatToCollapsed(map: RichDocMap, flatOffset: number): number { export function flatToMarkdown(map: RichDocMap, flatOffset: number): number { const bounded = Math.max(0, Math.min(flatOffset, map.docLength)); for (const run of map.runs) { - if (bounded < run.flatStart + run.docLen) { + if (runOwnsOffset(run, bounded)) { if (run.kind === "text" || run.kind === "token") { return run.mdStart + run.openLen + (bounded - run.flatStart); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index d1ca0f4cf60e..4968b294a004 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1388,7 +1388,7 @@ html[data-theme-id] [data-chat-header] [data-slot="separator"] { /* Chat code blocks join the other code surfaces on the code tokens. The default look keeps its secondary/input tints; themed palettes tint those surfaces with the accent, which fights syntax highlighting. */ -html[data-theme-id] .chat-markdown .chat-markdown-codeblock { +html[data-theme-id] :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock { background-color: var(--code-background); border-color: var(--contrast-border); color: var(--code-foreground); @@ -1399,11 +1399,11 @@ html[data-theme-id] .chat-markdown pre { color: var(--code-foreground); } -html[data-theme-id] .chat-markdown .chat-markdown-codeblock pre { +html[data-theme-id] :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock pre { background-color: transparent; } -html[data-theme-id] .chat-markdown .chat-markdown-codeblock-header { +html[data-theme-id] :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock-header { color: color-mix(in srgb, var(--code-foreground) 72%, transparent); } @@ -1905,23 +1905,23 @@ code { background: color-mix(in srgb, var(--contrast-border) 78%, transparent); } -.chat-markdown .chat-markdown-codeblock-header { +:is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock-header { color: color-mix(in srgb, var(--contrast-foreground) 72%, transparent); } -.chat-markdown .chat-markdown-codeblock pre { +:is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock pre { margin: 0; border: none; border-radius: 0; background: transparent; } -.chat-markdown .chat-markdown-codeblock[data-wrap="true"] pre { +:is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock[data-wrap="true"] pre { white-space: pre-wrap; overflow-wrap: anywhere; } -.chat-markdown .chat-markdown-shiki .shiki { +:is(.chat-markdown, .composer-tiptap) .chat-markdown-shiki .shiki { background: transparent !important; } @@ -2158,6 +2158,28 @@ code { padding: 0.05em 0.25em; } +/* The fence node view reuses chat's code block markup and class names, so the + shared rules above carry the surface. What is left is what only an editable + fence needs: the code itself, and a caret that can sit on an empty line. */ +.composer-tiptap .chat-markdown-codeblock pre { + padding: 0.25rem 0.7rem 0.5rem; + max-width: 100%; + overflow-x: auto; +} + +.composer-tiptap .chat-markdown-codeblock pre code { + display: block; + min-height: 1lh; + border: none; + background: transparent; + padding: 0; + color: inherit; + font-family: var(--font-mono); + font-size: var(--font-size-code, 0.92em); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + .composer-rich-marker { color: var(--muted-foreground); opacity: 0.75; diff --git a/docs/internals/composer-editors.md b/docs/internals/composer-editors.md index 6ed0a8c2f7bf..4d9769367895 100644 --- a/docs/internals/composer-editors.md +++ b/docs/internals/composer-editors.md @@ -25,3 +25,31 @@ Clipboard text must come from the Markdown serializer, not DOM text: chip labels the source and marker decorations are not content. Structured context records accompany that text when available. Paste completes trailing chip delimiters and adds a leading boundary when inserting a chip directly after text. + +Fenced code blocks are real `codeBlock` nodes rather than literal text. They keep +their exact delimiters in attributes — `fence`, `language` and `close` — so a +fence round-trips byte-identically, including tilde fences, long fences and a +fence the user has not closed yet. Neither delimiter owns a document character, +so fence offsets clamp to the edge of the code the same way checkbox and style +markers do. The end of the code is the one place fences and inline marks differ: +the end of `**bold**` maps after its markers, but the end of a fence stays inside +the block, because its closing fence is a line of its own and "after it" would +move the caret to another line. The one normalization is an empty block written +with a blank line. + +Fence delimiters are not marker decorations: the block is drawn as a block. Fence +runs carry `nodeName: "codeBlock"` and the marker plugin skips them. The node view +reuses the chat view's code block markup and class names so a draft looks like the +message it becomes; the shared rules in `index.css` are widened to both surfaces +rather than restated. + +Enter reaches the fence before it reaches the send handler. Enter sends by +default, so checking the fence afterwards means a fence never opens and a +newline inside one sends the draft instead. Cmd/Ctrl+Enter still falls through +to send, which is the way out of a fence. + +Fence editing lives in [composer-code-block](../../apps/web/src/composer-code-block.ts): +Enter keeps the current indent, Tab shifts whole lines, and two trailing blank +lines exit the block, which is the only way out of a fence at the end of a prompt. +Highlighting is Shiki decorations over the editable text, per block and cached by +content, so a keystroke re-tokenizes only the block that changed. diff --git a/docs/user/composer.md b/docs/user/composer.md index 7ee8edcbb121..a4d01af46489 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -12,6 +12,24 @@ becomes an attachment when inserting it would exceed the message limit. On a hardware keyboard, use `Cmd+Shift+V` on Apple devices or `Ctrl+Shift+V` elsewhere to keep a large paste editable in the composer instead. +## Formatting + +The composer writes Markdown and shows it styled as you type. Bold, italic, +strikethrough and inline code are marked up as usual; the markers stay in the +text and appear beside the styled words when your cursor is on them, so nothing +you typed is ever hidden from you. Task lists come from `- [ ]` and get +checkboxes you can click. + +Start a fenced code block with three backticks, optionally followed by a +language, then press Enter or Space. Inside a fence: + +- **Enter** starts a new line at the current indentation. +- **Tab** and **Shift+Tab** indent and outdent the lines your selection touches. +- Two blank lines at the end leave the block and return to prose. + +Code is syntax highlighted using your current theme, and the language you +declared is named in the corner of the block. + ## Attach files Attach up to 100 files per message. Each image can be up to 10 MiB, with at most diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f579bfa8a5a8..f63b7ef0de12 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -645,6 +645,9 @@ importers: '@tiptap/extension-code': specifier: ^3.31.3 version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-code-block': + specifier: ^3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-task-item': specifier: ^3.31.3 version: 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) From a6a933ec2b3792fbc97525d6a67649f66337b028 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Thu, 17 Sep 2026 12:55:22 +0100 Subject: [PATCH 02/43] feat(web): toggle the composer between rich text and Markdown source Reading the Markdown a draft will actually send meant opening Settings. The composer controls now carry the switch directly. There is no second editor behind this: the control writes the same composerRichTextEnabled setting, which remounts the same engine with the mark extensions off, so the draft, its chips and the caret survive the flip. --- .../components/ComposerPromptEditorTiptap.tsx | 41 +++++++++++++++ apps/web/src/components/chat/ChatComposer.tsx | 20 ++++++++ .../components/chat/ComposerSourceToggle.tsx | 50 +++++++++++++++++++ apps/web/src/composer-rich-text-doc.test.ts | 31 ++++++++++++ docs/internals/composer-editors.md | 25 ++++++++++ docs/user/composer.md | 5 ++ 6 files changed, 172 insertions(+) create mode 100644 apps/web/src/components/chat/ComposerSourceToggle.tsx diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 5c4eb6b139fd..514c05428be7 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -117,6 +117,16 @@ export interface ComposerPromptEditorProps { * literal character. */ richTextEnabled?: boolean; + /** + * Set while a composer-driven flip of `richTextEnabled` is in flight. The + * flip remounts the editor, and the new instance takes the caret back at + * the stored cursor. Driven by the control that was clicked so that + * flipping the same setting from Settings does not pull focus into the + * composer; the editor clears it through `onFocusRestored`. + */ + restoreFocusOnMount?: boolean; + /** Reports that a requested focus restore has been applied. */ + onFocusRestored?: (() => void) | undefined; /** Draft records behind the prompt's context references, keyed by context id. */ contextRecords: ComposerDraftContextRecords; /** Structured clipboard payload for the given referenced ids, or null to skip. */ @@ -1270,6 +1280,37 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { [editor], ); + // This editor was mounted by a composer-driven rich text flip, so take the + // caret back at the cursor the last one reported. Two things make this less + // direct than it looks. `useEditor` returns null on its first render and + // builds the instance in an effect, so the restore has to wait for the + // instance. And the instance can be rebuilt again right after, which + // replaces the focused DOM node and drops focus to the body — so this + // restores for whichever instance is current rather than only the first. + // The cursor is captured at mount so typing never drags the caret back. + const [restoreFocusCursor] = useState(() => (props.restoreFocusOnMount ? initialCursor : null)); + const onFocusRestoredRef = useRef(props.onFocusRestored); + useEffect(() => { + onFocusRestoredRef.current = props.onFocusRestored; + }); + useEffect(() => { + if (restoreFocusCursor === null || !editor) return; + // Not `focusAt`: that bails before placing the caret whenever the editor's + // text and the store's disagree, a guard against reporting stale text to + // the store. A restore reports nothing, so it places the caret directly + // against the document as it is, clamped by the same coordinate mapping. + const map = serializeEditorDoc(editor.state.doc); + const flat = collapsedToFlat(map, clampCollapsedComposerCursor(map.value, restoreFocusCursor)); + editor.commands.setTextSelection(flatToPm(map, flat)); + // ProseMirror's focus, not the DOM's: it writes the selection into the + // DOM. A raw `dom.focus()` leaves the DOM selection at the start, and the + // first chip node view to mount makes ProseMirror re-read it from there, + // which resets the caret and reports a cursor of 0 to the store. + editor.view.focus(); + scrollTiptapCaretIntoView(editor); + onFocusRestoredRef.current?.(); + }, [editor, restoreFocusCursor]); + useImperativeHandle( editorRef, () => ({ diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d168c6db4e5f..d42095979d29 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -243,6 +243,7 @@ import { } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; import { useDebouncedValue } from "~/state/queries"; +import { ComposerSourceToggle } from "./ComposerSourceToggle"; import { ProviderModelPicker } from "./ProviderModelPicker"; import { resolveModelPickerSelectedModel } from "./ModelPickerContent"; import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; @@ -978,6 +979,7 @@ import { } from "@t3tools/client-runtime/providerSkills"; import { searchProviderSkills } from "../../providerSkillSearch"; import { useDelayedStatus } from "../../hooks/useDelayedStatus"; +import { useUpdateClientSettings } from "../../hooks/useSettings"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import { usePanelAnimationSettings } from "../../panelAnimations"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -2133,6 +2135,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const hasWrappedPrompt = useComposerMultilinePrompt(composerMenuAnchor); const hasMultilinePrompt = prompt.includes("\n") || hasWrappedPrompt; const [isStashMenuOpen, setIsStashMenuOpen] = useState(false); + const updateClientSettings = useUpdateClientSettings(); + // Flipping the setting remounts the editor, which drops focus. The token + // tells the new instance that the user asked for this from the composer and + // wants the caret back; changing the same setting from Settings leaves it + // alone. Rich text is a client setting rather than composer state, so both + // entry points drive the same switch. + const [pendingComposerFocusRestore, setPendingComposerFocusRestore] = useState(false); + const toggleComposerRichText = useCallback(() => { + setPendingComposerFocusRestore(true); + void updateClientSettings({ composerRichTextEnabled: !settings.composerRichTextEnabled }); + }, [settings.composerRichTextEnabled, updateClientSettings]); const [isTasksDrawerOpen, setIsTasksDrawerOpen] = useState(false); const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({ key: 0, @@ -5016,6 +5029,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) data-resting-controls-separator="true" /> ) : null} + setPendingComposerFocusRestore(false)} value={ isComposerApprovalState ? "" diff --git a/apps/web/src/components/chat/ComposerSourceToggle.tsx b/apps/web/src/components/chat/ComposerSourceToggle.tsx new file mode 100644 index 000000000000..0f02b3ac0877 --- /dev/null +++ b/apps/web/src/components/chat/ComposerSourceToggle.tsx @@ -0,0 +1,50 @@ +import { CodeIcon } from "lucide-react"; +import { memo } from "react"; + +import { cn } from "~/lib/utils"; + +import { ComposerControl, ComposerControlIcon } from "./ComposerControl"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +const LABEL_ON = "Edit the Markdown source"; +const LABEL_OFF = "Back to the rich text editor"; + +/** + * Swaps the composer between rich text and its literal Markdown source. + * + * There is no second editor behind this: plain mode is the same Tiptap + * document with the mark extensions off, so the draft, the chips and the + * caret all survive the flip. The button writes the same client setting the + * Settings panel exposes, which is what makes the two agree. + */ +export const ComposerSourceToggle = memo(function ComposerSourceToggle(props: { + richTextEnabled: boolean; + size?: "sm" | "xs"; + onToggle: () => void; +}) { + const size = props.size ?? "sm"; + const label = props.richTextEnabled ? LABEL_ON : LABEL_OFF; + return ( + + + } + > + + + {label} + + ); +}); diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 321bb8de50e6..abd4169b984c 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -437,6 +437,37 @@ describe("composer rich text document model", () => { } }); + // Flipping the rich text setting remounts the editor, and the caret is + // restored from the stored collapsed cursor. That only works because the + // coordinate means the same thing on both sides of the flip. + it.each([ + "plain prose with no styling at all", + "a chip @README.md counts one character in both modes", + "$my-skill leads the line", + "trailing newline\n", + ])("resolves a collapsed cursor identically in both modes for %s", (value) => { + const rich = roundTrip(value); + const plain = roundTripPlain(value); + expect(rich.value).toBe(value); + expect(plain.value).toBe(value); + for (let collapsed = 0; collapsed <= value.length; collapsed += 1) { + expect(flatToMarkdown(rich, collapsedToFlat(rich, collapsed))).toBe( + flatToMarkdown(plain, collapsedToFlat(plain, collapsed)), + ); + } + }); + + it("clamps a cursor that was sitting inside a marker onto the styled text", () => { + const value = "a **bold** c"; + const plain = roundTripPlain(value); + const rich = roundTrip(value); + // Between the two asterisks: a real caret position in plain mode, and no + // position at all in rich mode, where it lands on the first styled + // character instead. The flip moves the caret by a marker's width at most. + expect(flatToMarkdown(plain, collapsedToFlat(plain, 3))).toBe(3); + expect(flatToMarkdown(rich, collapsedToFlat(rich, 3))).toBe(4); + }); + it("maps markdown offsets at styled edges onto document text", () => { const value = "a **bold** c"; const map = roundTrip(value); diff --git a/docs/internals/composer-editors.md b/docs/internals/composer-editors.md index 4d9769367895..00e1d625c148 100644 --- a/docs/internals/composer-editors.md +++ b/docs/internals/composer-editors.md @@ -53,3 +53,28 @@ Enter keeps the current indent, Tab shifts whole lines, and two trailing blank lines exit the block, which is the only way out of a fence at the end of a prompt. Highlighting is Shiki decorations over the editable text, per block and cached by content, so a keystroke re-tokenizes only the block that changed. + +The source toggle is not a second editor. It writes `composerRichTextEnabled`, +which remounts the same engine with the mark extensions off, so the draft and its +chips survive the flip. The surface does not change font: plain mode is the same +prose the user was already looking at, minus the styling. + +The caret survives it too. A collapsed cursor means the same offset in both modes, +because markers are literal characters in the stored value either way, so the +remounted editor restores it from the stored cursor. Two traps sit in the way. +The flip has to be signalled by the control that was clicked rather than derived +from the setting: adjusting state during render makes React discard that render +pass including its children, so a flag computed that way never reaches the +editor being mounted, and flipping the setting from Settings should not pull +focus into the composer anyway. And `useEditor` returns null on its first render +and builds the instance in an effect, then can rebuild it once more right after, +which replaces the focused DOM node and drops focus to the body. So the restore +waits for the instance and applies to whichever instance is current, not only +the first; a one-shot guard restores into an editor that is about to be thrown +away. Placing the caret must end with ProseMirror's own `view.focus()`, not the +DOM's: only that writes the selection into the DOM. Left unsynced, the first chip +node view to mount makes ProseMirror re-read the DOM selection from the start, +which moves the caret and reports a cursor of 0 to the store — so a draft with +a chip lost its caret while plain prose kept it. A caret that was sitting between two markers has no +position in rich mode and clamps to the styled text, which moves it by a marker's +width at most. diff --git a/docs/user/composer.md b/docs/user/composer.md index a4d01af46489..285abe2cd5e9 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -30,6 +30,11 @@ language, then press Enter or Space. Inside a fence: Code is syntax highlighted using your current theme, and the language you declared is named in the corner of the block. +Use **Edit the Markdown source** in the composer controls to switch to literal +Markdown, with every marker shown as you typed it and nothing styled. Chips, +attachments and your cursor survive the switch, so you can move between the two +freely. The same switch lives in Settings as the rich text composer option. + ## Attach files Attach up to 100 files per message. Each image can be up to 10 MiB, with at most From a12e3515cf52b13eff61a76c8aaa833feba83755 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Thu, 17 Sep 2026 21:25:21 +0100 Subject: [PATCH 03/43] feat(web): rendered lists in the rich text composer Bullet and ordered lists stayed literal text in the rich text composer, with Shift+Enter continuing them by editing the Markdown string. Only task lists were real nodes. Bullet and ordered lists are now list nodes whose items keep the exact source marker, spacing and indent as attributes, so a list round-trips byte-identically and is never renumbered: `* ` stays a star and `3)` keeps its paren. The line grammar is the plain-mode continuation's own, so both modes agree on what a list line is, and items of a different marker at the same indent start a sibling list. Enter semantics are unchanged: Enter sends, Shift+Enter splits the item natively so marks and chips survive, ordered items counting up with the increment the literal continuation uses, and Tab still nests through the store edit. The bullet rule claims `- ` as it is typed, so `[ ] ` inside a bullet item is the task gesture. --- apps/web/package.json | 1 + .../components/ComposerPromptEditorTiptap.tsx | 81 +++++- .../src/composer-list-continuation.test.ts | 19 +- apps/web/src/composer-list-continuation.ts | 21 +- apps/web/src/composer-rich-text-doc.test.ts | 65 +++++ apps/web/src/composer-rich-text-doc.ts | 230 +++++++++++++----- apps/web/src/index.css | 16 +- docs/internals/composer-editors.md | 17 ++ docs/user/composer.md | 7 + pnpm-lock.yaml | 3 + 10 files changed, 380 insertions(+), 80 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index b9e51d28bee8..e8a79a16797a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,6 +34,7 @@ "@tiptap/core": "^3.31.3", "@tiptap/extension-code": "^3.31.3", "@tiptap/extension-code-block": "^3.31.3", + "@tiptap/extension-list": "^3.31.3", "@tiptap/extension-task-item": "^3.31.3", "@tiptap/extension-task-list": "^3.31.3", "@tiptap/pm": "^3.31.3", diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 514c05428be7..9a9c07f824f1 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1,4 +1,4 @@ -import { Extension, Node, wrappingInputRule, type JSONContent } from "@tiptap/core"; +import { Extension, InputRule, Node, wrappingInputRule, type JSONContent } from "@tiptap/core"; import { TaskList } from "@tiptap/extension-task-list"; import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; @@ -46,6 +46,7 @@ import { import { buildDocJson, ComposerCodeBlockExtension, + ComposerListExtensions, buildTiptapContent, collapsedToFlat, ComposerCodeExtension, @@ -62,6 +63,7 @@ import { indentCodeBlock, indentedNewlineInCodeBlock, } from "~/composer-code-block"; +import { nextOrderedMarkerText } from "~/composer-list-continuation"; import { collectInlineContextIds } from "~/lib/composerContextReferences"; import { resolveDiffThemeName } from "~/lib/diffRendering"; import { cn, isMacPlatform } from "~/lib/utils"; @@ -531,6 +533,51 @@ function collectStyledRanges(doc: ProseMirrorNode): StyledRange[] { return ranges; } +/** + * A typed marker becomes a list item that remembers the marker it was typed + * with, so the stored Markdown keeps `*` or `3)` rather than a canonical `-`. + */ +function listMarkerInputRule(find: RegExp, listType: "bulletList" | "orderedList"): InputRule { + return new InputRule({ + find, + handler: ({ state, range, match, chain }) => { + const marker = match[1] ?? "-"; + if (state.doc.resolve(range.from).parent.type.name !== "paragraph") return null; + chain() + .deleteRange(range) + .wrapInList( + listType, + listType === "orderedList" ? { start: Number.parseInt(marker, 10) || 1 } : {}, + ) + .updateAttributes("listItem", { marker, space: " " }) + .run(); + return undefined; + }, + }); +} + +/** + * `[ ] ` at the start of a bullet item turns it into a task. The bullet rule + * takes `- ` the moment it is typed, so this is how the task gesture from a + * plain paragraph still lands where it always did. + */ +const bulletToTaskInputRule = new InputRule({ + find: /^\[([ xX])\] $/, + handler: ({ state, range, match, chain }) => { + const $from = state.doc.resolve(range.from); + const item = $from.node(-1); + if ($from.parent.type.name !== "paragraph" || item?.type.name !== "listItem") return null; + if ((item.attrs as { marker?: string }).marker !== "-") return null; + const indent = typeof item.attrs.indent === "string" ? item.attrs.indent : ""; + chain() + .deleteRange(range) + .toggleList("taskList", "taskItem") + .updateAttributes("taskItem", { checked: (match[1] ?? " ").toLowerCase() === "x", indent }) + .run(); + return undefined; + }, +}); + /** Whether the caret sits inside a fenced code block. */ function isInCodeBlock(view: EditorView): boolean { return view.state.selection.$from.parent.type.spec.code === true; @@ -837,6 +884,17 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { document.documentElement.classList.contains("dark") ? "dark" : "light", ), }), + ...ComposerListExtensions.map((extension) => + extension.name === "listItem" + ? extension + : extension.extend({ + addInputRules() { + return this.name === "bulletList" + ? [listMarkerInputRule(/^([-*+])\s$/, "bulletList")] + : [listMarkerInputRule(/^(\d+[.)])\s$/, "orderedList")]; + }, + }), + ), TaskList, ComposerTaskItemExtension.extend({ addInputRules() { @@ -846,6 +904,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { type: this.type, getAttributes: (match) => ({ checked: match[1]?.toLowerCase() === "x" }), }), + bulletToTaskInputRule, ]; }, }), @@ -1014,7 +1073,8 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { if (event.key === "Enter") { const instance = editorHolder.current; const isTaskItem = richText && (instance?.isActive("taskItem") ?? false); - const handled = handler?.("Enter", event, isTaskItem) ?? false; + const isListItem = richText && (instance?.isActive("listItem") ?? false); + const handled = handler?.("Enter", event, isTaskItem || isListItem) ?? false; if (handled) { event.preventDefault(); event.stopPropagation(); @@ -1030,6 +1090,23 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ) { return true; } + if (isListItem && instance) { + const item = view.state.selection.$from.node(-1); + const marker = typeof item?.attrs.marker === "string" ? item.attrs.marker : "-"; + const isOrdered = /^\d+[.)]$/.test(marker); + if ( + instance.commands.splitListItem( + "listItem", + isOrdered + ? { marker: nextOrderedMarkerText(marker), space: " " } + : { space: " " }, + ) || + (view.state.selection.$from.parent.content.size === 0 && + instance.commands.liftListItem("listItem")) + ) { + return true; + } + } // Split the paragraph so a single newline visibly advances the caret. return splitBlockKeepMarks(view.state, (tr) => { // The split is programmatic, so the browser won't follow the diff --git a/apps/web/src/composer-list-continuation.test.ts b/apps/web/src/composer-list-continuation.test.ts index b92514dd2714..a878ed95a125 100644 --- a/apps/web/src/composer-list-continuation.test.ts +++ b/apps/web/src/composer-list-continuation.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { listContinuationForEnter, listIndentForTab } from "./composer-list-continuation"; +import { + listContinuationForEnter, + listIndentForTab, + nextOrderedMarkerText, +} from "./composer-list-continuation"; function applyEdit(value: string, edit: { start: number; end: number; replacement: string }) { return value.slice(0, edit.start) + edit.replacement + value.slice(edit.end); @@ -66,3 +70,16 @@ describe("composer list continuation", () => { expect(listIndentForTab("- foo", 1, 3)).toBeNull(); }); }); + +describe("nextOrderedMarkerText", () => { + it.each([ + ["1.", "2."], + ["1)", "2)"], + ["09.", "10."], + ["001)", "002)"], + ["99.", "100."], + ["99999999999999999999.", "99999999999999999999."], + ])("counts %s up to %s", (marker, expected) => { + expect(nextOrderedMarkerText(marker)).toBe(expected); + }); +}); diff --git a/apps/web/src/composer-list-continuation.ts b/apps/web/src/composer-list-continuation.ts index 952dfa549475..be617da2a268 100644 --- a/apps/web/src/composer-list-continuation.ts +++ b/apps/web/src/composer-list-continuation.ts @@ -55,13 +55,24 @@ function parseListMarker(line: string): { marker: ListMarker; markerEnd: number return null; } +/** + * The ordered marker that follows `3.` or `09)`: one higher, same delimiter, + * zero padding kept, and a number too large to count left as typed. Shared + * with the rich editor's native split so both modes count the same way. + */ +export function nextOrderedMarkerText(marker: string): string { + const numberText = marker.slice(0, -1); + const delimiter = marker.slice(-1); + const number = Number.parseInt(numberText, 10); + const next = Number.isSafeInteger(number) + ? String(number + 1).padStart(numberText.length, "0") + : numberText; + return `${next}${delimiter}`; +} + function nextMarkerText(marker: ListMarker): string { if (marker.kind === "ordered") { - const number = Number.parseInt(marker.numberText, 10); - const next = Number.isSafeInteger(number) - ? String(number + 1).padStart(marker.numberText.length, "0") - : marker.numberText; - return `${marker.indent}${next}${marker.delimiter} `; + return `${marker.indent}${nextOrderedMarkerText(`${marker.numberText}${marker.delimiter}`)} `; } if (marker.kind === "task") { return `${marker.indent}- [ ] `; diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index abd4169b984c..14eafbf7190b 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildDocJson, ComposerCodeBlockExtension, + ComposerListExtensions, collapsedToFlat, ComposerCodeExtension, ComposerTaskItemExtension, @@ -63,6 +64,7 @@ const schema = getSchemaByResolvedExtensions( TaskList, ComposerTaskItemExtension, ComposerCodeBlockExtension, + ...ComposerListExtensions, ]), ); @@ -343,6 +345,69 @@ describe("composer rich text document model", () => { expect(roundTripPlain(value).value).toBe(value); }); + it.each([ + "- one\n- two", + "* star\n* star two", + "+ plus", + "* star\n+ plus", + "1. first\n2. second", + "1) paren\n2) paren", + "3. starts at three\n4. four", + "01. zero padded\n02. two", + "- parent\n - child\n - sibling\n- uncle", + "1. ordered\n - bullet child\n2. next", + "- outer\n 1. inner ordered\n 2. more\n- outer again", + "- wide space item", + "-", + "1.", + "- \n- second", + "- **bold** item with @README.md", + "- [ ] task\n- bullet after", + "- bullet\n- [x] task after", + "para\n- item\npara", + "\t- tab indented\n\t- again", + " - leading indent only", + "- item\n\n- after a blank", + "- item one\n```ts\ncode\n```\n- item two", + "-no space stays literal", + "1.no space stays literal", + "10. ten\n11. eleven", + ])("round-trips the list %s through a real ProseMirror document", (value) => { + expect(roundTrip(value).value).toBe(value); + }); + + it.each([ + "- one\n- two", + "1. first\n2. second", + "- parent\n - child", + "* star\n+ plus", + "-no space stays literal", + ])("keeps the list %s literal in plain mode", (value) => { + expect(roundTripPlain(value).value).toBe(value); + }); + + it.each(["- one\n- two", "1. a\n - b\n2. c", "- **bold** @README.md tail", "-"])( + "maps every document offset of the list %s through collapsed coordinates and back", + (value) => { + const map = roundTrip(value); + expect(map.value).toBe(value); + for (let flat = 0; flat <= map.docLength; flat += 1) { + expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); + } + }, + ); + + it("clamps offsets inside a list marker to the start of the item text", () => { + const value = "- item"; + const map = roundTrip(value); + // The marker owns no document characters, like a checkbox. + for (let collapsed = 0; collapsed <= "- ".length; collapsed += 1) { + expect(collapsedToFlat(map, collapsed)).toBe(0); + } + expect(collapsedToFlat(map, "- it".length)).toBe(2); + expect(flatToCollapsed(map, 0)).toBe("- ".length); + }); + it.each([ "```\ncode\n```", "```ts\nconst a = 1;\n```", diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index 13cf0e5edd4a..e0278fffbf78 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -1,6 +1,7 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { Code } from "@tiptap/extension-code"; import { CodeBlock } from "@tiptap/extension-code-block"; +import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list"; import { TaskItem } from "@tiptap/extension-task-item"; import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; @@ -104,6 +105,25 @@ export function codeBlockSource(node: ProseMirrorNode): { return { open: `${fence}${language}${content ? "\n" : ""}`, content, close }; } +/** + * Bullet and ordered items keep their exact source marker so a list + * round-trips byte-identically: `marker` is the literal `-`, `*`, `+`, `3.` + * or `3)`, `space` what followed it, and `indent` the leading whitespace. + * Numbering is not renumbered: what the user typed is what the agent gets. + */ +export const ComposerListItemExtension = ListItem.extend({ + addAttributes() { + return { + ...this.parent?.(), + indent: { default: "" }, + marker: { default: "-" }, + space: { default: " " }, + }; + }, +}); + +export const ComposerListExtensions = [BulletList, OrderedList, ComposerListItemExtension]; + function randomNodeKey(): string { return `tiptap-${Math.random().toString(36).slice(2)}`; } @@ -156,10 +176,46 @@ function isClosingFence(line: string, fence: string): boolean { return run[0] === fence[0] && run.length >= fence.length; } +type ListLinePrefix = + | ({ kind: "task" } & TaskLinePrefix) + | { kind: "bullet" | "ordered"; indent: string; marker: string; space: string }; + +/** + * The same grammar the literal continuation uses, so plain and rich mode agree + * on what a list line is: a task first (it also looks like a bullet), then an + * ordered marker, then a bullet. A marker followed by nothing is an empty item. + */ +function parseListPrefix(line: string): { prefix: ListLinePrefix; markerLength: number } | null { + const task = parseTaskPrefix(line); + if (task) return { prefix: { kind: "task", ...task.prefix }, markerLength: task.markerLength }; + const ordered = /^([ \t]*)(\d+[.)])((?:[ \t]+)|$)/.exec(line); + if (ordered) { + return { + prefix: { kind: "ordered", indent: ordered[1]!, marker: ordered[2]!, space: ordered[3]! }, + markerLength: ordered[0].length, + }; + } + const bullet = /^([ \t]*)([-*+])((?:[ \t]+)|$)/.exec(line); + if (bullet) { + return { + prefix: { kind: "bullet", indent: bullet[1]!, marker: bullet[2]!, space: bullet[3]! }, + markerLength: bullet[0].length, + }; + } + return null; +} + +/** Items of one kind and marker family belong to one list; a change starts a sibling list. */ +function listKey(prefix: ListLinePrefix): string { + if (prefix.kind === "task") return "task"; + if (prefix.kind === "ordered") return `ordered:${prefix.marker.slice(-1)}`; + return `bullet:${prefix.marker}`; +} + type InlineJson = Record; interface DocLine { - task: TaskLinePrefix | null; + list: ListLinePrefix | null; inline: InlineJson[]; } @@ -201,28 +257,52 @@ function atomJsonForSegment( }; } -interface PendingTaskItem extends TaskLinePrefix { +interface PendingItem { + prefix: ListLinePrefix; content: InlineJson[]; - children: PendingTaskItem[]; + /** Nested lists, in order; a parent can hold lists of different kinds. */ + children: PendingList[]; } -function taskListJson(items: PendingTaskItem[]): InlineJson { - return { - type: "taskList", - content: items.map((item) => ({ - type: "taskItem", - attrs: { - checked: item.checked, - indent: item.indent, - markerSpace: item.markerSpace, - contentSpace: item.contentSpace, - }, - content: [ - { type: "paragraph", content: item.content }, - ...(item.children.length > 0 ? [taskListJson(item.children)] : []), - ], - })), - }; +interface PendingList { + key: string; + items: PendingItem[]; +} + +function listJson(list: PendingList): InlineJson { + const first = list.items[0]!.prefix; + const items = list.items.map((item) => { + const content = [ + { type: "paragraph", content: item.content }, + ...item.children.map((child) => listJson(child)), + ]; + if (item.prefix.kind === "task") { + return { + type: "taskItem", + attrs: { + checked: item.prefix.checked, + indent: item.prefix.indent, + markerSpace: item.prefix.markerSpace, + contentSpace: item.prefix.contentSpace, + }, + content, + }; + } + return { + type: "listItem", + attrs: { indent: item.prefix.indent, marker: item.prefix.marker, space: item.prefix.space }, + content, + }; + }); + if (first.kind === "task") return { type: "taskList", content: items }; + if (first.kind === "ordered") { + return { + type: "orderedList", + attrs: { start: Number.parseInt(first.marker, 10) || 1 }, + content: items, + }; + } + return { type: "bulletList", content: items }; } function textJsonForSpan(text: string, marks: RichTextMark[]): Record { @@ -261,7 +341,7 @@ export function buildTiptapContent( .join(""); let atomIndex = 0; const buildDocLine = (line: string): DocLine => { - const parsed = styling ? parseTaskPrefix(line) : null; + const parsed = styling ? parseListPrefix(line) : null; const content = parsed ? line.slice(parsed.markerLength) : line; const spans = styling ? parseInlineMarkdown(content) : [{ text: content, marks: [] }]; const inline: InlineJson[] = []; @@ -274,7 +354,7 @@ export function buildTiptapContent( if (piece) inline.push(textJsonForSpan(piece, span.marks)); }); } - return { task: parsed?.prefix ?? null, inline }; + return { list: parsed?.prefix ?? null, inline }; }; // Pass 1: fenced blocks claim their lines whole; everything else becomes an @@ -321,64 +401,72 @@ export function buildTiptapContent( index = closed ? cursor : sourceLines.length; } - // Pass 2: consecutive task lines group into (possibly nested) task lists - // by indent prefix; everything else stays a paragraph. + // Pass 2: consecutive list lines group into (possibly nested) lists by + // indent prefix; everything else stays a paragraph. Items of a different + // kind or marker at the same indent start a sibling list, so `* a` under + // `- b` keeps its star and a task list can follow a bullet list. const blocks: Record[] = []; - let stack: { indent: string; items: PendingTaskItem[] }[] = []; - const flushTasks = () => { - if (stack.length > 0) { - blocks.push(taskListJson(stack[0]!.items)); - stack = []; - } + let rootLists: PendingList[] = []; + let stack: { indent: string; list: PendingList; container: PendingList[] }[] = []; + const flushLists = () => { + for (const list of rootLists) blocks.push(listJson(list)); + rootLists = []; + stack = []; + }; + const openList = (container: PendingList[], key: string): PendingList => { + const list = { key, items: [] }; + container.push(list); + return list; }; for (const entry of entries) { if ("code" in entry) { - flushTasks(); + flushLists(); blocks.push(entry.code); continue; } const line = entry.line; - if (!line.task) { - flushTasks(); + if (!line.list) { + flushLists(); blocks.push({ type: "paragraph", content: line.inline }); continue; } - const item: PendingTaskItem = { - ...line.task, - content: line.inline, - children: [], - }; + const item: PendingItem = { prefix: line.list, content: line.inline, children: [] }; + const key = listKey(line.list); + const indent = line.list.indent; for (;;) { const top = stack[stack.length - 1]; if (!top) { // A leading indented item with no parent flattens but keeps indent. - stack.push({ indent: item.indent, items: [] }); + stack.push({ indent, list: openList(rootLists, key), container: rootLists }); continue; } - if (top.indent === item.indent) { - top.items.push(item); + if (top.indent === indent) { + if (top.list.key !== key) top.list = openList(top.container, key); + top.list.items.push(item); break; } - if (top.indent !== "" && !item.indent.startsWith(top.indent)) { + if (top.indent !== "" && !indent.startsWith(top.indent)) { if (stack.length > 1) { stack.pop(); continue; } - top.indent = item.indent; - top.items.push(item); + top.indent = indent; + if (top.list.key !== key) top.list = openList(top.container, key); + top.list.items.push(item); break; } - const parent = top.items[top.items.length - 1]; + const parent = top.list.items[top.list.items.length - 1]; if (!parent) { - top.items.push(item); + top.list.items.push(item); break; } - parent.children.push(item); - stack.push({ indent: item.indent, items: parent.children }); + const list = openList(parent.children, key); + stack.push({ indent, list, container: parent.children }); + list.items.push(item); break; } } - flushTasks(); + flushLists(); return blocks; } @@ -607,7 +695,28 @@ function appendInlineRuns( } } -function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumulator): void { +const LIST_NODE_NAMES = new Set(["taskList", "bulletList", "orderedList"]); + +/** The literal prefix an item serializes to. Empty items keep their exact spacing. */ +function listItemPrefix(item: ProseMirrorNode, empty: boolean): string { + const attrs = item.attrs as Record; + const indent = typeof attrs.indent === "string" ? attrs.indent : ""; + if (item.type.name === "taskItem") { + const markerSpace = typeof attrs.markerSpace === "string" ? attrs.markerSpace : " "; + const contentSpace = + typeof attrs.contentSpace === "string" + ? attrs.contentSpace || (empty ? "" : " ") + : empty + ? "" + : " "; + return `${indent}-${markerSpace}[${attrs.checked === true ? "x" : " "}]${contentSpace}`; + } + const marker = typeof attrs.marker === "string" && attrs.marker ? attrs.marker : "-"; + const space = typeof attrs.space === "string" ? attrs.space : " "; + return `${indent}${marker}${space}`; +} + +function walkList(list: ProseMirrorNode, listStart: number, acc: RichAccumulator): void { let itemPos = listStart + 1; let firstItem = true; list.content.forEach((item) => { @@ -617,17 +726,8 @@ function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumul const itemContentStart = itemPos + 1; const first = item.firstChild; const empty = first?.type.name === "paragraph" && first.content.childCount === 0; - const attrs = item.attrs as Record; - const indent = typeof attrs.indent === "string" ? attrs.indent : ""; - const markerSpace = typeof attrs.markerSpace === "string" ? attrs.markerSpace : " "; - const contentSpace = - typeof attrs.contentSpace === "string" - ? attrs.contentSpace || (empty ? "" : " ") - : empty - ? "" - : " "; - const prefix = `${indent}-${markerSpace}[${attrs.checked === true ? "x" : " "}]${contentSpace}`; - // The checkbox owns no document characters; every prefix offset clamps + const prefix = listItemPrefix(item, empty); + // The marker owns no document characters; every prefix offset clamps // to the start of the item text, exactly like style markers. acc.runs.push({ kind: "prefix", @@ -649,8 +749,8 @@ function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumul item.content.forEach((child) => { if (!firstBlock) pushBreakRun(acc); firstBlock = false; - if (child.type.name === "taskList") { - walkTaskList(child, childPos, acc); + if (LIST_NODE_NAMES.has(child.type.name)) { + walkList(child, childPos, acc); } else if (child.type.name === "paragraph") { appendInlineRuns(child, childPos + 1, acc); } @@ -697,8 +797,8 @@ export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap { let pmBlockStart = 0; blocks.forEach((block, blockIndex) => { if (blockIndex > 0) pushBreakRun(acc); - if (block.type.name === "taskList") { - walkTaskList(block, pmBlockStart, acc); + if (LIST_NODE_NAMES.has(block.type.name)) { + walkList(block, pmBlockStart, acc); } else if (block.type.name === "codeBlock") { appendCodeBlockRun(block, pmBlockStart + 1, acc); } else if (block.type.name === "paragraph") { diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 4968b294a004..a0994f3bb9a3 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1721,7 +1721,9 @@ code { .chat-markdown ol, .chat-markdown blockquote, .chat-markdown pre, -.chat-markdown .chat-markdown-table-container { +.chat-markdown .chat-markdown-table-container, +.composer-tiptap ul, +.composer-tiptap ol { margin: 0.65rem 0; } @@ -1759,7 +1761,7 @@ code { color: var(--contrast-muted-foreground); } -.chat-markdown ul { +:is(.chat-markdown, .composer-tiptap) ul { /* Reset for nested uls under a widened ol. --list-gutter is an inherited custom property, so without this a task-list under a multi-digit ordered list would inherit the outer gutter instead of its own default. */ @@ -1773,25 +1775,25 @@ code { widest marker has multiple characters so it isn't clipped by list-style-position: outside painting the marker past the padding box. Reset it here too so a nested ol without its own widened marker doesn't inherit the outer one. */ -.chat-markdown ol { +:is(.chat-markdown, .composer-tiptap) ol { --list-gutter: 1.25rem; padding-left: var(--list-gutter, 1.25rem); list-style-type: decimal; } -.chat-markdown ol > li::marker { +:is(.chat-markdown, .composer-tiptap) ol > li::marker { font-variant-numeric: tabular-nums; } -.chat-markdown ul ul { +:is(.chat-markdown, .composer-tiptap) ul ul { list-style-type: circle; } -.chat-markdown ul ul ul { +:is(.chat-markdown, .composer-tiptap) ul ul ul { list-style-type: square; } -.chat-markdown ol ol { +:is(.chat-markdown, .composer-tiptap) ol ol { list-style-type: lower-alpha; } diff --git a/docs/internals/composer-editors.md b/docs/internals/composer-editors.md index 00e1d625c148..4c73a996bc2c 100644 --- a/docs/internals/composer-editors.md +++ b/docs/internals/composer-editors.md @@ -26,6 +26,23 @@ the source and marker decorations are not content. Structured context records ac that text when available. Paste completes trailing chip delimiters and adds a leading boundary when inserting a chip directly after text. +Bullet and ordered lists are real list nodes, but their items keep the exact +source marker (`marker`: `-`, `*`, `+`, `3.`, `3)`), the whitespace after it and +the leading indent as attributes, so a list round-trips byte-identically and is +never renumbered. The line grammar is the one the plain-mode continuation in +[composer-list-continuation](../../apps/web/src/composer-list-continuation.ts) +uses, so both modes agree on what a list line is; keep them in step. Items of a +different kind or marker at the same indent start a sibling list, which is what +lets `* a` under `- b` keep its star and a task list follow a bullet list. + +Enter semantics are unchanged by rendering: `composerSubmissionIntentForEnter` +has no list flag and runs first, so Enter sends and it is Shift+Enter that +reaches the list branch. Rendered items split natively there (marks and chips +survive), ordered items counting up the way the literal continuation does; Tab +still goes through the literal store edit, which the rebuilt document reads back +as nesting. The bullet input rule claims `- ` as soon as it is typed, so the +task gesture is `[ ] ` inside a bullet item rather than `- [ ] ` in a paragraph. + Fenced code blocks are real `codeBlock` nodes rather than literal text. They keep their exact delimiters in attributes — `fence`, `language` and `close` — so a fence round-trips byte-identically, including tilde fences, long fences and a diff --git a/docs/user/composer.md b/docs/user/composer.md index 285abe2cd5e9..9f8e523ab1b1 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -20,6 +20,13 @@ text and appear beside the styled words when your cursor is on them, so nothing you typed is ever hidden from you. Task lists come from `- [ ]` and get checkboxes you can click. +Lists render as lists: `- `, `* ` or `+ ` starts a bulleted item and `1. ` or +`1) ` a numbered one. Inside an item, **Shift+Enter** continues the list (a +numbered list counts up), Shift+Enter on an empty item leaves the list, and +**Tab** nests the item. Enter still sends. Typing `[ ] ` at the start of a +bulleted item turns it into a task. The list is stored exactly as you wrote it: +the marker you typed and the numbers you used are what the agent receives. + Start a fenced code block with three backticks, optionally followed by a language, then press Enter or Space. Inside a fence: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f63b7ef0de12..25bbf463e67d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -648,6 +648,9 @@ importers: '@tiptap/extension-code-block': specifier: ^3.31.3 version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list': + specifier: ^3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-task-item': specifier: ^3.31.3 version: 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) From d5689a06fb2239278f47464b2129734de649e83c Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Thu, 17 Sep 2026 21:39:16 +0100 Subject: [PATCH 04/43] feat(web): block quotes in the rich text composer A `>` line stayed literal text in the rich text composer. Quotes are now blockquote nodes that keep the exact `>` prefix of their lines as an attribute, applied to every line on the way out, so a quote round-trips byte-identically and a new line typed inside it gets the same prefix. One source line is one paragraph, and a line whose prefix differs starts a sibling quote, the rule lists already use. Shift+Enter continues a quote and leaves it from an empty line. Quotes hold prose: list markers and nested `>` marks inside one stay literal, and the list input rules refuse to fire there, so the serializer never meets a block it cannot write back. --- apps/web/package.json | 1 + .../components/ComposerPromptEditorTiptap.tsx | 44 +++++++++- apps/web/src/composer-rich-text-doc.test.ts | 48 ++++++++++ apps/web/src/composer-rich-text-doc.ts | 88 +++++++++++++++++-- apps/web/src/index.css | 9 +- docs/internals/composer-editors.md | 7 ++ docs/user/composer.md | 4 + pnpm-lock.yaml | 3 + 8 files changed, 196 insertions(+), 8 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index e8a79a16797a..5fc78b5d2735 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -33,6 +33,7 @@ "@tanstack/react-router": "^1.160.2", "@tiptap/core": "^3.31.3", "@tiptap/extension-code": "^3.31.3", + "@tiptap/extension-blockquote": "^3.31.3", "@tiptap/extension-code-block": "^3.31.3", "@tiptap/extension-list": "^3.31.3", "@tiptap/extension-task-item": "^3.31.3", diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 9a9c07f824f1..518031eaca70 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -6,6 +6,7 @@ import { type Node as ProseMirrorNode } from "@tiptap/pm/model"; import { exitCode, newlineInCode, splitBlockKeepMarks } from "@tiptap/pm/commands"; import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view"; +import type { ResolvedPos } from "@tiptap/pm/model"; import type { AssistantCitation, ComposerContextClipboardFragment, @@ -45,6 +46,7 @@ import { } from "~/composer-editor-mentions"; import { buildDocJson, + ComposerBlockExtensions, ComposerCodeBlockExtension, ComposerListExtensions, buildTiptapContent, @@ -542,7 +544,8 @@ function listMarkerInputRule(find: RegExp, listType: "bulletList" | "orderedList find, handler: ({ state, range, match, chain }) => { const marker = match[1] ?? "-"; - if (state.doc.resolve(range.from).parent.type.name !== "paragraph") return null; + const $from = state.doc.resolve(range.from); + if ($from.parent.type.name !== "paragraph" || hasAncestor($from, "blockquote")) return null; chain() .deleteRange(range) .wrapInList( @@ -578,6 +581,27 @@ const bulletToTaskInputRule = new InputRule({ }, }); +function hasAncestor($pos: ResolvedPos, name: string): boolean { + for (let depth = $pos.depth; depth > 0; depth -= 1) { + if ($pos.node(depth).type.name === name) return true; + } + return false; +} + +/** + * `> ` at the start of a top-level paragraph opens a quote. Not inside a list: + * a quote holds prose lines, and a list item is not one. + */ +const blockquoteInputRule = new InputRule({ + find: /^>\s$/, + handler: ({ state, range, chain }) => { + const $from = state.doc.resolve(range.from); + if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null; + chain().deleteRange(range).wrapIn("blockquote", { prefix: "> " }).run(); + return undefined; + }, +}); + /** Whether the caret sits inside a fenced code block. */ function isInCodeBlock(view: EditorView): boolean { return view.state.selection.$from.parent.type.spec.code === true; @@ -884,6 +908,15 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { document.documentElement.classList.contains("dark") ? "dark" : "light", ), }), + ...ComposerBlockExtensions.map((extension) => + extension.name === "blockquote" + ? extension.extend({ + addInputRules() { + return [blockquoteInputRule]; + }, + }) + : extension, + ), ...ComposerListExtensions.map((extension) => extension.name === "listItem" ? extension @@ -1090,6 +1123,15 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ) { return true; } + if ( + richText && + instance && + hasAncestor(view.state.selection.$from, "blockquote") && + view.state.selection.$from.parent.content.size === 0 && + instance.commands.lift("blockquote") + ) { + return true; + } if (isListItem && instance) { const item = view.state.selection.$from.node(-1); const marker = typeof item?.attrs.marker === "string" ? item.attrs.marker : "-"; diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 14eafbf7190b..dc86a7e2811f 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildDocJson, + ComposerBlockExtensions, ComposerCodeBlockExtension, ComposerListExtensions, collapsedToFlat, @@ -65,6 +66,7 @@ const schema = getSchemaByResolvedExtensions( ComposerTaskItemExtension, ComposerCodeBlockExtension, ...ComposerListExtensions, + ...ComposerBlockExtensions, ]), ); @@ -408,6 +410,52 @@ describe("composer rich text document model", () => { expect(flatToCollapsed(map, 0)).toBe("- ".length); }); + it.each([ + "> quoted", + "> line one\n> line two", + ">no space", + "> two spaces", + "> a\n>b", + ">", + "> **bold** and @README.md inside", + "> - looks like a list but stays quote text", + "> > nested stays literal inside the quote", + "before\n> quoted\nafter", + "> quote\n\n> another", + "- item\n> quote after list", + ])("round-trips the quote %s through a real ProseMirror document", (value) => { + expect(roundTrip(value).value).toBe(value); + }); + + it.each(["> quoted", "> a\n>b"])("keeps the block %s literal in plain mode", (value) => { + expect(roundTripPlain(value).value).toBe(value); + }); + + it("parses a quote as a blockquote of one paragraph per line", () => { + const json = buildDocJson("> a\n> b\n>c", (n) => ({ label: n, description: null })); + expect(json.content.map((block) => block.type)).toEqual(["blockquote", "blockquote"]); + expect((json.content[0] as { content: unknown[] }).content).toHaveLength(2); + }); + + it.each(["> a\n> b", "> **q** @README.md"])( + "maps every document offset of the block %s through collapsed coordinates and back", + (value) => { + const map = roundTrip(value); + expect(map.value).toBe(value); + for (let flat = 0; flat <= map.docLength; flat += 1) { + expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); + } + }, + ); + + it("clamps offsets inside a quote marker to the start of the text", () => { + const map = roundTrip("> quoted"); + for (let collapsed = 0; collapsed <= "> ".length; collapsed += 1) { + expect(collapsedToFlat(map, collapsed)).toBe(0); + } + expect(flatToCollapsed(map, 0)).toBe("> ".length); + }); + it.each([ "```\ncode\n```", "```ts\nconst a = 1;\n```", diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index e0278fffbf78..a14e81017da6 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -1,5 +1,6 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { Code } from "@tiptap/extension-code"; +import { Blockquote } from "@tiptap/extension-blockquote"; import { CodeBlock } from "@tiptap/extension-code-block"; import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list"; import { TaskItem } from "@tiptap/extension-task-item"; @@ -124,6 +125,22 @@ export const ComposerListItemExtension = ListItem.extend({ export const ComposerListExtensions = [BulletList, OrderedList, ComposerListItemExtension]; +/** + * A quote keeps the exact `>` prefix its lines were written with, applied to + * every line, so it round-trips byte-identically and a new line typed inside + * it gets the same prefix. One source line is one paragraph; a line whose + * prefix differs starts a sibling quote. Nested markers and list markers + * inside a quote stay literal text: the composer quotes prose, not documents. + */ +export const ComposerBlockquoteExtension = Blockquote.extend({ + addAttributes() { + return { ...this.parent?.(), prefix: { default: "> " } }; + }, +}); + +/** Block-level nodes beyond lists and fences, in the order the parser tries them. */ +export const ComposerBlockExtensions = [ComposerBlockquoteExtension]; + function randomNodeKey(): string { return `tiptap-${Math.random().toString(36).slice(2)}`; } @@ -340,9 +357,7 @@ export function buildTiptapContent( }) .join(""); let atomIndex = 0; - const buildDocLine = (line: string): DocLine => { - const parsed = styling ? parseListPrefix(line) : null; - const content = parsed ? line.slice(parsed.markerLength) : line; + const buildInline = (content: string): InlineJson[] => { const spans = styling ? parseInlineMarkdown(content) : [{ text: content, marks: [] }]; const inline: InlineJson[] = []; for (const span of spans) { @@ -354,13 +369,22 @@ export function buildTiptapContent( if (piece) inline.push(textJsonForSpan(piece, span.marks)); }); } - return { list: parsed?.prefix ?? null, inline }; + return inline; + }; + const buildDocLine = (line: string): DocLine => { + const parsed = styling ? parseListPrefix(line) : null; + const content = parsed ? line.slice(parsed.markerLength) : line; + return { list: parsed?.prefix ?? null, inline: buildInline(content) }; }; // Pass 1: fenced blocks claim their lines whole; everything else becomes an // inline-parsed line. Fence bodies restore chip sources as literal text. const sourceLines = text.split("\n"); - const entries: ({ code: Record } | { line: DocLine })[] = []; + const entries: ( + | { code: Record } + | { quote: { prefix: string; inline: InlineJson[] } } + | { line: DocLine } + )[] = []; const restoreSources = (line: string) => line.split(sentinel).reduce((joined, piece, index) => { if (index === 0) return piece; @@ -372,7 +396,9 @@ export function buildTiptapContent( const line = sourceLines[index]!; const opening = styling ? parseOpeningFence(line) : null; if (!opening) { - entries.push({ line: buildDocLine(line) }); + const quote = styling ? /^(>[ \t]*)(.*)$/.exec(line) : null; + if (quote) entries.push({ quote: { prefix: quote[1]!, inline: buildInline(quote[2]!) } }); + else entries.push({ line: buildDocLine(line) }); continue; } const body: string[] = []; @@ -418,7 +444,25 @@ export function buildTiptapContent( container.push(list); return list; }; + let openQuote: { prefix: string; content: InlineJson[][] } | null = null; + const flushQuote = () => { + if (!openQuote) return; + blocks.push({ + type: "blockquote", + attrs: { prefix: openQuote.prefix }, + content: openQuote.content.map((inline) => ({ type: "paragraph", content: inline })), + }); + openQuote = null; + }; for (const entry of entries) { + if ("quote" in entry) { + flushLists(); + if (openQuote && openQuote.prefix !== entry.quote.prefix) flushQuote(); + openQuote ??= { prefix: entry.quote.prefix, content: [] }; + openQuote.content.push(entry.quote.inline); + continue; + } + flushQuote(); if ("code" in entry) { flushLists(); blocks.push(entry.code); @@ -466,6 +510,7 @@ export function buildTiptapContent( break; } } + flushQuote(); flushLists(); return blocks; } @@ -787,6 +832,35 @@ function appendCodeBlockRun(block: ProseMirrorNode, pmPos: number, acc: RichAccu acc.md += open.length + content.length + close.length; } +/** Each paragraph of a quote is one source line behind the quote's prefix. */ +function walkBlockquote(quote: ProseMirrorNode, quoteStart: number, acc: RichAccumulator): void { + const attrs = quote.attrs as Record; + const prefix = typeof attrs.prefix === "string" ? attrs.prefix : "> "; + let childPos = quoteStart + 1; + let firstLine = true; + quote.content.forEach((child) => { + if (!firstLine) pushBreakRun(acc); + firstLine = false; + acc.runs.push({ + kind: "prefix", + flatStart: acc.flat, + docLen: 0, + collapsedLen: prefix.length, + mdLen: prefix.length, + openLen: 0, + closeLen: 0, + pmPos: childPos + 1, + mdStart: acc.md, + collapsedStart: acc.collapsed, + }); + acc.value += prefix; + acc.collapsed += prefix.length; + acc.md += prefix.length; + if (child.type.name === "paragraph") appendInlineRuns(child, childPos + 1, acc); + childPos += child.nodeSize; + }); +} + export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap { const acc: RichAccumulator = { runs: [], value: "", flat: 0, collapsed: 0, md: 0 }; const blocks: ProseMirrorNode[] = []; @@ -801,6 +875,8 @@ export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap { walkList(block, pmBlockStart, acc); } else if (block.type.name === "codeBlock") { appendCodeBlockRun(block, pmBlockStart + 1, acc); + } else if (block.type.name === "blockquote") { + walkBlockquote(block, pmBlockStart, acc); } else if (block.type.name === "paragraph") { appendInlineRuns(block, pmBlockStart + 1, acc); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index a0994f3bb9a3..6bebe2ec60ea 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1838,12 +1838,19 @@ code { background-size: 4px 2px; } -.chat-markdown blockquote { +:is(.chat-markdown, .composer-tiptap) blockquote { border-left: 2px solid var(--contrast-border); padding-left: 0.8rem; color: var(--contrast-muted-foreground); } +/* A quote in the composer is text the user is still writing; muting it reads + as disabled in an input, so the border alone carries the quote. */ +.composer-tiptap blockquote { + margin: 0.65rem 0; + color: var(--foreground); +} + .chat-markdown section[data-footnotes] { margin-top: 1.25rem; border-top: 1px solid var(--contrast-border); diff --git a/docs/internals/composer-editors.md b/docs/internals/composer-editors.md index 4c73a996bc2c..8a68aaf06dc8 100644 --- a/docs/internals/composer-editors.md +++ b/docs/internals/composer-editors.md @@ -43,6 +43,13 @@ still goes through the literal store edit, which the rebuilt document reads back as nesting. The bullet input rule claims `- ` as soon as it is typed, so the task gesture is `[ ] ` inside a bullet item rather than `- [ ] ` in a paragraph. +A quote is a `blockquote` node carrying the exact `>` prefix of its lines as an +attribute, applied to every child paragraph on the way out; one source line is +one paragraph, and a line whose prefix differs starts a sibling quote, the same +rule lists use for a marker change. Lists and nested quotes are not parsed +inside a quote and the list input rules refuse to fire there: a quote holds +prose lines, and the serializer would have nowhere to put anything else. + Fenced code blocks are real `codeBlock` nodes rather than literal text. They keep their exact delimiters in attributes — `fence`, `language` and `close` — so a fence round-trips byte-identically, including tilde fences, long fences and a diff --git a/docs/user/composer.md b/docs/user/composer.md index 9f8e523ab1b1..c5c59fdfa6b5 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -27,6 +27,10 @@ numbered list counts up), Shift+Enter on an empty item leaves the list, and bulleted item turns it into a task. The list is stored exactly as you wrote it: the marker you typed and the numbers you used are what the agent receives. +`> ` at the start of a line opens a quote. Each line inside it is quoted, so +Shift+Enter continues the quote and Shift+Enter on an empty line leaves it. +Quotes hold prose: list markers and further `>` marks inside one stay literal. + Start a fenced code block with three backticks, optionally followed by a language, then press Enter or Space. Inside a fence: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25bbf463e67d..6ddb3885a896 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -642,6 +642,9 @@ importers: '@tiptap/core': specifier: ^3.31.3 version: 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-blockquote': + specifier: ^3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-code': specifier: ^3.31.3 version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) From c0c998e9e4da4da805d96664d9927d3401a33eb7 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Thu, 17 Sep 2026 21:43:48 +0100 Subject: [PATCH 05/43] feat(web): horizontal rules in the rich text composer A line of dashes stayed literal text in the rich text composer. A thematic break is now a rule node that keeps its exact source line, so `---`, `* * *` and `_____` round-trip byte-identically. The parser tries it ahead of lists and inline parsing, which keeps `- - -` from becoming a bullet and `***` from becoming an empty bold span. Typing `---` makes a rule on the third dash; `***` and `___` wait for a following space so bold and underscores are not interrupted, matching Tiptap's own rule. The rule only forms at a top-level paragraph, where the serializer has a line for it, and inserts through setHorizontalRule so a rule at the end gets a paragraph after it. --- apps/web/package.json | 1 + .../components/ComposerPromptEditorTiptap.tsx | 43 ++++++++++++++++- apps/web/src/composer-rich-text-doc.test.ts | 42 +++++++++++++++-- apps/web/src/composer-rich-text-doc.ts | 47 ++++++++++++++++++- apps/web/src/index.css | 6 +++ docs/internals/composer-editors.md | 8 ++++ docs/user/composer.md | 4 ++ pnpm-lock.yaml | 3 ++ 8 files changed, 149 insertions(+), 5 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 5fc78b5d2735..fe5eef492b8b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -35,6 +35,7 @@ "@tiptap/extension-code": "^3.31.3", "@tiptap/extension-blockquote": "^3.31.3", "@tiptap/extension-code-block": "^3.31.3", + "@tiptap/extension-horizontal-rule": "^3.31.3", "@tiptap/extension-list": "^3.31.3", "@tiptap/extension-task-item": "^3.31.3", "@tiptap/extension-task-list": "^3.31.3", diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 518031eaca70..0ced00b9d858 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -602,6 +602,41 @@ const blockquoteInputRule = new InputRule({ }, }); +/** + * `---` becomes a rule as the third dash lands; `***` and `___` need a space + * after them so typing bold or an underscore is not interrupted, matching + * Tiptap's own rule. Only at a top-level paragraph: the list and quote + * serializers have no line to write a rule into. The typed characters are + * kept as the rule's source, and `setHorizontalRule` adds a paragraph after a + * rule at the end so the caret has somewhere to go. + */ +const horizontalRuleInputRule = new InputRule({ + find: /^(---|\*\*\*|___)\s?$/, + handler: ({ state, range, match, chain }) => { + const source = match[1] ?? "---"; + if (source !== "---" && !/\s$/.test(match[0] ?? "")) return null; + const $from = state.doc.resolve(range.from); + if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null; + chain() + .deleteRange(range) + .setHorizontalRule() + .command(({ tr }) => { + // The rule is the block before the caret's paragraph. + const $pos = tr.selection.$from; + const index = $pos.index(0) - 1; + if (index < 0) return true; + const rulePos = $pos.posAtIndex(index, 0); + const rule = tr.doc.nodeAt(rulePos); + if (rule?.type.name === "horizontalRule") { + tr.setNodeMarkup(rulePos, undefined, { ...rule.attrs, source }); + } + return true; + }) + .run(); + return undefined; + }, +}); + /** Whether the caret sits inside a fenced code block. */ function isInCodeBlock(view: EditorView): boolean { return view.state.selection.$from.parent.type.spec.code === true; @@ -915,7 +950,13 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { return [blockquoteInputRule]; }, }) - : extension, + : extension.name === "horizontalRule" + ? extension.extend({ + addInputRules() { + return [horizontalRuleInputRule]; + }, + }) + : extension, ), ...ComposerListExtensions.map((extension) => extension.name === "listItem" diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index dc86a7e2811f..a150cc9ab9aa 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -427,17 +427,53 @@ describe("composer rich text document model", () => { expect(roundTrip(value).value).toBe(value); }); - it.each(["> quoted", "> a\n>b"])("keeps the block %s literal in plain mode", (value) => { - expect(roundTripPlain(value).value).toBe(value); + it.each([ + "---", + "***", + "___", + "- - -", + "* * *", + "-----", + "--- ", + "text\n---\nmore", + "- item\n---\n- item two", + "```\n---\n```", + "--", + "-- -", + "---text", + ])("round-trips the rule %s through a real ProseMirror document", (value) => { + expect(roundTrip(value).value).toBe(value); + }); + + it("parses rules ahead of lists and emphasis", () => { + const json = buildDocJson("- - -\n***\n___\ntext\n---\n- item", (n) => ({ + label: n, + description: null, + })); + expect(json.content.map((block) => block.type)).toEqual([ + "horizontalRule", + "horizontalRule", + "horizontalRule", + "paragraph", + "horizontalRule", + "bulletList", + ]); }); + it.each(["> quoted", "> a\n>b", "---", "- - -"])( + "keeps the block %s literal in plain mode", + (value) => { + expect(roundTripPlain(value).value).toBe(value); + }, + ); + it("parses a quote as a blockquote of one paragraph per line", () => { const json = buildDocJson("> a\n> b\n>c", (n) => ({ label: n, description: null })); expect(json.content.map((block) => block.type)).toEqual(["blockquote", "blockquote"]); expect((json.content[0] as { content: unknown[] }).content).toHaveLength(2); }); - it.each(["> a\n> b", "> **q** @README.md"])( + it.each(["> a\n> b", "> **q** @README.md", "text\n---\nmore", "---\ntext"])( "maps every document offset of the block %s through collapsed coordinates and back", (value) => { 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 a14e81017da6..e23ab7f835a0 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -2,6 +2,7 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { Code } from "@tiptap/extension-code"; import { Blockquote } from "@tiptap/extension-blockquote"; import { CodeBlock } from "@tiptap/extension-code-block"; +import { HorizontalRule } from "@tiptap/extension-horizontal-rule"; import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list"; import { TaskItem } from "@tiptap/extension-task-item"; @@ -138,8 +139,22 @@ export const ComposerBlockquoteExtension = Blockquote.extend({ }, }); +/** + * A rule keeps the exact line it was written as (`---`, `* * *`, `_____`), so + * it round-trips byte-identically. It owns no document characters: offsets + * inside its source clamp to the block after it. + */ +export const ComposerHorizontalRuleExtension = HorizontalRule.extend({ + addAttributes() { + return { ...this.parent?.(), source: { default: "---" } }; + }, +}); + /** Block-level nodes beyond lists and fences, in the order the parser tries them. */ -export const ComposerBlockExtensions = [ComposerBlockquoteExtension]; +export const ComposerBlockExtensions = [ + ComposerBlockquoteExtension, + ComposerHorizontalRuleExtension, +]; function randomNodeKey(): string { return `tiptap-${Math.random().toString(36).slice(2)}`; @@ -382,6 +397,7 @@ export function buildTiptapContent( const sourceLines = text.split("\n"); const entries: ( | { code: Record } + | { rule: string } | { quote: { prefix: string; inline: InlineJson[] } } | { line: DocLine } )[] = []; @@ -396,6 +412,12 @@ export function buildTiptapContent( const line = sourceLines[index]!; const opening = styling ? parseOpeningFence(line) : null; if (!opening) { + // A thematic break outranks a list: `- - -` and `* * *` are rules, and + // `***` on its own line is a rule rather than an empty bold span. + if (styling && /^([-*_])(?:[ \t]*\1){2,}[ \t]*$/.test(line)) { + entries.push({ rule: line }); + continue; + } const quote = styling ? /^(>[ \t]*)(.*)$/.exec(line) : null; if (quote) entries.push({ quote: { prefix: quote[1]!, inline: buildInline(quote[2]!) } }); else entries.push({ line: buildDocLine(line) }); @@ -468,6 +490,11 @@ export function buildTiptapContent( blocks.push(entry.code); continue; } + if ("rule" in entry) { + flushLists(); + blocks.push({ type: "horizontalRule", attrs: { source: entry.rule } }); + continue; + } const line = entry.line; if (!line.list) { flushLists(); @@ -877,6 +904,24 @@ export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap { appendCodeBlockRun(block, pmBlockStart + 1, acc); } else if (block.type.name === "blockquote") { walkBlockquote(block, pmBlockStart, acc); + } else if (block.type.name === "horizontalRule") { + const attrs = block.attrs as Record; + const source = typeof attrs.source === "string" && attrs.source ? attrs.source : "---"; + acc.runs.push({ + kind: "prefix", + flatStart: acc.flat, + docLen: 0, + collapsedLen: source.length, + mdLen: source.length, + openLen: 0, + closeLen: 0, + pmPos: pmBlockStart + block.nodeSize, + mdStart: acc.md, + collapsedStart: acc.collapsed, + }); + acc.value += source; + acc.collapsed += source.length; + acc.md += source.length; } else if (block.type.name === "paragraph") { appendInlineRuns(block, pmBlockStart + 1, acc); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 6bebe2ec60ea..4a973a080ff5 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1851,6 +1851,12 @@ code { color: var(--foreground); } +/* Preflight already draws the 1px rule, as it does in chat; only the block + spacing needs to match. */ +.composer-tiptap hr { + margin: 0.65rem 0; +} + .chat-markdown section[data-footnotes] { margin-top: 1.25rem; border-top: 1px solid var(--contrast-border); diff --git a/docs/internals/composer-editors.md b/docs/internals/composer-editors.md index 8a68aaf06dc8..8b7134d96195 100644 --- a/docs/internals/composer-editors.md +++ b/docs/internals/composer-editors.md @@ -50,6 +50,14 @@ rule lists use for a marker change. Lists and nested quotes are not parsed inside a quote and the list input rules refuse to fire there: a quote holds prose lines, and the serializer would have nowhere to put anything else. +A thematic break is a `horizontalRule` node carrying its exact source line. The +parser tries it before lists and before inline parsing, which is what keeps +`- - -` from becoming a bullet and `***` from becoming an empty bold span. It +owns no document characters, so offsets inside its source clamp to whatever +follows; a draft that ends on a rule has no caret position after it, which the +input rule avoids by inserting through `setHorizontalRule`, which appends a +paragraph when nothing follows. + Fenced code blocks are real `codeBlock` nodes rather than literal text. They keep their exact delimiters in attributes — `fence`, `language` and `close` — so a fence round-trips byte-identically, including tilde fences, long fences and a diff --git a/docs/user/composer.md b/docs/user/composer.md index c5c59fdfa6b5..ee5f01e71dad 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -31,6 +31,10 @@ the marker you typed and the numbers you used are what the agent receives. Shift+Enter continues the quote and Shift+Enter on an empty line leaves it. Quotes hold prose: list markers and further `>` marks inside one stay literal. +Three dashes on their own line become a horizontal rule as you type them; +`***` or `___` followed by a space do the same. The rule is stored as the +characters you typed. + Start a fenced code block with three backticks, optionally followed by a language, then press Enter or Space. Inside a fence: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ddb3885a896..09a881cf80c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -651,6 +651,9 @@ importers: '@tiptap/extension-code-block': specifier: ^3.31.3 version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-horizontal-rule': + specifier: ^3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-list': specifier: ^3.31.3 version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) From ba4d806120f47406acd6085f852627266c84a7a4 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Thu, 17 Sep 2026 21:49:26 +0100 Subject: [PATCH 06/43] feat(web): headings in the rich text composer `# Heading` stayed literal text in the rich text composer. Headings are now heading nodes whose `space` attribute keeps the exact whitespace after the `#`s, so a heading round-trips byte-identically and closing `#`s stay literal. The `#`s must be followed by whitespace, in the parser and in the input rule alike, and that is the whole of the coexistence with pull request references: `#1234` is a token of `#` plus word characters, a heading needs the space that ends that token, so neither can claim the other's input and the picker keeps opening for `#12`. Headings form only at a top-level paragraph, and Shift+Enter after one starts an ordinary line. --- apps/web/package.json | 1 + .../components/ComposerPromptEditorTiptap.tsx | 27 ++++- apps/web/src/composer-rich-text-doc.test.ts | 100 ++++++++++++++++-- apps/web/src/composer-rich-text-doc.ts | 56 ++++++++++ apps/web/src/index.css | 31 +++--- docs/internals/composer-editors.md | 9 ++ docs/user/composer.md | 4 + pnpm-lock.yaml | 3 + 8 files changed, 210 insertions(+), 21 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index fe5eef492b8b..2610a4d70114 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -35,6 +35,7 @@ "@tiptap/extension-code": "^3.31.3", "@tiptap/extension-blockquote": "^3.31.3", "@tiptap/extension-code-block": "^3.31.3", + "@tiptap/extension-heading": "^3.31.3", "@tiptap/extension-horizontal-rule": "^3.31.3", "@tiptap/extension-list": "^3.31.3", "@tiptap/extension-task-item": "^3.31.3", diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 0ced00b9d858..1c2063387d2a 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -637,6 +637,25 @@ const horizontalRuleInputRule = new InputRule({ }, }); +/** + * `# ` through `###### ` at a top-level paragraph make a heading. The space + * is required, which is exactly what keeps `#1234` a pull request reference + * with its picker rather than a heading. Not inside lists or quotes, whose + * serializers have no line for one. + */ +const headingInputRule = new InputRule({ + find: /^(#{1,6})\s$/, + handler: ({ state, range, match, chain }) => { + const $from = state.doc.resolve(range.from); + if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null; + chain() + .deleteRange(range) + .setNode("heading", { level: match[1]?.length ?? 1, space: " " }) + .run(); + return undefined; + }, +}); + /** Whether the caret sits inside a fenced code block. */ function isInCodeBlock(view: EditorView): boolean { return view.state.selection.$from.parent.type.spec.code === true; @@ -956,7 +975,13 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { return [horizontalRuleInputRule]; }, }) - : extension, + : extension.name === "heading" + ? extension.extend({ + addInputRules() { + return [headingInputRule]; + }, + }) + : extension, ), ...ComposerListExtensions.map((extension) => extension.name === "listItem" diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index a150cc9ab9aa..e09189066453 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -460,7 +460,81 @@ describe("composer rich text document model", () => { ]); }); - it.each(["> quoted", "> a\n>b", "---", "- - -"])( + it.each([ + "# Heading", + "## Two", + "###### Six", + "####### seven hashes stays a paragraph", + "# two spaces", + "#\tTab", + "# Trailing hashes stay literal #", + "#1234", + "#1234 is a pull request, not a heading", + "# Heading with **bold** and @README.md", + "#", + "# ", + "text\n# Heading\ntext", + "# Heading\n- item\n> quote\n---", + ])("round-trips the heading %s through a real ProseMirror document", (value) => { + expect(roundTrip(value).value).toBe(value); + }); + + it.each(["> quoted", "> a\n>b", "---", "- - -", "# Heading", "#1234"])( + "keeps the block %s literal in plain mode", + (value) => { + expect(roundTripPlain(value).value).toBe(value); + }, + ); + + it("parses the blocks it renders as the right node types", () => { + const json = buildDocJson("# Title\n> quote\n---\n#1234 ref\n- - -", (n) => ({ + label: n, + description: null, + })); + expect(json.content.map((block) => block.type)).toEqual([ + "heading", + "blockquote", + "horizontalRule", + "paragraph", + "horizontalRule", + ]); + }); + + it("parses rules ahead of lists and emphasis", () => { + const json = buildDocJson("- - -\n***\n___\ntext\n---\n- item", (n) => ({ + label: n, + description: null, + })); + expect(json.content.map((block) => block.type)).toEqual([ + "horizontalRule", + "horizontalRule", + "horizontalRule", + "paragraph", + "horizontalRule", + "bulletList", + ]); + }); + + it.each([ + "# Heading", + "## Two", + "###### Six", + "####### seven hashes stays a paragraph", + "# two spaces", + "#\tTab", + "# Trailing hashes stay literal #", + "#1234", + "#1234 is a pull request, not a heading", + "# Heading with **bold** and @README.md", + "#", + "# ", + "text\n# Heading\ntext", + "# Heading\n- item\n> quote\n---", + ])("round-trips the heading %s through a real ProseMirror document", (value) => { + expect(roundTrip(value).value).toBe(value); + }); + + it.each(["> quoted", "> a\n>b", "---", "- - -", "# Heading", "#1234"])( "keeps the block %s literal in plain mode", (value) => { expect(roundTripPlain(value).value).toBe(value); @@ -473,7 +547,14 @@ describe("composer rich text document model", () => { expect((json.content[0] as { content: unknown[] }).content).toHaveLength(2); }); - it.each(["> a\n> b", "> **q** @README.md", "text\n---\nmore", "---\ntext"])( + it.each([ + "> a\n> b", + "> **q** @README.md", + "text\n---\nmore", + "---\ntext", + "# Heading text", + "## **b** @README.md", + ])( "maps every document offset of the block %s through collapsed coordinates and back", (value) => { const map = roundTrip(value); @@ -484,12 +565,17 @@ describe("composer rich text document model", () => { }, ); - it("clamps offsets inside a quote marker to the start of the text", () => { - const map = roundTrip("> quoted"); - for (let collapsed = 0; collapsed <= "> ".length; collapsed += 1) { - expect(collapsedToFlat(map, collapsed)).toBe(0); + it("clamps offsets inside a quote or heading marker to the start of the text", () => { + for (const [value, prefix] of [ + ["> quoted", "> "], + ["# Heading", "# "], + ] as const) { + const map = roundTrip(value); + for (let collapsed = 0; collapsed <= prefix.length; collapsed += 1) { + expect(collapsedToFlat(map, collapsed)).toBe(0); + } + expect(flatToCollapsed(map, 0)).toBe(prefix.length); } - expect(flatToCollapsed(map, 0)).toBe("> ".length); }); it.each([ diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index e23ab7f835a0..56b57e6157ff 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -2,6 +2,7 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { Code } from "@tiptap/extension-code"; import { Blockquote } from "@tiptap/extension-blockquote"; import { CodeBlock } from "@tiptap/extension-code-block"; +import { Heading } from "@tiptap/extension-heading"; import { HorizontalRule } from "@tiptap/extension-horizontal-rule"; import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list"; import { TaskItem } from "@tiptap/extension-task-item"; @@ -150,10 +151,23 @@ export const ComposerHorizontalRuleExtension = HorizontalRule.extend({ }, }); +/** + * A heading keeps the exact whitespace between its `#`s and its text. The + * `#`s must be followed by whitespace to count, which is also what keeps a + * `#1234` pull request reference a reference: the marker owns no document + * characters, closing `#`s stay literal text, and nothing is ever stripped. + */ +export const ComposerHeadingExtension = Heading.extend({ + addAttributes() { + return { ...this.parent?.(), space: { default: " " } }; + }, +}); + /** Block-level nodes beyond lists and fences, in the order the parser tries them. */ export const ComposerBlockExtensions = [ ComposerBlockquoteExtension, ComposerHorizontalRuleExtension, + ComposerHeadingExtension, ]; function randomNodeKey(): string { @@ -398,6 +412,7 @@ export function buildTiptapContent( const entries: ( | { code: Record } | { rule: string } + | { heading: { level: number; space: string; inline: InlineJson[] } } | { quote: { prefix: string; inline: InlineJson[] } } | { line: DocLine } )[] = []; @@ -418,6 +433,17 @@ export function buildTiptapContent( entries.push({ rule: line }); continue; } + const heading = styling ? /^(#{1,6})([ \t]+)(.*)$/.exec(line) : null; + if (heading) { + entries.push({ + heading: { + level: heading[1]!.length, + space: heading[2]!, + inline: buildInline(heading[3]!), + }, + }); + continue; + } const quote = styling ? /^(>[ \t]*)(.*)$/.exec(line) : null; if (quote) entries.push({ quote: { prefix: quote[1]!, inline: buildInline(quote[2]!) } }); else entries.push({ line: buildDocLine(line) }); @@ -495,6 +521,15 @@ export function buildTiptapContent( blocks.push({ type: "horizontalRule", attrs: { source: entry.rule } }); continue; } + if ("heading" in entry) { + flushLists(); + blocks.push({ + type: "heading", + attrs: { level: entry.heading.level, space: entry.heading.space }, + content: entry.heading.inline, + }); + continue; + } const line = entry.line; if (!line.list) { flushLists(); @@ -904,6 +939,27 @@ export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap { appendCodeBlockRun(block, pmBlockStart + 1, acc); } else if (block.type.name === "blockquote") { walkBlockquote(block, pmBlockStart, acc); + } else if (block.type.name === "heading") { + const attrs = block.attrs as Record; + const level = typeof attrs.level === "number" ? attrs.level : 1; + const space = typeof attrs.space === "string" ? attrs.space : " "; + const prefix = `${"#".repeat(level)}${space}`; + acc.runs.push({ + kind: "prefix", + flatStart: acc.flat, + docLen: 0, + collapsedLen: prefix.length, + mdLen: prefix.length, + openLen: 0, + closeLen: 0, + pmPos: pmBlockStart + 1, + mdStart: acc.md, + collapsedStart: acc.collapsed, + }); + acc.value += prefix; + acc.collapsed += prefix.length; + 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 : "---"; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 4a973a080ff5..fbe74bd2c207 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1727,37 +1727,37 @@ code { margin: 0.65rem 0; } -.chat-markdown h1, -.chat-markdown h2, -.chat-markdown h3, -.chat-markdown h4, -.chat-markdown h5, -.chat-markdown h6 { +:is(.chat-markdown, .composer-tiptap) h1, +:is(.chat-markdown, .composer-tiptap) h2, +:is(.chat-markdown, .composer-tiptap) h3, +:is(.chat-markdown, .composer-tiptap) h4, +:is(.chat-markdown, .composer-tiptap) h5, +:is(.chat-markdown, .composer-tiptap) h6 { margin: 1.25rem 0 0.5rem; font-weight: 600; line-height: 1.3; color: var(--contrast-foreground); } -.chat-markdown h1 { +:is(.chat-markdown, .composer-tiptap) h1 { font-size: 1.25rem; } -.chat-markdown h2 { +:is(.chat-markdown, .composer-tiptap) h2 { font-size: 1.125rem; } -.chat-markdown h3 { +:is(.chat-markdown, .composer-tiptap) h3 { font-size: 1rem; } -.chat-markdown h4, -.chat-markdown h5, -.chat-markdown h6 { +:is(.chat-markdown, .composer-tiptap) h4, +:is(.chat-markdown, .composer-tiptap) h5, +:is(.chat-markdown, .composer-tiptap) h6 { font-size: 0.875rem; } -.chat-markdown h6 { +:is(.chat-markdown, .composer-tiptap) h6 { color: var(--contrast-muted-foreground); } @@ -1857,6 +1857,11 @@ code { margin: 0.65rem 0; } +/* A heading at the top of a draft should not push the first line down. */ +.composer-tiptap :is(h1, h2, h3, h4, h5, h6):first-child { + margin-top: 0; +} + .chat-markdown section[data-footnotes] { margin-top: 1.25rem; border-top: 1px solid var(--contrast-border); diff --git a/docs/internals/composer-editors.md b/docs/internals/composer-editors.md index 8b7134d96195..bc11d6c943dc 100644 --- a/docs/internals/composer-editors.md +++ b/docs/internals/composer-editors.md @@ -58,6 +58,15 @@ follows; a draft that ends on a rule has no caret position after it, which the input rule avoids by inserting through `setHorizontalRule`, which appends a paragraph when nothing follows. +A heading is a `heading` node whose `space` attribute keeps the exact +whitespace after the `#`s; closing `#`s stay literal text. The `#`s must be +followed by whitespace, in the parser and in the input rule alike. That is the +whole of the coexistence with pull request references: `detectComposerTrigger` +matches `#` followed immediately by word characters as a token, and a heading +needs the space that ends that token, so neither can ever claim the other's +input. Headings, rules and quotes form only at a top-level paragraph; the list +and quote serializers write paragraphs and lists and nothing else. + Fenced code blocks are real `codeBlock` nodes rather than literal text. They keep their exact delimiters in attributes — `fence`, `language` and `close` — so a fence round-trips byte-identically, including tilde fences, long fences and a diff --git a/docs/user/composer.md b/docs/user/composer.md index ee5f01e71dad..03931109bc05 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -35,6 +35,10 @@ Three dashes on their own line become a horizontal rule as you type them; `***` or `___` followed by a space do the same. The rule is stored as the characters you typed. +`# ` through `###### ` at the start of a line make a heading; the space after +the hashes is what makes it one, so `#1234` still looks up a pull request. +Shift+Enter after a heading starts an ordinary line. + Start a fenced code block with three backticks, optionally followed by a language, then press Enter or Space. Inside a fence: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09a881cf80c9..f423b83341b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -651,6 +651,9 @@ importers: '@tiptap/extension-code-block': specifier: ^3.31.3 version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-heading': + specifier: ^3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-horizontal-rule': specifier: ^3.31.3 version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) From 337fd3f5dcbc9efbe2b88ef66037ed508ac6d151 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Fri, 18 Sep 2026 13:14:06 +0100 Subject: [PATCH 07/43] feat(web): type GFM task items whole in the rich text composer `- ` became a bullet the moment it was typed, so `- [ ] ` landed as a bullet that the checkbox rule then converted: the right result by way of a flash, and not the GFM gesture people expect. The dash bullet rule now waits for the first character after `- ` and carries it into the new item, so `- x` is a bullet holding `x` while `- [ ] ` and `- [x] ` are typed whole and reach the task rule directly. `*` and `+` still convert on the space, since they are not task markers in this grammar, and `[ ] ` inside an existing bullet still converts it. A `- ` left on its own is a bullet again the next time the draft is rebuilt. --- .../components/ComposerPromptEditorTiptap.tsx | 24 +++++++++++++------ docs/internals/composer-editors.md | 6 +++-- docs/user/composer.md | 4 ++-- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 1c2063387d2a..a22b91c91606 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -538,31 +538,38 @@ function collectStyledRanges(doc: ProseMirrorNode): StyledRange[] { /** * A typed marker becomes a list item that remembers the marker it was typed * with, so the stored Markdown keeps `*` or `3)` rather than a canonical `-`. + * + * `- ` alone is not enough for a dash: it waits for the first character after + * the space, which it carries into the new item. That is what lets the GFM + * task gesture `- [ ] ` or `- [x] ` be typed whole and reach the task rule, + * instead of being cut off by an instant bullet. A `- ` left on its own is + * still a bullet the next time the draft is rebuilt. */ function listMarkerInputRule(find: RegExp, listType: "bulletList" | "orderedList"): InputRule { return new InputRule({ find, handler: ({ state, range, match, chain }) => { const marker = match[1] ?? "-"; + const carried = match[2] ?? ""; const $from = state.doc.resolve(range.from); if ($from.parent.type.name !== "paragraph" || hasAncestor($from, "blockquote")) return null; - chain() + const command = chain() .deleteRange(range) .wrapInList( listType, listType === "orderedList" ? { start: Number.parseInt(marker, 10) || 1 } : {}, ) - .updateAttributes("listItem", { marker, space: " " }) - .run(); + .updateAttributes("listItem", { marker, space: " " }); + (carried ? command.insertContent(carried) : command).run(); return undefined; }, }); } /** - * `[ ] ` at the start of a bullet item turns it into a task. The bullet rule - * takes `- ` the moment it is typed, so this is how the task gesture from a - * plain paragraph still lands where it always did. + * `[ ] ` at the start of an existing bullet item turns it into a task, for + * items that were already a list when the checkbox was wanted. New tasks are + * typed whole, `- [ ] `, and reach the task rule directly. */ const bulletToTaskInputRule = new InputRule({ find: /^\[([ xX])\] $/, @@ -989,7 +996,10 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { : extension.extend({ addInputRules() { return this.name === "bulletList" - ? [listMarkerInputRule(/^([-*+])\s$/, "bulletList")] + ? [ + listMarkerInputRule(/^([*+])\s$/, "bulletList"), + listMarkerInputRule(/^(-) ([^\s[])$/, "bulletList"), + ] : [listMarkerInputRule(/^(\d+[.)])\s$/, "orderedList")]; }, }), diff --git a/docs/internals/composer-editors.md b/docs/internals/composer-editors.md index bc11d6c943dc..482457cea11f 100644 --- a/docs/internals/composer-editors.md +++ b/docs/internals/composer-editors.md @@ -40,8 +40,10 @@ has no list flag and runs first, so Enter sends and it is Shift+Enter that reaches the list branch. Rendered items split natively there (marks and chips survive), ordered items counting up the way the literal continuation does; Tab still goes through the literal store edit, which the rebuilt document reads back -as nesting. The bullet input rule claims `- ` as soon as it is typed, so the -task gesture is `[ ] ` inside a bullet item rather than `- [ ] ` in a paragraph. +as nesting. The dash bullet rule waits for the first character after `- ` and carries +it into the item, so the GFM task gesture `- [ ] ` is typed whole and reaches +the task rule; `*` and `+` convert on the space, since they are not task +markers here. `[ ] ` inside an existing bullet converts it as well. A quote is a `blockquote` node carrying the exact `>` prefix of its lines as an attribute, applied to every child paragraph on the way out; one source line is diff --git a/docs/user/composer.md b/docs/user/composer.md index 03931109bc05..35207d42f63e 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -23,8 +23,8 @@ checkboxes you can click. Lists render as lists: `- `, `* ` or `+ ` starts a bulleted item and `1. ` or `1) ` a numbered one. Inside an item, **Shift+Enter** continues the list (a numbered list counts up), Shift+Enter on an empty item leaves the list, and -**Tab** nests the item. Enter still sends. Typing `[ ] ` at the start of a -bulleted item turns it into a task. The list is stored exactly as you wrote it: +**Tab** nests the item. Enter still sends. `- [ ] ` and `- [x] ` make task +items, and `[ ] ` at the start of an existing bulleted item turns it into one. The list is stored exactly as you wrote it: the marker you typed and the numbers you used are what the agent receives. `> ` at the start of a line opens a quote. Each line inside it is quoted, so From 0beed6053ad2095adb46dbaa114ab4aa448bcb62 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Fri, 18 Sep 2026 14:22:50 +0100 Subject: [PATCH 08/43] chore(web): drop unused composer exports knip flagged six exports the rich text composer never imports: a plugin key and a fence source helper used only inside their own modules, and four block extensions only ever consumed through the ComposerListExtensions and ComposerBlockExtensions arrays. They are module-private now. --- apps/web/src/components/composerCodeBlockHighlight.ts | 4 +--- apps/web/src/composer-rich-text-doc.ts | 10 +++++----- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/composerCodeBlockHighlight.ts b/apps/web/src/components/composerCodeBlockHighlight.ts index a61fd7ff1593..1e4577ec0447 100644 --- a/apps/web/src/components/composerCodeBlockHighlight.ts +++ b/apps/web/src/components/composerCodeBlockHighlight.ts @@ -12,9 +12,7 @@ interface HighlightedBlock { readonly decorations: ReadonlyArray<{ from: number; to: number; color: string }>; } -export const composerCodeBlockHighlightKey = new PluginKey( - "composerCodeBlockHighlight", -); +const composerCodeBlockHighlightKey = new PluginKey("composerCodeBlockHighlight"); const MAX_CACHED_BLOCKS = 64; /** diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index 56b57e6157ff..0803d729032f 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -95,7 +95,7 @@ export const ComposerCodeBlockExtension = CodeBlock.extend({ }); /** The markdown a code block node serializes to, delimiters included. */ -export function codeBlockSource(node: ProseMirrorNode): { +function codeBlockSource(node: ProseMirrorNode): { open: string; content: string; close: string; @@ -114,7 +114,7 @@ export function codeBlockSource(node: ProseMirrorNode): { * or `3)`, `space` what followed it, and `indent` the leading whitespace. * Numbering is not renumbered: what the user typed is what the agent gets. */ -export const ComposerListItemExtension = ListItem.extend({ +const ComposerListItemExtension = ListItem.extend({ addAttributes() { return { ...this.parent?.(), @@ -134,7 +134,7 @@ export const ComposerListExtensions = [BulletList, OrderedList, ComposerListItem * prefix differs starts a sibling quote. Nested markers and list markers * inside a quote stay literal text: the composer quotes prose, not documents. */ -export const ComposerBlockquoteExtension = Blockquote.extend({ +const ComposerBlockquoteExtension = Blockquote.extend({ addAttributes() { return { ...this.parent?.(), prefix: { default: "> " } }; }, @@ -145,7 +145,7 @@ export const ComposerBlockquoteExtension = Blockquote.extend({ * it round-trips byte-identically. It owns no document characters: offsets * inside its source clamp to the block after it. */ -export const ComposerHorizontalRuleExtension = HorizontalRule.extend({ +const ComposerHorizontalRuleExtension = HorizontalRule.extend({ addAttributes() { return { ...this.parent?.(), source: { default: "---" } }; }, @@ -157,7 +157,7 @@ export const ComposerHorizontalRuleExtension = HorizontalRule.extend({ * `#1234` pull request reference a reference: the marker owns no document * characters, closing `#`s stay literal text, and nothing is ever stripped. */ -export const ComposerHeadingExtension = Heading.extend({ +const ComposerHeadingExtension = Heading.extend({ addAttributes() { return { ...this.parent?.(), space: { default: " " } }; }, From f944a85e975227c9cc043b87560d5b85d9adada5 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Fri, 18 Sep 2026 15:10:05 +0100 Subject: [PATCH 09/43] fix(web): keep fence editing inside one code block `codeBlockRange` compared the selection's endpoints with `Node.eq`, which is structural, so a selection from one fence into a second, identical fence passed the guard and Tab or Enter could write across the block boundary. It now requires the same code block occurrence. --- apps/web/src/composer-code-block.test.ts | 21 +++++++++++++++++++++ apps/web/src/composer-code-block.ts | 4 +++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/web/src/composer-code-block.test.ts b/apps/web/src/composer-code-block.test.ts index b08f9eebf4ad..c8267a42ec5a 100644 --- a/apps/web/src/composer-code-block.test.ts +++ b/apps/web/src/composer-code-block.test.ts @@ -177,6 +177,27 @@ describe("indentCodeBlock", () => { }); }); +describe("selections across two identical fences", () => { + /** Two structurally equal code blocks; the selection spans from one into the other. */ + function twoBlockEditor() { + const block = { type: "codeBlock", content: [{ type: "text", text: " a" }] }; + const editor = new Editor({ extensions, content: { type: "doc", content: [block, block] } }); + // First block: 1..4 (text " a" occupies 1..4); second block starts at 5. + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 2, 7)), + ); + return editor; + } + + it("leaves Tab and Enter alone rather than editing through the boundary", () => { + const editor = twoBlockEditor(); + const before = editor.getJSON(); + expect(indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr))).toBe(false); + expect(indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); + expect(editor.getJSON()).toEqual(before); + }); +}); + describe("convertCodeFenceOnEnter", () => { /** Builds an editor holding one paragraph with the caret at its end. */ function paragraphEditor(text: string) { diff --git a/apps/web/src/composer-code-block.ts b/apps/web/src/composer-code-block.ts index 23ae25a6c4d7..f38a6e69a3b9 100644 --- a/apps/web/src/composer-code-block.ts +++ b/apps/web/src/composer-code-block.ts @@ -48,7 +48,9 @@ function codeBlockRange( const { $from, $to } = state.selection; const parent = $from.parent; if (parent.type.spec.code !== true) return null; - if (!$to.parent.eq(parent)) return null; + // Same occurrence, not merely equal structure: two identical fences would + // otherwise let a selection across them edit through the block boundary. + if (!$from.sameParent($to)) return null; const from = $from.start(); return { from, to: from + parent.content.size, text: parent.textContent }; } From 53f2044b524515b397baf6bfdef3acf676c32747 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Fri, 18 Sep 2026 15:10:07 +0100 Subject: [PATCH 10/43] fix(web): keep fence info strings and bare list markers round-tripping A chip in a fence's info string was serialized as the sentinel character and left the atom index unadvanced, so the next chip after the fence took the wrong atom. The info string now has its sources restored like every other line, in order, before the body. A bare `-` or `1.` item stored an empty space and kept it after the user typed into the item, serializing `-text`, which is no longer a list line. Like task items, the space is restored once the item has content. --- apps/web/src/composer-rich-text-doc.test.ts | 34 +++++++++++++++++++++ apps/web/src/composer-rich-text-doc.ts | 9 ++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index e09189066453..6e1b3e89b25e 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -658,6 +658,40 @@ describe("composer rich text document model", () => { expect(roundTrip(value).value).toBe(expected); }); + it("keeps a chip in a fence info string as source and keeps later chips aligned", () => { + const value = "```@README.md\ncode\n```\n$my-skill after"; + expect(roundTrip(value).value).toBe(value); + const json = buildDocJson(value, (n) => ({ label: n, description: null })); + const after = json.content[1] as { + content: { type: string; attrs?: { skillName?: string } }[]; + }; + expect(after.content.map((n) => n.type)).toEqual(["composer-skill", "text"]); + expect(after.content[0]?.attrs?.skillName).toBe("my-skill"); + }); + + it.each([ + ["listItem", { marker: "-", space: "" }, "bulletList", "- text"], + ["listItem", { marker: "1.", space: "" }, "orderedList", "1. text"], + ])("gives a bare %s a space once it has text", (item, attrs, list, expected) => { + const doc = ProseMirrorNode.fromJSON(schema, { + type: "doc", + content: [ + { + type: list, + content: [ + { + type: item, + attrs, + content: [{ type: "paragraph", content: [{ type: "text", text: "text" }] }], + }, + ], + }, + ], + }); + expect(serializeEditorDoc(doc).value).toBe(expected); + expect(roundTrip("-").value).toBe("-"); + }); + it("keeps fences literal in plain mode", () => { const value = "```ts\nconst a = 1;\n```"; expect(roundTripPlain(value).value).toBe(value); diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index 0803d729032f..9018403bbee8 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -468,7 +468,9 @@ export function buildTiptapContent( entries.push({ code: { type: "codeBlock", - attrs: { language: opening.language, fence: opening.fence, close }, + // The info string went through the sentinel pass like every line, so + // a token in it is put back as source here, in order, before the body. + attrs: { language: restoreSources(opening.language), fence: opening.fence, close }, ...(content ? { content: [{ type: "text", text: content }] } : {}), }, }); @@ -819,7 +821,10 @@ function listItemPrefix(item: ProseMirrorNode, empty: boolean): string { return `${indent}-${markerSpace}[${attrs.checked === true ? "x" : " "}]${contentSpace}`; } const marker = typeof attrs.marker === "string" && attrs.marker ? attrs.marker : "-"; - const space = typeof attrs.space === "string" ? attrs.space : " "; + // A bare `-` keeps its missing space only while the item is empty: once it + // has text, `-text` would not be a list line any more. + const space = + typeof attrs.space === "string" ? attrs.space || (empty ? "" : " ") : empty ? "" : " "; return `${indent}${marker}${space}`; } From ba4bd26208811042d3775559669f1ed4a79c95f0 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Fri, 18 Sep 2026 15:10:09 +0100 Subject: [PATCH 11/43] fix(web): render composer ordered markers from their source The composer preserved `3)` and a nested `7.` in the stored Markdown but let the browser number the list, showing `3.` and `a.`. The item now carries its source marker as `data-marker`, the composer draws it through `::marker` content, and chat's nested alpha and roman styles stay chat's own. --- apps/web/src/composer-rich-text-doc.ts | 6 ++++++ apps/web/src/index.css | 8 +++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index 9018403bbee8..2d4a520f0201 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -4,6 +4,7 @@ import { Blockquote } from "@tiptap/extension-blockquote"; import { CodeBlock } from "@tiptap/extension-code-block"; import { Heading } from "@tiptap/extension-heading"; import { HorizontalRule } from "@tiptap/extension-horizontal-rule"; +import { mergeAttributes } from "@tiptap/core"; import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list"; import { TaskItem } from "@tiptap/extension-task-item"; @@ -123,6 +124,11 @@ const ComposerListItemExtension = ListItem.extend({ space: { default: " " }, }; }, + // The source marker rides on the item so the composer draws `3)` and a + // nested `7.` as written, rather than the browser own numbering. + renderHTML({ node, HTMLAttributes }) { + return ["li", mergeAttributes(HTMLAttributes, { "data-marker": node.attrs.marker }), 0]; + }, }); export const ComposerListExtensions = [BulletList, OrderedList, ComposerListItemExtension]; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index fbe74bd2c207..53934b2a6913 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1785,6 +1785,12 @@ code { font-variant-numeric: tabular-nums; } +/* The composer shows the marker the source has, `3)` or a nested `7.`, not the + browser counter. Browsers without ::marker content fall back to `start`. */ +.composer-tiptap ol > li[data-marker]::marker { + content: attr(data-marker) " "; +} + :is(.chat-markdown, .composer-tiptap) ul ul { list-style-type: circle; } @@ -1793,7 +1799,7 @@ code { list-style-type: square; } -:is(.chat-markdown, .composer-tiptap) ol ol { +.chat-markdown ol ol { list-style-type: lower-alpha; } From 460d070147ab4314593ab669f7cc602f8a6b2590 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Fri, 18 Sep 2026 15:10:10 +0100 Subject: [PATCH 12/43] fix(web): accept any whitespace after a typed dash marker The deferred dash rule required exactly one space, so `- item` and `-\titem` stayed literal while the parser would have read them as a list. The rule now takes the whitespace the parser takes and keeps it on the item. --- .../components/ComposerPromptEditorTiptap.tsx | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index a22b91c91606..41d51a87e67b 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -549,8 +549,9 @@ function listMarkerInputRule(find: RegExp, listType: "bulletList" | "orderedList return new InputRule({ find, handler: ({ state, range, match, chain }) => { - const marker = match[1] ?? "-"; - const carried = match[2] ?? ""; + const marker = match.groups?.marker ?? "-"; + const space = match.groups?.space ?? " "; + const carried = match.groups?.carried ?? ""; const $from = state.doc.resolve(range.from); if ($from.parent.type.name !== "paragraph" || hasAncestor($from, "blockquote")) return null; const command = chain() @@ -559,7 +560,7 @@ function listMarkerInputRule(find: RegExp, listType: "bulletList" | "orderedList listType, listType === "orderedList" ? { start: Number.parseInt(marker, 10) || 1 } : {}, ) - .updateAttributes("listItem", { marker, space: " " }); + .updateAttributes("listItem", { marker, space }); (carried ? command.insertContent(carried) : command).run(); return undefined; }, @@ -997,10 +998,18 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { addInputRules() { return this.name === "bulletList" ? [ - listMarkerInputRule(/^([*+])\s$/, "bulletList"), - listMarkerInputRule(/^(-) ([^\s[])$/, "bulletList"), + listMarkerInputRule(/^(?[*+])(?\s)$/, "bulletList"), + listMarkerInputRule( + /^(?-)(?[ \t]+)(?[^\s[])$/, + "bulletList", + ), ] - : [listMarkerInputRule(/^(\d+[.)])\s$/, "orderedList")]; + : [ + listMarkerInputRule( + /^(?\d+[.)])(?\s)$/, + "orderedList", + ), + ]; }, }), ), From 3107fc9ce112459ea3013885dfd24436928f893a Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Fri, 18 Sep 2026 15:10:11 +0100 Subject: [PATCH 13/43] refactor(web): share the code block frame between chat and the composer The composer's fence copied the chat code block's wrapper, header and title class strings. Those are now exported from ChatMarkdown and used by both, so the two cannot drift, and the static styling only an editable fence needs is carried as classes on the node view rather than in global CSS. --- apps/web/src/components/ChatMarkdown.tsx | 18 ++++++++++-- .../chat/ComposerCodeBlockNodeView.tsx | 28 +++++++++++-------- apps/web/src/index.css | 22 --------------- 3 files changed, 32 insertions(+), 36 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 97d38a9c224e..8479e7d06ac3 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -898,6 +898,18 @@ function MarkdownDetails({ * icon (redundant next to its own name) and fall back to the language text * when no specific icon exists or it fails to load. */ +/** + * The code block frame, shared with the composer so an editable fence and a + * rendered one cannot drift apart: the wrapper, its header row, and the + * title slot the language icon sits in. + */ +export const MARKDOWN_CODE_BLOCK_CLASS_NAME = + "chat-markdown-codeblock my-[0.65rem] overflow-hidden rounded-lg border border-border/70 bg-secondary leading-snug dark:border-transparent dark:bg-input/32"; +export const MARKDOWN_CODE_BLOCK_HEADER_CLASS_NAME = + "chat-markdown-codeblock-header flex items-center justify-between gap-2 pt-1.5 pr-1.5 pb-0 pl-3 select-none"; +export const MARKDOWN_CODE_BLOCK_TITLE_CLASS_NAME = + "inline-flex min-w-0 items-center gap-1.5 font-mono text-2xs"; + export function MarkdownCodeBlockTitleContent({ fenceTitle, language, @@ -1008,12 +1020,12 @@ function MarkdownCodeBlock({ return (
-
- +
+ -
- +
+
-
-           as="code" />
+        
+          {/* A caret needs a line to sit on even before any code is typed. */}
+          
+            as="code"
+            className="block min-h-[1lh] border-0 bg-transparent p-0 font-mono whitespace-pre-wrap [color:inherit] [font-size:var(--font-size-code,0.92em)] [overflow-wrap:anywhere]"
+          />
         
diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 53934b2a6913..500de732abd3 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2184,28 +2184,6 @@ code { padding: 0.05em 0.25em; } -/* The fence node view reuses chat's code block markup and class names, so the - shared rules above carry the surface. What is left is what only an editable - fence needs: the code itself, and a caret that can sit on an empty line. */ -.composer-tiptap .chat-markdown-codeblock pre { - padding: 0.25rem 0.7rem 0.5rem; - max-width: 100%; - overflow-x: auto; -} - -.composer-tiptap .chat-markdown-codeblock pre code { - display: block; - min-height: 1lh; - border: none; - background: transparent; - padding: 0; - color: inherit; - font-family: var(--font-mono); - font-size: var(--font-size-code, 0.92em); - white-space: pre-wrap; - overflow-wrap: anywhere; -} - .composer-rich-marker { color: var(--muted-foreground); opacity: 0.75; From bf30636d997501b520ef5f058619c3f8c7048bcb Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Fri, 18 Sep 2026 15:10:12 +0100 Subject: [PATCH 14/43] docs(user): lint-clean code spans in the composer guide Code spans ending in a space use double backticks so the space stays visible and markdownlint's MD038 passes, and one over-long line is wrapped. --- docs/user/composer.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/user/composer.md b/docs/user/composer.md index 35207d42f63e..12a539ee1ef5 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -24,8 +24,9 @@ Lists render as lists: `- `, `* ` or `+ ` starts a bulleted item and `1. ` or `1) ` a numbered one. Inside an item, **Shift+Enter** continues the list (a numbered list counts up), Shift+Enter on an empty item leaves the list, and **Tab** nests the item. Enter still sends. `- [ ] ` and `- [x] ` make task -items, and `[ ] ` at the start of an existing bulleted item turns it into one. The list is stored exactly as you wrote it: -the marker you typed and the numbers you used are what the agent receives. +items, and `[ ] ` at the start of an existing bulleted item turns it into +one. The list is stored exactly as you wrote it: the marker you typed and the +numbers you used are what the agent receives. `> ` at the start of a line opens a quote. Each line inside it is quoted, so Shift+Enter continues the quote and Shift+Enter on an empty line leaves it. From 837c09df419565318debe7cac7f33f7ab26bc25d Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Fri, 18 Sep 2026 16:29:10 +0100 Subject: [PATCH 15/43] fix(web): only open fences at a top-level paragraph A fence created inside a list item or quote, by Enter on a fence line, by Tiptap's own space-triggered rule, or by pasting Markdown there, had no line the list and quote serializers could write it into, so it vanished on the next update. The Enter conversion now requires a top-level paragraph, the space-triggered rules are off, and Markdown pasted inside a list item or quote stays literal lines the next rebuild reads back. --- .../components/ComposerPromptEditorTiptap.tsx | 23 ++++++++++++-- apps/web/src/composer-code-block.test.ts | 31 ++++++++++++++++++- apps/web/src/composer-code-block.ts | 4 ++- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 41d51a87e67b..9488f2e8e2d8 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -963,6 +963,12 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { addNodeView() { return ReactNodeViewRenderer(ComposerCodeBlockNodeView); }, + // Tiptap's own ``` + space rule would open a fence inside a + // list item or quote, where the serializer has no line for + // it. Enter on a fence line covers the gesture at top level. + addInputRules() { + return []; + }, }), composerCodeBlockHighlight({ resolveTheme: () => @@ -1324,9 +1330,20 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { } const editorInstance = editorHolder.current; if (editorInstance) { - insertMarkdownParagraphs(text, skillLabelFor, { styling: richText }, (content) => { - editorInstance.commands.insertContent(content); - }); + // Inside a list item or quote, pasted block markup has nowhere to + // go: it stays literal lines the next rebuild reads back. + const $paste = view.state.selection.$from; + const nested = ["listItem", "taskItem", "blockquote"].some((name) => + hasAncestor($paste, name), + ); + insertMarkdownParagraphs( + text, + skillLabelFor, + { styling: richText && !nested }, + (content) => { + editorInstance.commands.insertContent(content); + }, + ); scrollTiptapCaretIntoView(editorInstance); } return true; diff --git a/apps/web/src/composer-code-block.test.ts b/apps/web/src/composer-code-block.test.ts index c8267a42ec5a..f6d27cc20636 100644 --- a/apps/web/src/composer-code-block.test.ts +++ b/apps/web/src/composer-code-block.test.ts @@ -13,11 +13,12 @@ import { } from "./composer-code-block"; import StarterKit from "@tiptap/starter-kit"; -import { ComposerCodeBlockExtension } from "./composer-rich-text-doc"; +import { ComposerCodeBlockExtension, ComposerListExtensions } from "./composer-rich-text-doc"; const extensions = [ StarterKit.configure({ codeBlock: false, trailingNode: false }), ComposerCodeBlockExtension, + ...ComposerListExtensions, ]; describe("leadingWhitespace", () => { @@ -232,6 +233,34 @@ describe("convertCodeFenceOnEnter", () => { expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); }); + it("refuses to open a fence inside a list item", () => { + const text = "```ts"; + const editor = new Editor({ + extensions, + content: { + type: "doc", + content: [ + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [{ type: "paragraph", content: [{ type: "text", text }] }], + }, + ], + }, + ], + }, + }); + // Inside listItem > paragraph: position 3 is the paragraph start. + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 3 + text.length)), + ); + const before = editor.getJSON(); + expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); + expect(editor.getJSON()).toEqual(before); + }); + it("ignores a fence with the caret before its end", () => { const editor = paragraphEditor("```ts"); editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 3))); diff --git a/apps/web/src/composer-code-block.ts b/apps/web/src/composer-code-block.ts index f38a6e69a3b9..9ae317e76529 100644 --- a/apps/web/src/composer-code-block.ts +++ b/apps/web/src/composer-code-block.ts @@ -152,7 +152,9 @@ export function convertCodeFenceOnEnter( dispatch?: (transaction: Transaction) => void, ): boolean { const { $from, empty } = state.selection; - if (!empty || $from.parent.type.name !== "paragraph") return false; + // Top-level paragraphs only: the list and quote serializers have no line + // to write a fence into, so one created inside them would vanish. + if (!empty || $from.parent.type.name !== "paragraph" || $from.depth !== 1) return false; if ($from.parentOffset !== $from.parent.content.size) return false; const match = /^(`{3,}|~{3,})([A-Za-z0-9_+#.-]*)$/.exec($from.parent.textContent); const codeBlock = state.schema.nodes.codeBlock; From bcf7894381a94a758d0e8b5928b792f015feddf9 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Fri, 18 Sep 2026 16:29:13 +0100 Subject: [PATCH 16/43] fix(web): let a selection leaving a fence reach the composer's Tab and Enter The fence branches checked only where the selection starts, then their commands refused a selection ending elsewhere, so the key was swallowed. They now require the whole selection to sit in one code block and otherwise fall through. --- apps/web/src/components/ComposerPromptEditorTiptap.tsx | 10 ++++++++-- apps/web/src/composer-code-block.ts | 6 ++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 9488f2e8e2d8..55190514c1d3 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -64,6 +64,7 @@ import { convertCodeFenceOnEnter, indentCodeBlock, indentedNewlineInCodeBlock, + selectionInOneCodeBlock, } from "~/composer-code-block"; import { nextOrderedMarkerText } from "~/composer-list-continuation"; import { collectInlineContextIds } from "~/lib/composerContextReferences"; @@ -1158,7 +1159,12 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { } // Inside a fence Tab belongs to the code, not to the composer's // focus order or its autocomplete. - if (event.key === "Tab" && !event.metaKey && !event.ctrlKey && isInCodeBlock(view)) { + if ( + event.key === "Tab" && + !event.metaKey && + !event.ctrlKey && + selectionInOneCodeBlock(view.state) + ) { event.preventDefault(); event.stopPropagation(); return indentCodeBlock(view.state, event.shiftKey ? "out" : "in", (tr) => @@ -1177,7 +1183,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { !event.ctrlKey && !event.isComposing ) { - if (isInCodeBlock(view)) { + if (selectionInOneCodeBlock(view.state)) { event.preventDefault(); event.stopPropagation(); const dispatch = (tr: typeof view.state.tr) => view.dispatch(tr.scrollIntoView()); diff --git a/apps/web/src/composer-code-block.ts b/apps/web/src/composer-code-block.ts index 9ae317e76529..5dac39a70571 100644 --- a/apps/web/src/composer-code-block.ts +++ b/apps/web/src/composer-code-block.ts @@ -42,6 +42,12 @@ export function indentLines(lines: ReadonlyArray, direction: "in" | "out * same block: a selection that reaches past the fence belongs to the document, * not to the code. */ +/** Whether every endpoint of the selection sits in the same code block. */ +export function selectionInOneCodeBlock(state: EditorState): boolean { + const { $from, $to } = state.selection; + return $from.parent.type.spec.code === true && $from.sameParent($to); +} + function codeBlockRange( state: EditorState, ): { readonly from: number; readonly to: number; readonly text: string } | null { From a8a4ab84caa731e90afb48740c061fc2d98911fe Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Fri, 18 Sep 2026 16:29:14 +0100 Subject: [PATCH 17/43] fix(web): keep the whitespace typed after quote, heading and rule markers The quote, heading and rule input rules discarded the matched whitespace and stored a canonical space or a bare rule, so `>` followed by a tab, `#` then a tab, or `*** ` lost what was typed while the parser would have kept it. Each rule now stores the whitespace it matched. --- .../components/ComposerPromptEditorTiptap.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 55190514c1d3..11052d85765f 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -602,11 +602,14 @@ function hasAncestor($pos: ResolvedPos, name: string): boolean { * a quote holds prose lines, and a list item is not one. */ const blockquoteInputRule = new InputRule({ - find: /^>\s$/, - handler: ({ state, range, chain }) => { + find: /^>(\s)$/, + handler: ({ state, range, match, chain }) => { const $from = state.doc.resolve(range.from); if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null; - chain().deleteRange(range).wrapIn("blockquote", { prefix: "> " }).run(); + chain() + .deleteRange(range) + .wrapIn("blockquote", { prefix: `>${match[1] ?? " "}` }) + .run(); return undefined; }, }); @@ -622,8 +625,8 @@ const blockquoteInputRule = new InputRule({ const horizontalRuleInputRule = new InputRule({ find: /^(---|\*\*\*|___)\s?$/, handler: ({ state, range, match, chain }) => { - const source = match[1] ?? "---"; - if (source !== "---" && !/\s$/.test(match[0] ?? "")) return null; + const source = match[0] ?? "---"; + if (!source.startsWith("---") && !/\s$/.test(source)) return null; const $from = state.doc.resolve(range.from); if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null; chain() @@ -653,13 +656,13 @@ const horizontalRuleInputRule = new InputRule({ * serializers have no line for one. */ const headingInputRule = new InputRule({ - find: /^(#{1,6})\s$/, + find: /^(#{1,6})(\s)$/, handler: ({ state, range, match, chain }) => { const $from = state.doc.resolve(range.from); if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null; chain() .deleteRange(range) - .setNode("heading", { level: match[1]?.length ?? 1, space: " " }) + .setNode("heading", { level: match[1]?.length ?? 1, space: match[2] ?? " " }) .run(); return undefined; }, From 6045eec6e8fc95f71539f3058d1dbd3914a7c138 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Fri, 18 Sep 2026 16:29:15 +0100 Subject: [PATCH 18/43] fix(web): keep the composer's inline code styling off fenced code `.composer-tiptap code` outranked the fence node view's utility classes, so the body of a fence was drawn as a padded inline-code panel. The rule is now scoped to code outside a `pre`, as chat's is. --- apps/web/src/index.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 500de732abd3..d988b04d9ef8 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2176,7 +2176,7 @@ code { outline: none; } -.composer-tiptap code { +.composer-tiptap :not(pre) > code { font-family: var(--font-mono); font-size: 0.92em; background: color-mix(in oklab, var(--muted-foreground) 12%, transparent); From 9a2e2b388327cf95f9ff6ced37ad8a3fde1aa22d Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Fri, 18 Sep 2026 16:29:16 +0100 Subject: [PATCH 19/43] refactor(web): compose the composer fence from chat's code block frame `MarkdownCodeBlockFrame` renders the wrapper, header, language title and an optional actions slot; chat's code block and the composer node view both compose it, the latter with Tiptap's node view wrapper as the outer element. --- apps/web/src/components/ChatMarkdown.tsx | 81 +++++++++++++------ .../chat/ComposerCodeBlockNodeView.tsx | 35 +++----- 2 files changed, 67 insertions(+), 49 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 8479e7d06ac3..4b492e1e19ee 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -900,17 +900,54 @@ function MarkdownDetails({ */ /** * The code block frame, shared with the composer so an editable fence and a - * rendered one cannot drift apart: the wrapper, its header row, and the - * title slot the language icon sits in. + * rendered one cannot drift apart: the wrapper, its header row with the + * language title, an optional actions slot, and the body as children. The + * composer passes Tiptap's node view wrapper as the outer element. */ -export const MARKDOWN_CODE_BLOCK_CLASS_NAME = - "chat-markdown-codeblock my-[0.65rem] overflow-hidden rounded-lg border border-border/70 bg-secondary leading-snug dark:border-transparent dark:bg-input/32"; -export const MARKDOWN_CODE_BLOCK_HEADER_CLASS_NAME = - "chat-markdown-codeblock-header flex items-center justify-between gap-2 pt-1.5 pr-1.5 pb-0 pl-3 select-none"; -export const MARKDOWN_CODE_BLOCK_TITLE_CLASS_NAME = - "inline-flex min-w-0 items-center gap-1.5 font-mono text-2xs"; - -export function MarkdownCodeBlockTitleContent({ +export function MarkdownCodeBlockFrame({ + as: Wrapper = "div", + language, + fenceTitle, + theme, + wrapped = true, + actions, + headerProps, + children, +}: { + as?: React.ElementType; + language: string; + fenceTitle: string | null; + theme: "light" | "dark"; + wrapped?: boolean; + actions?: React.ReactNode; + headerProps?: React.HTMLAttributes; + children: React.ReactNode; +}) { + return ( + +
+ + + + {actions} +
+ {children} +
+ ); +} + +function MarkdownCodeBlockTitleContent({ fenceTitle, language, theme, @@ -1019,19 +1056,12 @@ function MarkdownCodeBlock({ ); return ( -
-
- - - + {copyLabel} -
+ } + > {children} -
+ ); } diff --git a/apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx b/apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx index 350c09e596a1..1fdce9aa9744 100644 --- a/apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx +++ b/apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx @@ -1,16 +1,11 @@ import { NodeViewContent, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; import { useTheme } from "../../hooks/useTheme"; -import { - MARKDOWN_CODE_BLOCK_CLASS_NAME, - MARKDOWN_CODE_BLOCK_HEADER_CLASS_NAME, - MARKDOWN_CODE_BLOCK_TITLE_CLASS_NAME, - MarkdownCodeBlockTitleContent, -} from "../ChatMarkdown"; +import { MarkdownCodeBlockFrame } from "../ChatMarkdown"; /** - * Draws a composer fence with the chat view's own code block frame, so a - * draft looks like the message it is about to become. + * Draws a composer fence in the chat view's own code block frame, so a draft + * looks like the message it is about to become. * * The chrome stops at the language icon. Chat's wrap and copy buttons act on * text the reader cannot change; here the text is the draft, and both are @@ -23,30 +18,22 @@ export function ComposerCodeBlockNodeView({ node }: NodeViewProps) { // when a fence arrives without an info string. const language = declared || "text"; return ( - -
- - - -
           {/* A caret needs a line to sit on even before any code is typed. */}
           
             as="code"
-            className="block min-h-[1lh] border-0 bg-transparent p-0 font-mono whitespace-pre-wrap [color:inherit] [font-size:var(--font-size-code,0.92em)] [overflow-wrap:anywhere]"
+            className="block min-h-[1lh] font-mono whitespace-pre-wrap [color:inherit] [font-size:var(--font-size-code,0.92em)] [overflow-wrap:anywhere]"
           />
         
-
+ ); } From 3da30675d4d929a7fbcf0af83baed8bc65ff83b7 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Sat, 19 Sep 2026 17:27:23 +0100 Subject: [PATCH 20/43] fix(web): move the source toggle beside the attach button The toggle sat with the model, effort and access controls, which configure the agent. It acts on the draft, so it now sits with the draft actions, left of attach, styled like it, and it swallows pointer-down so flipping never takes focus from the editor it is flipping. --- apps/web/src/components/chat/ChatComposer.tsx | 9 +++---- .../components/chat/ComposerSourceToggle.tsx | 26 +++++++++---------- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d42095979d29..185a37db8625 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -5029,11 +5029,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) data-resting-controls-separator="true" /> ) : null} - + {showComposerAttachAction ? ( <> void; }) { - const size = props.size ?? "sm"; const label = props.richTextEnabled ? LABEL_ON : LABEL_OFF; return ( event.preventDefault()} onClick={props.onToggle} /> } > - + - {label} + {label} ); }); From be717697de6d04c3cda04c11acb5e5785e065a5f Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Sat, 19 Sep 2026 20:29:01 +0100 Subject: [PATCH 21/43] refactor(web): build the source toggle on the shared Toggle primitive The control re-implemented a pressed state and its active colors on a Button. It is now the ui Toggle with `pressed` and `onPressedChange` in the ghost variant, which owns that state and styling, at the size of the attach button beside it. --- .../src/components/chat/ComposerSourceToggle.tsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/chat/ComposerSourceToggle.tsx b/apps/web/src/components/chat/ComposerSourceToggle.tsx index 9ba5f708eaf2..b2c9319f86d5 100644 --- a/apps/web/src/components/chat/ComposerSourceToggle.tsx +++ b/apps/web/src/components/chat/ComposerSourceToggle.tsx @@ -1,9 +1,7 @@ import { CodeIcon } from "lucide-react"; import { memo } from "react"; -import { cn } from "~/lib/utils"; - -import { Button } from "../ui/button"; +import { Toggle } from "../ui/toggle"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; const LABEL_ON = "Edit the Markdown source"; @@ -29,20 +27,18 @@ export const ComposerSourceToggle = memo(function ComposerSourceToggle(props: { event.preventDefault()} - onClick={props.onToggle} /> } > - + {label} From d966b45e4870cd1046ae34bbbadb8b7d39402401 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Mon, 21 Sep 2026 04:15:15 +0100 Subject: [PATCH 22/43] feat(web): toggle rich text from the composer with mod+/ The composer carried a button for flipping rich text, which is not flipped often enough to earn space in an already tight footer. The button is gone; the flip is now the configurable `composer.toggleRichText` shortcut, `mod+/` by default, alongside the Settings switch that stays. The shortcut keeps what the button had: it remounts the editor and the new instance takes the caret back, while a flip from Settings still leaves focus alone. --- apps/web/src/components/chat/ChatComposer.tsx | 36 +++++++++++---- .../components/chat/ComposerSourceToggle.tsx | 46 ------------------- .../KeybindingsSettings.logic.test.ts | 1 + apps/web/src/keybindings.test.ts | 29 ++++++++++++ docs/internals/composer-editors.md | 6 +-- docs/user/composer.md | 9 ++-- docs/user/keybindings.md | 1 + packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 9 files changed, 67 insertions(+), 63 deletions(-) delete mode 100644 apps/web/src/components/chat/ComposerSourceToggle.tsx diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 185a37db8625..654ea7ef1d37 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -243,7 +243,6 @@ import { } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; import { useDebouncedValue } from "~/state/queries"; -import { ComposerSourceToggle } from "./ComposerSourceToggle"; import { ProviderModelPicker } from "./ProviderModelPicker"; import { resolveModelPickerSelectedModel } from "./ModelPickerContent"; import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; @@ -2136,11 +2135,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const hasMultilinePrompt = prompt.includes("\n") || hasWrappedPrompt; const [isStashMenuOpen, setIsStashMenuOpen] = useState(false); const updateClientSettings = useUpdateClientSettings(); - // Flipping the setting remounts the editor, which drops focus. The token - // tells the new instance that the user asked for this from the composer and - // wants the caret back; changing the same setting from Settings leaves it - // alone. Rich text is a client setting rather than composer state, so both - // entry points drive the same switch. + // Flipping the setting remounts the editor, which drops focus. The flag + // tells the new instance that the user asked for this from the composer's + // shortcut and wants the caret back; changing the same setting from + // Settings leaves it alone. Rich text is a client setting rather than + // composer state, so both entry points drive the same switch. const [pendingComposerFocusRestore, setPendingComposerFocusRestore] = useState(false); const toggleComposerRichText = useCallback(() => { setPendingComposerFocusRestore(true); @@ -5220,6 +5219,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsStashMenuOpen(false); }, [prompt]); + // The composer's own way to flip rich text, so the caret comes back after + // the remount. The Settings panel flips the same setting without a restore. + useEffect(() => { + const handler = (event: globalThis.KeyboardEvent) => { + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: getTerminalFocusOwner() !== null, + terminalOpen, + modelPickerOpen: isComposerModelPickerOpen, + }, + }); + if (command !== "composer.toggleRichText") return; + event.preventDefault(); + event.stopPropagation(); + if (isCommandPaletteOpen()) return; + toggleComposerRichText(); + }; + window.addEventListener("keydown", handler, true); + return () => window.removeEventListener("keydown", handler, true); + }, [isComposerModelPickerOpen, keybindings, terminalOpen, toggleComposerRichText]); + useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { const command = resolveShortcutCommand(event, keybindings, { @@ -6990,10 +7010,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } className="flex shrink-0 flex-nowrap items-center justify-end gap-2" > - {showComposerAttachAction ? ( <> void; -}) { - const label = props.richTextEnabled ? LABEL_ON : LABEL_OFF; - return ( - - event.preventDefault()} - /> - } - > - - - {label} - - ); -}); diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 7729ce183754..f83eb4daf4e1 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -26,6 +26,7 @@ describe("KeybindingsSettings.logic", () => { "composer.workspace", "composer.branch", "composer.previousWorktree", + "composer.toggleRichText", "modelPicker.previousProvider", "modelPicker.nextProvider", "thread.copyReference", diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index a5fc2374be56..3c65fb7cd9aa 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -160,6 +160,11 @@ const DEFAULT_BINDINGS = compile([ command: "thread.settle", whenAst: whenNot(whenIdentifier("terminalFocus")), }, + { + shortcut: modShortcut("/"), + command: "composer.toggleRichText", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("1"), command: "thread.jump.1" }, { shortcut: modShortcut("2"), command: "thread.jump.2" }, { shortcut: modShortcut("3"), command: "thread.jump.3" }, @@ -1247,6 +1252,30 @@ describe("composer and pull request shortcuts", () => { }, ); + it("binds mod+/ to the composer rich text toggle on every platform", () => { + assert.equal( + resolveShortcutCommand(event({ key: "/", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "composer.toggleRichText", + ); + assert.equal( + resolveShortcutCommand(event({ key: "/", ctrlKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: false }, + }), + "composer.toggleRichText", + ); + assert.notEqual( + resolveShortcutCommand(event({ key: "/", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + "composer.toggleRichText", + ); + }); + const shortcuts = [ ["h", "composer.host"], ["e", "composer.effort"], diff --git a/docs/internals/composer-editors.md b/docs/internals/composer-editors.md index 482457cea11f..ce6d5e01dec0 100644 --- a/docs/internals/composer-editors.md +++ b/docs/internals/composer-editors.md @@ -97,7 +97,7 @@ lines exit the block, which is the only way out of a fence at the end of a promp Highlighting is Shiki decorations over the editable text, per block and cached by content, so a keystroke re-tokenizes only the block that changed. -The source toggle is not a second editor. It writes `composerRichTextEnabled`, +The `composer.toggleRichText` shortcut is not a second editor. It writes `composerRichTextEnabled`, which remounts the same engine with the mark extensions off, so the draft and its chips survive the flip. The surface does not change font: plain mode is the same prose the user was already looking at, minus the styling. @@ -105,8 +105,8 @@ prose the user was already looking at, minus the styling. The caret survives it too. A collapsed cursor means the same offset in both modes, because markers are literal characters in the stored value either way, so the remounted editor restores it from the stored cursor. Two traps sit in the way. -The flip has to be signalled by the control that was clicked rather than derived -from the setting: adjusting state during render makes React discard that render +The flip has to be signalled by the shortcut handler rather than derived from +the setting: adjusting state during render makes React discard that render pass including its children, so a flag computed that way never reaches the editor being mounted, and flipping the setting from Settings should not pull focus into the composer anyway. And `useEditor` returns null on its first render diff --git a/docs/user/composer.md b/docs/user/composer.md index 12a539ee1ef5..42d74370a539 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -50,10 +50,11 @@ language, then press Enter or Space. Inside a fence: Code is syntax highlighted using your current theme, and the language you declared is named in the corner of the block. -Use **Edit the Markdown source** in the composer controls to switch to literal -Markdown, with every marker shown as you typed it and nothing styled. Chips, -attachments and your cursor survive the switch, so you can move between the two -freely. The same switch lives in Settings as the rich text composer option. +Press `mod+/` to switch the composer to literal Markdown, with every marker +shown as you typed it and nothing styled, and again to come back. Chips, +attachments and your cursor survive the switch. The same switch lives in +Settings as the rich text composer option, and the shortcut can be changed +under Settings → Keybindings. ## Attach files diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 2525fed64d94..6e0456292f0f 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -19,6 +19,7 @@ Use `mod+shift+e` for effort, `mod+shift+a` for access mode, `mod+shift+x` for t workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the current checkout, a new worktree, and the previous worktree when available. Use `mod+shift+l` to reuse the previous worktree directly. +Use `mod+/` to switch the composer between rich text and its Markdown source. In the model picker, press Left in an empty search field or Shift+Tab to reach the provider list. Use Up/Down to move and Enter to choose. Right returns to diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 239f9905c534..2ad7e8064524 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -83,6 +83,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ "appearance.cycle", "themeEditor.toggle", "composer.stash", + "composer.toggleRichText", "composer.host", "composer.effort", "composer.mode", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 8029be96f939..abd134598c0c 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -45,6 +45,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+alt+shift+a", command: "appearance.cycle", when: "!terminalFocus" }, { key: "mod+alt+shift+t", command: "themeEditor.toggle" }, { key: "mod+s", command: "composer.stash", when: "!terminalFocus" }, + { key: "mod+/", command: "composer.toggleRichText", when: "!terminalFocus" }, { key: "mod+shift+enter", command: "thread.steerQueuedMessage", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, From 97eb21ed6b8cc7f101a2449967eea51f2142d7ee Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Mon, 21 Sep 2026 04:20:21 +0100 Subject: [PATCH 23/43] chore(web): format package.json after the rebase resolution The dependency block merged by hand during the rebase onto main was not run through the formatter, since rebase commits skip the pre-commit hook. --- apps/web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/package.json b/apps/web/package.json index 2610a4d70114..9e7cc0d45fb1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -32,8 +32,8 @@ "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-router": "^1.160.2", "@tiptap/core": "^3.31.3", - "@tiptap/extension-code": "^3.31.3", "@tiptap/extension-blockquote": "^3.31.3", + "@tiptap/extension-code": "^3.31.3", "@tiptap/extension-code-block": "^3.31.3", "@tiptap/extension-heading": "^3.31.3", "@tiptap/extension-horizontal-rule": "^3.31.3", From a0d433b3bb41a54d44df551eac0b9723c0109ddb Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Mon, 21 Sep 2026 04:24:53 +0100 Subject: [PATCH 24/43] fix(web): one press of the rich text shortcut is one flip A held mod+/ auto-repeats keydown, and every repeat flipped the setting and remounted the editor. Repeats are swallowed but not acted on. --- apps/web/src/components/chat/ChatComposer.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 654ea7ef1d37..f29f839c0ed2 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -5233,7 +5233,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (command !== "composer.toggleRichText") return; event.preventDefault(); event.stopPropagation(); - if (isCommandPaletteOpen()) return; + // A held key auto-repeats keydown; one press is one flip. + if (event.repeat || isCommandPaletteOpen()) return; toggleComposerRichText(); }; window.addEventListener("keydown", handler, true); From f040557a2444a3a4e2dbc9dc546ecbfa6f1c54ee Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Mon, 21 Sep 2026 04:24:54 +0100 Subject: [PATCH 25/43] fix(web): trim the fence info string before highlighting The stored language keeps its whitespace so the fence round-trips, but `typescript ` is not a language Shiki knows, so such a fence fell back to plain text. The highlighter now gets the trimmed name, as the header does. --- apps/web/src/components/composerCodeBlockHighlight.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/composerCodeBlockHighlight.ts b/apps/web/src/components/composerCodeBlockHighlight.ts index 1e4577ec0447..7184c148ab43 100644 --- a/apps/web/src/components/composerCodeBlockHighlight.ts +++ b/apps/web/src/components/composerCodeBlockHighlight.ts @@ -131,7 +131,8 @@ export function composerCodeBlockHighlight(options: { void Promise.all( pending.map(async ({ node }) => { - const language = String(node.attrs.language ?? "") || "text"; + // The stored info string keeps its whitespace; Shiki wants the name. + const language = String(node.attrs.language ?? "").trim() || "text"; const signature = blockSignature(node, theme); const highlighter = await getSyntaxHighlighterPromise(language); if (disposed) return; From e0d3e9f43d67cde8ac438a5ade7700374809233f Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Mon, 21 Sep 2026 04:24:55 +0100 Subject: [PATCH 26/43] fix(web): convert any bullet to a task with [ ] `[ ] ` at the start of a `*` or `+` item stayed literal because the conversion only accepted `-`. Any bullet converts now; since the task grammar only knows `-`, the item comes back out as `- [ ]`. --- apps/web/src/components/ComposerPromptEditorTiptap.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 11052d85765f..eb1db082e022 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -579,7 +579,9 @@ const bulletToTaskInputRule = new InputRule({ const $from = state.doc.resolve(range.from); const item = $from.node(-1); if ($from.parent.type.name !== "paragraph" || item?.type.name !== "listItem") return null; - if ((item.attrs as { marker?: string }).marker !== "-") return null; + // Any bullet converts; the task grammar only knows `-`, so a `*` or `+` + // item comes back out as `- [ ]`. + if (!["-", "*", "+"].includes((item.attrs as { marker?: string }).marker ?? "")) return null; const indent = typeof item.attrs.indent === "string" ? item.attrs.indent : ""; chain() .deleteRange(range) From ae3deb423a6695e67bde526d26b273f4f02a0594 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Mon, 21 Sep 2026 04:25:45 +0100 Subject: [PATCH 27/43] fix(web): let code block frame callers add header classes The frame spread `headerProps` and then set the header's class, so a caller's class was overwritten. The two are merged now. --- apps/web/src/components/ChatMarkdown.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 4b492e1e19ee..e4623024fc93 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -931,7 +931,10 @@ export function MarkdownCodeBlockFrame({ >
Date: Mon, 28 Sep 2026 13:59:26 +0100 Subject: [PATCH 28/43] fix(web): order Usage keybinding rows the same for any input order `usage.open` was missing from the Usage command ranking, so it compared alphabetically against commands that compared by page position. That comparator is inconsistent, and the resulting order depended on the order the bindings arrived in: adding any default binding reordered the Usage rows. The test's expected order was one such accident. `usage.open` now ranks first, ahead of the page's own order, and a test checks the order no longer moves with the input. --- .../settings/KeybindingsSettings.logic.test.ts | 12 +++++++++++- .../components/settings/KeybindingsSettings.logic.ts | 8 +++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index f83eb4daf4e1..2992abc1b369 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -49,8 +49,8 @@ describe("KeybindingsSettings.logic", () => { ); it("orders Usage bindings and command choices like the page", () => { const expected = [ - "usage.cost", "usage.open", + "usage.cost", "usage.tokens", "usage.limits", "usage.period.day", @@ -65,6 +65,16 @@ describe("KeybindingsSettings.logic", () => { ).toEqual(expected); }); + it("orders Usage bindings the same whatever order the bindings arrive in", () => { + const rows = (bindings: typeof DEFAULT_RESOLVED_KEYBINDINGS) => + buildKeybindingRows(bindings, "usage").map((row) => row.command); + const forward = rows(DEFAULT_RESOLVED_KEYBINDINGS); + expect(rows(DEFAULT_RESOLVED_KEYBINDINGS.toReversed())).toEqual(forward); + expect( + rows([...DEFAULT_RESOLVED_KEYBINDINGS.slice(1), DEFAULT_RESOLVED_KEYBINDINGS[0]!]), + ).toEqual(forward); + }); + it("builds searchable rows with readable key and when values", () => { const rows = buildKeybindingRows( [ diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index a06628e5d505..d88f68d92d52 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -15,8 +15,14 @@ import { shortcutKeyFromEvent } from "../../keybindings"; import { isMacPlatform } from "../../lib/utils"; import { METRIC_OPTIONS, WINDOW_OPTIONS } from "../usage/usageShortcuts"; +// Opening the page first, then the page's own order. Every usage command must +// be ranked here: one left out compares alphabetically against the rest, which +// makes the comparator inconsistent and the order depend on the input. const usageCommandOrder = new Map( - [...METRIC_OPTIONS, ...WINDOW_OPTIONS].map((option, index) => [option.command, index]), + [ + "usage.open" as const, + ...[...METRIC_OPTIONS, ...WINDOW_OPTIONS].map((option) => option.command), + ].map((command, index) => [command, index]), ); function compareUsageCommands(left: KeybindingCommand, right: KeybindingCommand): number | null { From 709ee8d0a17090dc4b42a59213dca2e13ed7a170 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Mon, 28 Sep 2026 14:13:41 +0100 Subject: [PATCH 29/43] fix(web): style the composer fence body from theme values Main's new no-arbitrary-values rule rejected three off-token utilities on the fence's code element. Its font size now comes from the shared `pre code` rule chat already uses, widened to the composer; the padding and colour use scale values. --- apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx | 4 ++-- apps/web/src/index.css | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx b/apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx index 1fdce9aa9744..3fabeb7e725d 100644 --- a/apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx +++ b/apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx @@ -26,11 +26,11 @@ export function ComposerCodeBlockNodeView({ node }: NodeViewProps) { headerProps={{ contentEditable: false }} >
-
+        
           {/* A caret needs a line to sit on even before any code is typed. */}
           
             as="code"
-            className="block min-h-[1lh] font-mono whitespace-pre-wrap [color:inherit] [font-size:var(--font-size-code,0.92em)] [overflow-wrap:anywhere]"
+            className="block min-h-[1lh] font-mono whitespace-pre-wrap text-inherit [overflow-wrap:anywhere]"
           />
         
diff --git a/apps/web/src/index.css b/apps/web/src/index.css index d988b04d9ef8..61cce184142d 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1651,7 +1651,7 @@ code { Inline code stays relative to its sentence so it never towers over prose; diffs and file previews take the size through --diffs-font-size. */ .chat-markdown .chat-markdown-shiki .shiki, -.chat-markdown pre code { +:is(.chat-markdown, .composer-tiptap) pre code { font-size: var(--font-size-code, inherit); } From 6c33c25bd1705e188883cee36faf05603a19e1c4 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Mon, 28 Sep 2026 14:13:43 +0100 Subject: [PATCH 30/43] test(web): lock in that splitting a list item keeps its source marker Shift+Enter splits an item with Tiptap's splitListItem, which carries every attribute left at keepOnSplit: true onto the new item and merges overrides on top. These tests hold `*`, `+`, `3)`, nested and task items to that, so the stored Markdown keeps the marker, indent and spacing the user typed. --- apps/web/src/composer-list-split.test.ts | 57 ++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 apps/web/src/composer-list-split.test.ts diff --git a/apps/web/src/composer-list-split.test.ts b/apps/web/src/composer-list-split.test.ts new file mode 100644 index 000000000000..66b29d39e3b5 --- /dev/null +++ b/apps/web/src/composer-list-split.test.ts @@ -0,0 +1,57 @@ +import { Editor } from "@tiptap/core"; +import { TaskList } from "@tiptap/extension-task-list"; +import { TextSelection } from "@tiptap/pm/state"; +import StarterKit from "@tiptap/starter-kit"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildDocJson, + ComposerListExtensions, + ComposerTaskItemExtension, + serializeEditorDoc, +} from "./composer-rich-text-doc"; + +/** + * Splits the last item of `value` the way Shift+Enter does, types `b`, and + * returns the stored Markdown. Tiptap carries every attribute left at the + * default `keepOnSplit: true` onto the new item and merges the overrides on + * top, so the new item must keep the source marker, indent and spacing. + */ +function splitLastItem(value: string, type: "listItem" | "taskItem", overrides: object) { + const editor = new Editor({ + extensions: [ + StarterKit.configure({ + bulletList: false, + orderedList: false, + listItem: false, + codeBlock: false, + trailingNode: false, + }), + ...ComposerListExtensions, + TaskList, + ComposerTaskItemExtension, + ], + content: buildDocJson(value, (name) => ({ label: name, description: null })), + }); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); + editor.commands.splitListItem(type, overrides); + editor.view.dispatch(editor.state.tr.insertText("b")); + return serializeEditorDoc(editor.state.doc).value; +} + +describe("splitting a list item", () => { + it.each([ + ["* a", { space: " " }, "* a\n* b"], + ["+ a", { space: " " }, "+ a\n+ b"], + ["- p\n - a", { space: " " }, "- p\n - a\n - b"], + ["3) a", { marker: "4)", space: " " }, "3) a\n4) b"], + ])("keeps the source marker and indent of %s", (value, overrides, expected) => { + expect(splitLastItem(value, "listItem", overrides)).toBe(expected); + }); + + it("keeps the indent of a nested task", () => { + expect(splitLastItem("- [ ] p\n - [ ] a", "taskItem", { checked: false })).toBe( + "- [ ] p\n - [ ] a\n - [ ] b", + ); + }); +}); From 0df805d14e61f983b50f84e59580e173f916af47 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Mon, 28 Sep 2026 16:38:39 +0100 Subject: [PATCH 31/43] feat(web): choose a fence's language from its header A fence's language was fixed by its opening line, so changing it meant flipping to the Markdown source, editing the fence and flipping back. The composer fence's header is now a language picker: a searchable list of common languages with their icons, plain text first, and whatever language the fence already declares shown and checked even when the list does not carry it. Choosing rewrites only the first word of the info string, so `js title=example` keeps its `title=example`; plain text clears the language, or writes `text` when something follows so the rest cannot be read as one. The change is one undo step and hands the caret back to the end of the code. The highlighter now reads that first word too, so a fence with extra info is still highlighted. Chat's own code block header is unchanged. --- apps/web/src/components/ChatMarkdown.tsx | 19 ++- .../components/ComposerPromptEditorTiptap.tsx | 10 +- .../chat/ComposerCodeBlockLanguagePicker.tsx | 122 ++++++++++++++++++ .../chat/ComposerCodeBlockNodeView.tsx | 48 +++++-- .../components/composerCodeBlockHighlight.ts | 7 +- apps/web/src/composer-code-languages.test.ts | 54 ++++++++ apps/web/src/composer-code-languages.ts | 95 ++++++++++++++ docs/user/composer.md | 4 +- 8 files changed, 337 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerCodeBlockLanguagePicker.tsx create mode 100644 apps/web/src/composer-code-languages.test.ts create mode 100644 apps/web/src/composer-code-languages.ts diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e4623024fc93..b2b5be7aa587 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -910,6 +910,7 @@ export function MarkdownCodeBlockFrame({ fenceTitle, theme, wrapped = true, + title, actions, headerProps, children, @@ -919,6 +920,8 @@ export function MarkdownCodeBlockFrame({ fenceTitle: string | null; theme: "light" | "dark"; wrapped?: boolean; + /** Replaces the language title, as the composer does with its language picker. */ + title?: React.ReactNode; actions?: React.ReactNode; headerProps?: React.HTMLAttributes; children: React.ReactNode; @@ -936,13 +939,15 @@ export function MarkdownCodeBlockFrame({ headerProps?.className, )} > - - - + {title ?? ( + + + + )} {actions}
{children} diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index eb1db082e022..71a5b7bddf86 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -967,7 +967,15 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ComposerCodeExtension, ComposerCodeBlockExtension.extend({ addNodeView() { - return ReactNodeViewRenderer(ComposerCodeBlockNodeView); + return ReactNodeViewRenderer(ComposerCodeBlockNodeView, { + // The header holds the language picker. Tiptap only shields + // events aimed at the button element itself, so a click on + // its icon or label would reach ProseMirror and move the + // selection; keep every header event away from the editor. + stopEvent: ({ event }) => + event.target instanceof Element && + event.target.closest(".chat-markdown-codeblock-header") !== null, + }); }, // Tiptap's own ``` + space rule would open a fence inside a // list item or quote, where the serializer has no line for diff --git a/apps/web/src/components/chat/ComposerCodeBlockLanguagePicker.tsx b/apps/web/src/components/chat/ComposerCodeBlockLanguagePicker.tsx new file mode 100644 index 000000000000..4dd30df92518 --- /dev/null +++ b/apps/web/src/components/chat/ComposerCodeBlockLanguagePicker.tsx @@ -0,0 +1,122 @@ +import { CheckIcon, ChevronDownIcon } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { + CODE_BLOCK_LANGUAGES, + type CodeBlockLanguage, + codeLanguageEntry, + codeLanguageLabel, +} from "~/composer-code-languages"; +import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "~/pierre-icons"; + +import { Button } from "../ui/button"; +import { + Combobox, + ComboboxEmpty, + ComboboxItem, + ComboboxList, + ComboboxPopup, + ComboboxSearchInput, + ComboboxTrigger, +} from "../ui/combobox"; +import { PierreEntryIcon } from "./PierreEntryIcon"; + +/** + * The language's file icon. In list rows a language without one keeps the + * icon's space, so every label starts at the same edge. + */ +function LanguageIcon(props: { + language: string; + theme: "light" | "dark"; + reserveSpace?: boolean; +}) { + const fileName = syntheticFileNameForLanguageId(props.language); + if (!props.language || !hasSpecificPierreIconForFileName(fileName)) { + return props.reserveSpace ?