Shift+Enter renders as a line break
the message was never wrong, which is worth saying first: Shift+Enter puts a real `\n` in the textarea, and a real `\n` is stored, sent, and given to the agent. what loses it is MARKDOWN'S OWN RULE — inside a paragraph a single line ending is whitespace, and only a blank line starts a new one. "one\ntwo" rendering as "one two" is correct CommonMark. it is right for prose and wrong for a chat box, where somebody pressing Shift+Enter is asking for a line. so the fix is at RENDER time, not on the way to disk. appending two spaces to every line as a message is saved would edit what somebody typed, and every consumer downstream — the agent, a transcript, an export — would carry trailing whitespace nobody asked for to satisfy a display concern. this is what `remark-breaks` does, and it is not in the lockfile. `remark-mentions` beside it already records why that is not a reason to add one: installs are frozen, and a dependency for a short tree walk is a deliberate lockfile change for no benefit. same shape, same file, one more local plugin. IT ONLY TOUCHES TEXT NODES, and that is the property that makes it safe rather than a precaution. by the time this runs the tree is parsed, so a newline inside a fence is the VALUE of a `code` node and never a `text` node — fenced code, inline code and tables cannot be reached. tested, not assumed. empty segments are dropped, so a trailing newline gives ONE break rather than a break followed by an empty text node — which renders as a blank line and is the usual way this ends up double-spacing everything after it. 7 tests: the break itself, several breaks, the trailing-newline case, code and inline code untouched, text without newlines unchanged, and nesting — emphasis, links and mentions all wrap text, so the walk has to go in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -3,6 +3,7 @@ import { useState, useRef, useEffect } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { remarkMentions, MENTION_HREF } from './remark-mentions';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { remarkLineBreaks } from './remark-line-breaks';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
|
||||
import {
|
||||
@@ -202,7 +203,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
|
||||
base64 blob pushes the bubble past max-w and out of the pane. */}
|
||||
<div className="chat-md break-words">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMentions]}
|
||||
remarkPlugins={[remarkGfm, remarkMentions, remarkLineBreaks]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
@@ -248,7 +249,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
|
||||
<div className="rounded-2xl rounded-tl-sm bg-muted/50 border border-border/50 px-4 py-2.5">
|
||||
<div className="chat-md">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMentions]}
|
||||
remarkPlugins={[remarkGfm, remarkMentions, remarkLineBreaks]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
@@ -546,7 +547,7 @@ const PlanCard = ({
|
||||
</div>
|
||||
|
||||
<div className="max-h-96 overflow-y-auto break-words">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMentions]} components={MD_COMPONENTS}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMentions, remarkLineBreaks]} components={MD_COMPONENTS}>
|
||||
{message.plan}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
@@ -637,7 +638,7 @@ export const StreamingBubble = ({ text }: StreamingBubbleProps) => {
|
||||
{text ? (
|
||||
<div className="chat-md">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMentions]}
|
||||
remarkPlugins={[remarkGfm, remarkMentions, remarkLineBreaks]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { remarkLineBreaks } from './remark-line-breaks';
|
||||
|
||||
type MdNode = { type: string; value?: string; children?: MdNode[] };
|
||||
|
||||
const run = (tree: MdNode): MdNode => {
|
||||
remarkLineBreaks()(tree);
|
||||
return tree;
|
||||
};
|
||||
|
||||
const para = (...children: MdNode[]): MdNode => ({ type: 'paragraph', children });
|
||||
const text = (value: string): MdNode => ({ type: 'text', value });
|
||||
|
||||
test('a single newline becomes a break', () => {
|
||||
const tree = run({ type: 'root', children: [para(text('one\ntwo'))] });
|
||||
expect(tree.children![0]!.children).toEqual([
|
||||
{ type: 'text', value: 'one' },
|
||||
{ type: 'break' },
|
||||
{ type: 'text', value: 'two' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('several newlines make several breaks', () => {
|
||||
const tree = run({ type: 'root', children: [para(text('a\nb\nc'))] });
|
||||
expect(tree.children![0]!.children!.filter((n) => n.type === 'break')).toHaveLength(2);
|
||||
});
|
||||
|
||||
// The empty-segment case: a trailing newline must give one break, never a break plus an empty text
|
||||
// node, which renders as an extra blank line and double-spaces everything after it.
|
||||
test('a trailing newline does not leave an empty text node', () => {
|
||||
const tree = run({ type: 'root', children: [para(text('one\n'))] });
|
||||
expect(tree.children![0]!.children).toEqual([{ type: 'text', value: 'one' }, { type: 'break' }]);
|
||||
});
|
||||
|
||||
// The property that makes this safe: by the time it runs, a newline inside a fence is the VALUE of a
|
||||
// `code` node, never a `text` node. Walking text alone cannot reach it.
|
||||
test('code blocks are untouched', () => {
|
||||
const code: MdNode = { type: 'code', value: 'line one\nline two' };
|
||||
const tree = run({ type: 'root', children: [code] });
|
||||
expect(tree.children![0]).toEqual({ type: 'code', value: 'line one\nline two' });
|
||||
});
|
||||
|
||||
test('inline code is untouched', () => {
|
||||
const tree = run({ type: 'root', children: [para({ type: 'inlineCode', value: 'a\nb' })] });
|
||||
expect(tree.children![0]!.children![0]).toEqual({ type: 'inlineCode', value: 'a\nb' });
|
||||
});
|
||||
|
||||
test('text without newlines is left exactly as it was', () => {
|
||||
const tree = run({ type: 'root', children: [para(text('nothing to do here'))] });
|
||||
expect(tree.children![0]!.children).toEqual([{ type: 'text', value: 'nothing to do here' }]);
|
||||
});
|
||||
|
||||
// Emphasis, links and mentions all nest text — the walk has to go into them, not just the top level.
|
||||
test('nested nodes are walked', () => {
|
||||
const tree = run({ type: 'root', children: [para({ type: 'emphasis', children: [text('one\ntwo')] })] });
|
||||
expect(tree.children![0]!.children![0]!.children).toEqual([
|
||||
{ type: 'text', value: 'one' },
|
||||
{ type: 'break' },
|
||||
{ type: 'text', value: 'two' },
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Make a single newline a line break, the way every chat app does.
|
||||
*
|
||||
* ── The message was never wrong ──
|
||||
*
|
||||
* Shift+Enter puts a real `\n` in the textarea and a real `\n` is stored, sent and given to the agent.
|
||||
* What loses it is MARKDOWN'S OWN RULE: inside a paragraph, a single line ending is whitespace, and only
|
||||
* a blank line starts a new paragraph. So
|
||||
*
|
||||
* one
|
||||
* two
|
||||
*
|
||||
* renders as "one two", correctly, per CommonMark. That is right for prose and wrong for a chat box,
|
||||
* where somebody pressing Shift+Enter is asking for a line.
|
||||
*
|
||||
* The fix therefore belongs at RENDER time. The alternative — appending two spaces to every line as the
|
||||
* message is saved — would edit what somebody typed on its way to disk, and every consumer downstream
|
||||
* (the agent, a transcript, an export) would carry trailing whitespace nobody asked for to satisfy a
|
||||
* display concern.
|
||||
*
|
||||
* This is what `remark-breaks` does. It is not in the lockfile, and `remark-mentions` beside this file
|
||||
* records the reasoning for not adding one: installs are frozen, and a dependency for a short tree walk
|
||||
* is a deliberate lockfile change for no benefit.
|
||||
*
|
||||
* ── Why the split is only inside text nodes ──
|
||||
*
|
||||
* By the time this runs the tree is already parsed, so a newline inside a code fence is the VALUE of a
|
||||
* `code` node and never a `text` node — walking text alone means fenced code, inline code and tables are
|
||||
* untouched. That is the property that makes this safe: it cannot reach anything where a newline means
|
||||
* something else.
|
||||
*/
|
||||
|
||||
type MdNode = { type: string; value?: string; children?: MdNode[] };
|
||||
|
||||
export function remarkLineBreaks() {
|
||||
return (tree: MdNode) => {
|
||||
const walk = (node: MdNode) => {
|
||||
if (!node.children?.length) return;
|
||||
|
||||
const next: MdNode[] = [];
|
||||
for (const child of node.children) {
|
||||
if (child.type !== 'text' || !child.value?.includes('\n')) {
|
||||
walk(child);
|
||||
next.push(child);
|
||||
continue;
|
||||
}
|
||||
|
||||
/*
|
||||
`one\ntwo` becomes text("one"), break, text("two").
|
||||
|
||||
Empty segments are dropped rather than emitted, so a trailing newline produces one break and
|
||||
not a break followed by an empty text node — which renders as an extra blank line and is the
|
||||
usual way this ends up double-spacing everything.
|
||||
*/
|
||||
const parts = child.value.split('\n');
|
||||
parts.forEach((part, index) => {
|
||||
if (index > 0) next.push({ type: 'break' });
|
||||
if (part) next.push({ type: 'text', value: part });
|
||||
});
|
||||
}
|
||||
|
||||
node.children = next;
|
||||
};
|
||||
|
||||
walk(tree);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user