From f9d530761fda67293786e177ad227f90e5c57e0c Mon Sep 17 00:00:00 2001 From: nikhilnigamnik Date: Sun, 4 Oct 2026 22:53:28 +0530 Subject: [PATCH 1/2] fix(web): show minimap previews as plain text, not raw markdown The minimap hover preview only collapsed whitespace, so assistant replies with tables, links, code, or lists showed raw syntax such as "| Used for | Model | | --- | --- |". Strip common markdown syntax from the assistant text before compacting it, keeping cell text, link labels, and code contents. --- .../chat/timelineMinimapItems.test.ts | 30 +++++++++++++++++++ .../components/chat/timelineMinimapItems.ts | 23 +++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/timelineMinimapItems.test.ts b/apps/web/src/components/chat/timelineMinimapItems.test.ts index cad6c2a374b3..a554d922d5aa 100644 --- a/apps/web/src/components/chat/timelineMinimapItems.test.ts +++ b/apps/web/src/components/chat/timelineMinimapItems.test.ts @@ -64,6 +64,36 @@ describe("timeline minimap previews", () => { expect(resolveTimelineMinimapPreview(null)).toBeNull(); }); + it("previews assistant markdown as plain text", () => { + const [item] = deriveTimelineMinimapItems( + rows([ + ["user", "what **model** are we using?"], + [ + "assistant", + [ + "## Models", + "We use **two** models, see [the docs](https://example.com/docs):", + "", + "| Used for | Model |", + "| --- | :---: |", + "| Titles | `@cf/meta/llama` |", + "", + "- [x] Set in `config/ai.ts`", + "> Note: ~~old~~ new", + "```ts", + "const snake_case = a * b;", + "```", + ].join("\n"), + ], + ]), + ); + expect(resolveTimelineMinimapPreview(item!)).toMatchObject({ + userText: "what **model** are we using?", + assistantText: + "Models We use two models, see the docs: Used for Model Titles @cf/meta/llama Set in config/ai.ts Note: old new const snake_case = a * b;", + }); + }); + it("shows fresh streaming text without changing the jump target", () => { const first = deriveTimelineMinimapItems( rows([ diff --git a/apps/web/src/components/chat/timelineMinimapItems.ts b/apps/web/src/components/chat/timelineMinimapItems.ts index 0a37c686e780..b55ee091efa0 100644 --- a/apps/web/src/components/chat/timelineMinimapItems.ts +++ b/apps/web/src/components/chat/timelineMinimapItems.ts @@ -48,6 +48,25 @@ function resolveFinalAssistantTextForTurn( return finalAssistantText; } +/** Drops markdown syntax so table, list, and link sources read as plain preview text. */ +function stripMarkdownForPreview(text: string) { + return ( + text + // Code fence lines, then table delimiter rows and thematic breaks. + .replace(/^[ \t]*(?:```|~~~).*$/gm, "") + .replace(/^[ \t]*\|?[ \t]*:?-{3,}:?[ \t]*(?:\|[ \t]*:?-{3,}:?[ \t]*)*\|?[ \t]*$/gm, "") + // Table rows keep their cell text. + .replace(/^[ \t]*\|(.*?)\|?[ \t]*$/gm, (_, cells: string) => cells.replace(/\s*\|\s*/g, " ")) + // Blockquote, heading, list, and task markers at the start of a line. + .replace(/^[ \t]*(?:>[ \t]*)*(?:(?:#{1,6}|[-*+]|\d+[.)])[ \t]+)?(?:\[[ xX]\][ \t]+)?/gm, "") + // Links and images keep their label. + .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/`+([^`]+)`+/g, "$1") + // Paired bold and strikethrough only; single `*` and `_` are common in identifiers. + .replace(/(\*\*|__|~~)(\S(?:.*?\S)?)\1/g, "$2") + ); +} + function compactMinimapPreview(text: string | null | undefined) { const compact = text?.replace(/\s+/g, " ").trim() ?? ""; return compact.length > 0 ? compact : null; @@ -61,6 +80,8 @@ export function resolveTimelineMinimapPreview( : { ...item, userText: compactMinimapPreview(item.userText), - assistantText: compactMinimapPreview(item.assistantText), + assistantText: compactMinimapPreview( + item.assistantText === null ? null : stripMarkdownForPreview(item.assistantText), + ), }; } From 725c2e45804ab20e509837119bd2e49c8aa5b64e Mon Sep 17 00:00:00 2001 From: nikhilnigamnik Date: Sun, 4 Oct 2026 23:07:40 +0530 Subject: [PATCH 2/2] fix(web): read minimap previews through the markdown parser The regex cleanup also rewrote code spans and fenced code, split table cells on escaped pipes, and stripped literal "[x]" outside task lists. Parse the assistant text with remark and GFM, the same syntax the chat renders, and read its text nodes instead. --- apps/web/package.json | 3 +- .../chat/timelineMinimapItems.test.ts | 20 ++++++++ .../components/chat/timelineMinimapItems.ts | 49 ++++++++++++------- pnpm-lock.yaml | 9 ++-- 4 files changed, 59 insertions(+), 22 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index ffccf206f910..ef768c400160 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -59,7 +59,9 @@ "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", "tailwind-merge": "^3.4.0", + "unified": "^11.0.5", "zustand": "^5.0.11" }, "devDependencies": { @@ -82,7 +84,6 @@ "jsdom": "^30.0.1", "react-test-renderer": "19.2.6", "tailwindcss": "^4.0.0", - "unified": "^11.0.5", "vite": "catalog:", "vite-plus": "catalog:" } diff --git a/apps/web/src/components/chat/timelineMinimapItems.test.ts b/apps/web/src/components/chat/timelineMinimapItems.test.ts index a554d922d5aa..5af13adcc9a4 100644 --- a/apps/web/src/components/chat/timelineMinimapItems.test.ts +++ b/apps/web/src/components/chat/timelineMinimapItems.test.ts @@ -94,6 +94,26 @@ describe("timeline minimap previews", () => { }); }); + it("keeps code, escaped table pipes, and literal checkboxes as written", () => { + const preview = (markdown: string) => + resolveTimelineMinimapPreview( + deriveTimelineMinimapItems( + rows([ + ["user", "Q"], + ["assistant", markdown], + ]), + )[0]!, + )?.assistantText; + + expect(preview("Match `**/*.ts` files\n\n```sh\n# install deps\n- **not bold**\n```")).toBe( + "Match **/*.ts files # install deps - **not bold**", + ); + expect(preview("| Operator | Meaning |\n| --- | --- |\n| `a \\| b` | either |")).toBe( + "Operator Meaning a | b either", + ); + expect(preview("[x] deployment complete")).toBe("[x] deployment complete"); + }); + it("shows fresh streaming text without changing the jump target", () => { const first = deriveTimelineMinimapItems( rows([ diff --git a/apps/web/src/components/chat/timelineMinimapItems.ts b/apps/web/src/components/chat/timelineMinimapItems.ts index b55ee091efa0..47bd74f154e8 100644 --- a/apps/web/src/components/chat/timelineMinimapItems.ts +++ b/apps/web/src/components/chat/timelineMinimapItems.ts @@ -1,3 +1,8 @@ +import type { Nodes } from "mdast"; +import remarkGfm from "remark-gfm"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; + import type { MessagesTimelineRow } from "./MessagesTimeline.logic"; export interface TimelineMinimapItem { @@ -48,23 +53,31 @@ function resolveFinalAssistantTextForTurn( return finalAssistantText; } -/** Drops markdown syntax so table, list, and link sources read as plain preview text. */ -function stripMarkdownForPreview(text: string) { - return ( - text - // Code fence lines, then table delimiter rows and thematic breaks. - .replace(/^[ \t]*(?:```|~~~).*$/gm, "") - .replace(/^[ \t]*\|?[ \t]*:?-{3,}:?[ \t]*(?:\|[ \t]*:?-{3,}:?[ \t]*)*\|?[ \t]*$/gm, "") - // Table rows keep their cell text. - .replace(/^[ \t]*\|(.*?)\|?[ \t]*$/gm, (_, cells: string) => cells.replace(/\s*\|\s*/g, " ")) - // Blockquote, heading, list, and task markers at the start of a line. - .replace(/^[ \t]*(?:>[ \t]*)*(?:(?:#{1,6}|[-*+]|\d+[.)])[ \t]+)?(?:\[[ xX]\][ \t]+)?/gm, "") - // Links and images keep their label. - .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") - .replace(/`+([^`]+)`+/g, "$1") - // Paired bold and strikethrough only; single `*` and `_` are common in identifiers. - .replace(/(\*\*|__|~~)(\S(?:.*?\S)?)\1/g, "$2") - ); +const markdownPreviewParser = unified().use(remarkParse).use(remarkGfm).freeze(); + +const INLINE_PARENT_TYPES = new Set([ + "paragraph", + "heading", + "tableCell", + "emphasis", + "strong", + "delete", + "link", + "linkReference", +]); + +function markdownNodeText(node: Nodes): string { + if (node.type === "html") return ""; + if ("value" in node) return node.value; + if (node.type === "image" || node.type === "imageReference") return node.alt ?? ""; + if (!("children" in node)) return ""; + const separator = INLINE_PARENT_TYPES.has(node.type) ? "" : " "; + return node.children.map((child: Nodes) => markdownNodeText(child)).join(separator); +} + +/** Reads assistant markdown as rendered text so previews never show table pipes or link syntax. */ +function markdownPreviewText(markdown: string) { + return markdownNodeText(markdownPreviewParser.parse(markdown)); } function compactMinimapPreview(text: string | null | undefined) { @@ -81,7 +94,7 @@ export function resolveTimelineMinimapPreview( ...item, userText: compactMinimapPreview(item.userText), assistantText: compactMinimapPreview( - item.assistantText === null ? null : stripMarkdownForPreview(item.assistantText), + item.assistantText === null ? null : markdownPreviewText(item.assistantText), ), }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02883a8b6536..8fe9424b6ae2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -752,9 +752,15 @@ importers: remark-gfm: specifier: ^4.0.1 version: 4.0.1 + remark-parse: + specifier: ^11.0.0 + version: 11.0.0 tailwind-merge: specifier: ^3.4.0 version: 3.6.0 + unified: + specifier: ^11.0.5 + version: 11.0.5 zustand: specifier: ^5.0.11 version: 5.0.14(@types/react@19.3.0)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) @@ -816,9 +822,6 @@ importers: tailwindcss: specifier: 4.3.3 version: 4.3.3 - unified: - specifier: ^11.0.5 - version: 11.0.5 vite: specifier: npm:@voidzero-dev/vite-plus-core@1.0.0 version: '@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0)'