From e1f5fd7c4a898a87f1d429cb09b3d74683ae5094 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Mon, 22 Jun 2026 19:16:22 +0530 Subject: [PATCH] fix(chat): keep morning-brief heading+body in one chat bubble (#3807) (#3851) --- .../__tests__/agentMessageBubbles.test.ts | 85 ++++++++++++++ app/src/utils/agentMessageBubbles.ts | 110 +++++++++++++++++- 2 files changed, 194 insertions(+), 1 deletion(-) diff --git a/app/src/utils/__tests__/agentMessageBubbles.test.ts b/app/src/utils/__tests__/agentMessageBubbles.test.ts index 58b2788bb..184cc824e 100644 --- a/app/src/utils/__tests__/agentMessageBubbles.test.ts +++ b/app/src/utils/__tests__/agentMessageBubbles.test.ts @@ -84,6 +84,91 @@ describe('splitAgentMessageIntoBubbles', () => { expect(splitAgentMessageIntoBubbles('___')).toEqual([]); expect(splitAgentMessageIntoBubbles('Before\n\n---\n\nAfter')).toEqual(['Before', 'After']); }); + + // Issue #3807: a heading and its body must never land in separate bubbles. + it('keeps an ATX heading together with its body in one bubble', () => { + expect(splitAgentMessageIntoBubbles('## 📅 Calendar\n\n- 9am standup')).toEqual([ + '## 📅 Calendar\n\n- 9am standup', + ]); + }); + + it('keeps a bold-line heading together with its body in one bubble', () => { + expect(splitAgentMessageIntoBubbles('**Tasks**\n\n- Ship the PR')).toEqual([ + '**Tasks**\n\n- Ship the PR', + ]); + }); + + it('renders a multi-section morning briefing as one bubble per section', () => { + const briefing = [ + 'Good morning! Here is what today looks like.', + '## 📅 Calendar', + '- 9:00 Standup\n- 14:00 Design review', + '## ✅ Tasks', + '- Finish the proposal (due today)', + '## 📧 Emails', + '2 unread threads from key contacts', + ].join('\n\n'); + + const bubbles = splitAgentMessageIntoBubbles(briefing); + + expect(bubbles).toEqual([ + 'Good morning! Here is what today looks like.', + '## 📅 Calendar\n\n- 9:00 Standup\n- 14:00 Design review', + '## ✅ Tasks\n\n- Finish the proposal (due today)', + '## 📧 Emails\n\n2 unread threads from key contacts', + ]); + + // No bubble is a heading with no body, and no body bubble lacks its heading. + for (const bubble of bubbles.slice(1)) { + const lines = bubble.split('\n').filter(line => line.trim().length > 0); + expect(lines.length).toBeGreaterThan(1); + expect(lines[0].startsWith('#')).toBe(true); + } + }); + + it('absorbs trailing closing lines into the final section bubble', () => { + const briefing = '## 📧 Emails\n\n2 unread threads\n\nHave a great day! ☀️'; + expect(splitAgentMessageIntoBubbles(briefing)).toEqual([ + '## 📧 Emails\n\n2 unread threads\n\nHave a great day! ☀️', + ]); + }); + + it('keeps a table out of the heading bubble so the table renderer still fires', () => { + // A table folded behind a heading would not sit at the bubble start and + // would render as raw pipe text, so it stays its own segment. + const content = '## 📅 Calendar\n\n| Time | Event |\n| --- | --- |\n| 9:00 | Standup |'; + expect(splitAgentMessageIntoBubbles(content)).toEqual([ + '## 📅 Calendar', + '| Time | Event |\n| --- | --- |\n| 9:00 | Standup |', + ]); + }); + + it('still groups body paragraphs that follow a table under their heading', () => { + const content = + '## 📅 Calendar\n\n| Time | Event |\n| --- | --- |\n| 9:00 | Standup |\n\n## ✅ Tasks\n\n- Ship the PR'; + expect(splitAgentMessageIntoBubbles(content)).toEqual([ + '## 📅 Calendar', + '| Time | Event |\n| --- | --- |\n| 9:00 | Standup |', + '## ✅ Tasks\n\n- Ship the PR', + ]); + }); + + it('folds a trailing body-less heading into the previous bubble', () => { + const content = '## 📅 Calendar\n\n- 9:00 Standup\n\n## ✅ Tasks'; + expect(splitAgentMessageIntoBubbles(content)).toEqual([ + '## 📅 Calendar\n\n- 9:00 Standup\n\n## ✅ Tasks', + ]); + }); + + it('does not treat inline bold emphasis with trailing prose as a heading', () => { + // Only a fully-bold line is a heading; emphasis followed by prose stays a + // plain paragraph so ordinary content is not misclassified. + const content = '**Heads up:** the meeting moved\n\nSee you at 3pm'; + expect(splitAgentMessageIntoBubbles(content)).toEqual([ + '**Heads up:** the meeting moved', + 'See you at 3pm', + ]); + }); }); describe('parseMarkdownTable', () => { diff --git a/app/src/utils/agentMessageBubbles.ts b/app/src/utils/agentMessageBubbles.ts index e6ff2dba7..f2326a672 100644 --- a/app/src/utils/agentMessageBubbles.ts +++ b/app/src/utils/agentMessageBubbles.ts @@ -1,3 +1,7 @@ +import debugFactory from 'debug'; + +const debug = debugFactory('agent-message-bubbles'); + /** * Split an agent message into render-time bubble segments. * @@ -5,6 +9,11 @@ * newlines. Fenced code blocks stay intact as a single segment so * Markdown/code rendering does not fragment unexpectedly. * Markdown tables also stay grouped so they can render as dedicated table UI. + * + * Finally, regroup so a section heading and the body that follows it always + * share one bubble (issue #3807): structured output such as the morning + * briefing writes `## Heading\n\n- body…`, and splitting on the blank line + * would otherwise orphan every heading into its own content-less bubble. */ export function splitAgentMessageIntoBubbles(content: string): string[] { const normalized = content @@ -77,7 +86,106 @@ export function splitAgentMessageIntoBubbles(content: string): string[] { } flushCurrent(); - return segments.filter(segment => !isVisualSeparatorOnly(segment)); + const cleaned = segments.filter(segment => !isVisualSeparatorOnly(segment)); + const grouped = groupSectionsUnderHeadings(cleaned); + debug( + '[bubbles] %d char message -> %d raw segment(s) -> %d bubble(s) after heading grouping', + content.length, + cleaned.length, + grouped.length + ); + return grouped; +} + +/** + * Merge each section heading with the body segments that follow it (up to the + * next heading) into a single bubble, so a heading is never stranded in a + * content-less bubble of its own. Segments before the first heading (e.g. a + * greeting) are left untouched. + * + * Tables are deliberately NOT absorbed into a heading bubble: `AgentMessageBubble` + * only renders the dedicated table UI when the table sits at the start of the + * bubble (`parseMarkdownTable`), and the Markdown renderer has no GFM-table + * support, so folding a table behind a heading would render it as raw pipe + * text. A table therefore stays its own bubble and ends the current section. + */ +function groupSectionsUnderHeadings(segments: string[]): string[] { + const grouped: string[] = []; + let index = 0; + + while (index < segments.length) { + const segment = segments[index]; + + if (!startsWithHeading(segment)) { + grouped.push(segment); + index += 1; + continue; + } + + // Absorb the body paragraphs below this heading — but stop at the next + // heading or a table, both of which must begin their own bubble. + const body: string[] = []; + index += 1; + while ( + index < segments.length && + !startsWithHeading(segments[index]) && + !isTableSegment(segments[index]) + ) { + body.push(segments[index]); + index += 1; + } + + if (body.length > 0) { + grouped.push([segment, ...body].join('\n\n')); + continue; + } + + // Heading with no inline body. Avoid an orphan heading bubble: when the + // next segment is a table the heading labels it from the bubble directly + // above (and must not be folded into the table); otherwise fold the + // heading into the previous bubble, unless that bubble is itself a table. + const nextIsTable = index < segments.length && isTableSegment(segments[index]); + const previous = grouped[grouped.length - 1]; + if (!nextIsTable && previous !== undefined && !isTableSegment(previous)) { + grouped[grouped.length - 1] = `${previous}\n\n${segment}`; + } else { + grouped.push(segment); + } + } + + return grouped; +} + +/** A segment "starts a section" when its first non-empty line is a heading. */ +function startsWithHeading(segment: string): boolean { + const firstLine = segment.split('\n').find(line => line.trim().length > 0) ?? ''; + return isHeadingLine(firstLine); +} + +/** A segment is a table when its first non-empty line begins a markdown table. */ +function isTableSegment(segment: string): boolean { + const lines = segment.split('\n'); + let index = 0; + while (index < lines.length && lines[index].trim().length === 0) index += 1; + return isMarkdownTableStart(lines, index); +} + +/** + * Recognize the heading shapes structured agent output uses to label a + * section: Markdown ATX headings (`#`..`######`) and a single fully-bold line + * (`**Calendar**`), optionally with a trailing colon. + * + * The bold form deliberately requires the WHOLE line to be bold — inline + * emphasis with trailing prose (`**Heads up:** the meeting moved`) is NOT a + * heading, so ordinary emphasized content is not misclassified. The residual + * heuristic risk is a standalone fully-bold sentence used as content; in that + * case it is simply merged with the following segment (kept together, never + * orphaned), which is harmless for the structured briefing shapes this serves. + */ +function isHeadingLine(line: string): boolean { + const trimmed = line.trim(); + if (/^#{1,6}\s+\S/.test(trimmed)) return true; + return /^\*\*[^*]+\*\*:?$/.test(trimmed); } export interface ParsedMarkdownTable {