diff --git a/packages/dashboard/app/components/MemoryView.css b/packages/dashboard/app/components/MemoryView.css index a501238beb..4220abe4b6 100644 --- a/packages/dashboard/app/components/MemoryView.css +++ b/packages/dashboard/app/components/MemoryView.css @@ -213,6 +213,8 @@ bare border-left text rows, matching the InsightsView card language. font-size: 13px; color: var(--text); line-height: 1.5; + /* Multiline insights keep their continuation-line breaks (PR #2003 review). */ + white-space: pre-wrap; } /* diff --git a/packages/dashboard/app/components/MemoryView.tsx b/packages/dashboard/app/components/MemoryView.tsx index 0196d74ac2..64ce034137 100644 --- a/packages/dashboard/app/components/MemoryView.tsx +++ b/packages/dashboard/app/components/MemoryView.tsx @@ -81,12 +81,24 @@ function parseInsightsContent(content: string | null): ParsedInsightCategory[] { .replace(//g, "") .trim(); - // Extract bullet points: filter bullet lines first, then strip the prefix + /* + FNXC:MemoryView 2026-07-11-01:00: + PR #2003 review: an insight can span multiple lines (one bullet followed by + continuation text). Bullet lines start a new item; non-empty non-bullet lines + append to the previous item instead of being dropped, so multiline insights + render in full rather than silently truncating after the first line. + */ const items = body .split("\n") .map((line) => line.trim()) - .filter((line) => /^[-*]\s+/.test(line)) - .map((line) => line.replace(/^[-*]\s+/, "")); + .reduce((acc, line) => { + if (/^[-*]\s+/.test(line)) { + acc.push(line.replace(/^[-*]\s+/, "")); + } else if (line.length > 0 && acc.length > 0) { + acc[acc.length - 1] = `${acc[acc.length - 1]}\n${line}`; + } + return acc; + }, []); if (items.length > 0 || body.length > 0) { categories.push({ diff --git a/packages/dashboard/app/components/__tests__/MemoryView.test.tsx b/packages/dashboard/app/components/__tests__/MemoryView.test.tsx index 4bb8e026be..4902ab9fff 100644 --- a/packages/dashboard/app/components/__tests__/MemoryView.test.tsx +++ b/packages/dashboard/app/components/__tests__/MemoryView.test.tsx @@ -158,6 +158,50 @@ describe("MemoryView", () => { ); }); + /* + FNXC:MemoryView 2026-07-11-01:00: + PR #2003 regression tests for insight parsing: bullets must parse into individual items + (the pre-fix parser stripped "- " before filtering for it, collapsing categories into one + blob), and a bullet's continuation lines must stay attached to that bullet instead of + being dropped. + */ + it("parses each markdown bullet into its own insight item with the correct count", async () => { + mockUseMemoryData.mockReturnValue( + createMemoryData({ + insightsExists: true, + insightsContent: "## Patterns\n\n- First insight\n- Second insight\n\n## Pitfalls\n- Third insight", + }), + ); + + render(); + await userEvent.click(screen.getByRole("tab", { name: "Insights" })); + + expect(screen.getByText("First insight")).toBeInTheDocument(); + expect(screen.getByText("Second insight")).toBeInTheDocument(); + expect(screen.getByText("Third insight")).toBeInTheDocument(); + // Total Insights stat counts individual bullets, and extraction-marker comments are excluded. + expect(screen.getByText("3")).toBeInTheDocument(); + expect(screen.queryByText(/extraction marker/)).not.toBeInTheDocument(); + }); + + it("keeps continuation lines attached to their bullet for multiline insights", async () => { + mockUseMemoryData.mockReturnValue( + createMemoryData({ + insightsExists: true, + insightsContent: "## Patterns\n- Multiline insight head\n continuation detail line\n- Plain insight", + }), + ); + + render(); + await userEvent.click(screen.getByRole("tab", { name: "Insights" })); + + expect(screen.getByText(/Multiline insight head/)).toBeInTheDocument(); + expect(screen.getByText(/continuation detail line/)).toBeInTheDocument(); + expect(screen.getByText("Plain insight")).toBeInTheDocument(); + // The continuation belongs to the first bullet: still exactly 2 insights in the Total stat. + expect(document.querySelector(".memory-stat-value")?.textContent).toBe("2"); + }); + it("shows read-only warning after backend resolves as non-writable", () => { mockUseMemoryData.mockReturnValue( createMemoryData({