@@ -546,7 +547,7 @@ const PlanCard = ({
-
+
{message.plan}
@@ -637,7 +638,7 @@ export const StreamingBubble = ({ text }: StreamingBubbleProps) => {
{text ? (
diff --git a/src/workspaces/officerdev/src/apps/Chat/components/remark-line-breaks.test.ts b/src/workspaces/officerdev/src/apps/Chat/components/remark-line-breaks.test.ts
new file mode 100644
index 00000000..ceecb044
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Chat/components/remark-line-breaks.test.ts
@@ -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' },
+ ]);
+});
diff --git a/src/workspaces/officerdev/src/apps/Chat/components/remark-line-breaks.ts b/src/workspaces/officerdev/src/apps/Chat/components/remark-line-breaks.ts
new file mode 100644
index 00000000..2957559e
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Chat/components/remark-line-breaks.ts
@@ -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);
+ };
+}