diff --git a/app/src/utils/__tests__/agentMessageBubbles.test.ts b/app/src/utils/__tests__/agentMessageBubbles.test.ts index 184cc824e..fa92ee343 100644 --- a/app/src/utils/__tests__/agentMessageBubbles.test.ts +++ b/app/src/utils/__tests__/agentMessageBubbles.test.ts @@ -51,6 +51,29 @@ describe('splitAgentMessageIntoBubbles', () => { ]); }); + it('does not absorb a following prose line whose pipes give a different column count', () => { + // The prose line satisfies looksLikeMarkdownTableRow (it contains pipes) but + // has 3 "cells" vs the table's 2; absorbing it makes parseMarkdownTable + // reject the block (null), suppressing the table renderer and merging the + // prose into the table bubble. + const content = + '| Plan | Cost |\n| --- | --- |\n| Basic | $10 |\nEither pick Basic | Pro, then run `ps aux | grep node`.'; + expect(splitAgentMessageIntoBubbles(content)).toEqual([ + '| Plan | Cost |\n| --- | --- |\n| Basic | $10 |', + 'Either pick Basic | Pro, then run `ps aux | grep node`.', + ]); + }); + + it('keeps a table whose first data row carries a code-span pipe as one readable block', () => { + // splitMarkdownTableCells splits naively on "|", so a genuine first data row + // with a pipe inside a code span over-counts its cells (3 vs the header's 2). + // Dropping it would leave a header+separator-only block that renders as an + // empty-row table and detaches the real row; keeping the first data row makes + // parseMarkdownTable return null so the block falls back to readable markdown. + const content = '| Command | Use |\n| --- | --- |\n| `ps aux | grep node` | Find it |'; + expect(splitAgentMessageIntoBubbles(content)).toEqual([content]); + }); + it('keeps double-newline paragraphs in the same bubble', () => { const content = 'First line\nSecond line\n\nThird paragraph\nFourth line'; expect(splitAgentMessageIntoBubbles(content)).toEqual([ diff --git a/app/src/utils/agentMessageBubbles.ts b/app/src/utils/agentMessageBubbles.ts index f2326a672..8ed42cb79 100644 --- a/app/src/utils/agentMessageBubbles.ts +++ b/app/src/utils/agentMessageBubbles.ts @@ -47,10 +47,29 @@ export function splitAgentMessageIntoBubbles(content: string): string[] { if (currentLines.length > 0) { flushCurrent(); } + // A prose line that merely contains a pipe (e.g. "pick A | B, then run + // `ps aux | grep x`") satisfies looksLikeMarkdownTableRow but is not a + // table row. Require the same column count as the header so it isn't + // absorbed into the table — otherwise parseMarkdownTable rejects the + // mismatched block (returns null), suppressing the table UI and merging + // the prose line into the table bubble. + // + // Always keep at least the first data row, though: splitMarkdownTableCells + // splits naively on "|" and does not honor escaped "\|" or pipes inside + // code spans, so a genuine first row carrying such a pipe over-counts its + // cells. Dropping it here would leave a header+separator-only block that + // parseMarkdownTable renders as an empty-row table (and detaches the real + // row). Keeping it preserves the count mismatch so parseMarkdownTable + // returns null and the block falls back to readable raw markdown. + const headerCellCount = splitMarkdownTableCells(line).length; const tableLines = [line, lines[index + 1]]; index += 2; + let dataRowsAbsorbed = 0; while (index < lines.length && looksLikeMarkdownTableRow(lines[index])) { + const matches = splitMarkdownTableCells(lines[index]).length === headerCellCount; + if (!matches && dataRowsAbsorbed > 0) break; tableLines.push(lines[index]); + dataRowsAbsorbed += 1; index += 1; } index -= 1;