diff --git a/apps/web/package.json b/apps/web/package.json index e61c2c24..62e2ab5e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -62,6 +62,7 @@ "react-markdown": "^10.1.0", "rehype-katex": "^7.0.1", "remark-breaks": "^4.0.0", + "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "shadcn": "^4.8.3", diff --git a/apps/web/src/components/ChatMarkdown.browser.tsx b/apps/web/src/components/ChatMarkdown.browser.tsx new file mode 100644 index 00000000..90f0139b --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.browser.tsx @@ -0,0 +1,41 @@ +import "../index.css"; + +import { page } from "vitest/browser"; +import { describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import ChatMarkdown from "./ChatMarkdown"; + +describe("ChatMarkdown frontmatter source positions", () => { + it("keeps a task checkbox aligned to its original source line", async () => { + const onTaskToggle = vi.fn(); + const source = [ + "---", + "name: task-document", + "description: A document with a task.", + "---", + "", + "# Tasks", + "", + "- [ ] Verify the preview", + ].join("\n"); + const screen = await render( + , + ); + + try { + await page.getByRole("checkbox", { name: "" }).click(); + + expect(onTaskToggle).toHaveBeenCalledOnce(); + expect(onTaskToggle).toHaveBeenCalledWith({ sourceLine: 8, checked: true }); + } finally { + await screen.unmount(); + } + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 6379d77f..d6b7b82f 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -21,7 +21,11 @@ async function renderMarkdown( text: string, cwd = "C:\\Users\\LENOVO\\synara", markers?: readonly ThreadMarker[], - options: { readonly isStreaming?: boolean; readonly directionHint?: "ltr" | "rtl" } = {}, + options: { + readonly isStreaming?: boolean; + readonly directionHint?: "ltr" | "rtl"; + readonly recognizeFrontmatter?: boolean; + } = {}, ) { const { default: ChatMarkdown } = await import("./ChatMarkdown"); @@ -32,6 +36,7 @@ async function renderMarkdown( isStreaming={options.isStreaming ?? false} markers={markers} {...(options.directionHint ? { directionHint: options.directionHint } : {})} + {...(options.recognizeFrontmatter ? { recognizeFrontmatter: true } : {})} />, ); } @@ -252,6 +257,95 @@ describe("ChatMarkdown", () => { expect(markup).not.toContain("$x$"); }); + it("hides YAML frontmatter when document recognition is enabled", async () => { + const source = [ + "---", + "name: scient-evidence-to-note", + "description: Turn evidence into a note.", + "---", + "", + "# Evidence to Note", + "", + "Body text.", + ].join("\n"); + const markup = await renderMarkdown(source, undefined, undefined, { + recognizeFrontmatter: true, + }); + + expect(markup).not.toContain("scient-evidence-to-note"); + expect(markup).not.toContain("Turn evidence into a note."); + expect(markup).not.toContain("Evidence to Note'); + expect(markup.match(/)/g) ?? []).toEqual(["

{ + const source = [ + "---", + 'name: "scient-evidence-to-note"', + "description: |", + " First line.", + " Second line with --- inside it.", + "---", + "", + "# Evidence to Note", + ].join("\r\n"); + const markup = await renderMarkdown(source, undefined, undefined, { + recognizeFrontmatter: true, + }); + + expect(markup).not.toContain("scient-evidence-to-note"); + expect(markup).not.toContain("First line."); + expect(markup).not.toContain("Second line"); + expect(markup).toContain('

Evidence to Note

'); + }); + + it("keeps Hebrew document direction after hiding YAML frontmatter", async () => { + const source = [ + "---", + "name: scient-medical-exam-study", + "description: Guide medical exam study.", + "---", + "", + "# הכנה למבחן", + "", + "סיכום רפואי בעברית.", + ].join("\n"); + const markup = await renderMarkdown(source, undefined, undefined, { + recognizeFrontmatter: true, + }); + + expect(markup).not.toContain("scient-medical-exam-study"); + expect(markup).not.toContain("Guide medical exam study."); + expect(markup).toContain('

הכנה למבחן

'); + expect(markup).toContain('

סיכום רפואי בעברית.

'); + }); + + it("keeps an unclosed opening delimiter as a normal Markdown horizontal rule", async () => { + const markup = await renderMarkdown("---\n\nParagraph after the rule.", undefined, undefined, { + recognizeFrontmatter: true, + }); + + expect(markup).toContain("
"); + expect(markup).toContain('

Paragraph after the rule.

'); + }); + + it("keeps default Markdown rendering unchanged when frontmatter recognition is disabled", async () => { + const source = [ + "---", + "name: scient-evidence-to-note", + "description: Turn evidence into a note.", + "---", + "", + "# Evidence to Note", + ].join("\n"); + const markup = await renderMarkdown(source); + + expect(markup).toContain("
"); + expect(markup).toContain('

name: scient-evidence-to-note'); + expect(markup).toContain('

Evidence to Note

'); + }); + it("keeps all-caps dollar identifiers literal", async () => { const markup = await renderMarkdown("Use $USD$ for price and $PATH$ for shell lookup."); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 465a3fad..12ec1dd2 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -26,6 +26,7 @@ import ReactMarkdown from "react-markdown"; import { defaultUrlTransform } from "react-markdown"; import rehypeKatex from "rehype-katex"; import remarkBreaks from "remark-breaks"; +import remarkFrontmatter from "remark-frontmatter"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import { copyTextToClipboard } from "../hooks/useCopyToClipboard"; @@ -122,6 +123,12 @@ interface ChatMarkdownProps { mentionReferences?: ReadonlyArray | undefined; /** Terminal selections rendered as inline chips inside user-message markdown. */ terminalContexts?: ReadonlyArray | undefined; + /** + * Recognizes leading YAML frontmatter as document metadata instead of + * rendering its delimiters as Markdown. Intended for workspace documents; + * chat and other shared-renderer consumers keep CommonMark behavior by default. + */ + recognizeFrontmatter?: boolean; /** * Makes GFM task-list checkboxes interactive. Receives the 1-based line of * the task item in `text` so the caller can flip that `[ ]` marker at the @@ -1174,6 +1181,7 @@ function ChatMarkdown({ variant = "assistant", mentionReferences, terminalContexts, + recognizeFrontmatter = false, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const diffThemeName = resolveDiffThemeName(resolvedTheme); @@ -1214,13 +1222,19 @@ function ChatMarkdown({ [isUserVariant, mentionReferences, terminalContexts], ); const remarkPlugins = useMemo(() => { + const plugins: MarkdownRemarkPlugins = [ + ...(isUserVariant ? USER_MARKDOWN_REMARK_PLUGINS : MARKDOWN_REMARK_PLUGINS), + ]; + if (recognizeFrontmatter) { + plugins.push(remarkFrontmatter); + } if (composerChipsRemarkPlugin) { - return [...USER_MARKDOWN_REMARK_PLUGINS, composerChipsRemarkPlugin]; + plugins.push(composerChipsRemarkPlugin); + } else if (threadMarkerRemarkPlugin) { + plugins.push(threadMarkerRemarkPlugin); } - return threadMarkerRemarkPlugin - ? [...MARKDOWN_REMARK_PLUGINS, threadMarkerRemarkPlugin] - : MARKDOWN_REMARK_PLUGINS; - }, [composerChipsRemarkPlugin, threadMarkerRemarkPlugin]); + return plugins; + }, [composerChipsRemarkPlugin, isUserVariant, recognizeFrontmatter, threadMarkerRemarkPlugin]); const markdownTextDirectionsPlugin = useMemo( () => createRehypeMarkdownTextDirections({ diff --git a/apps/web/src/components/WorkspaceFilePreview.browser.tsx b/apps/web/src/components/WorkspaceFilePreview.browser.tsx new file mode 100644 index 00000000..a10101d2 --- /dev/null +++ b/apps/web/src/components/WorkspaceFilePreview.browser.tsx @@ -0,0 +1,68 @@ +import "../index.css"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { page } from "vitest/browser"; +import { describe, expect, it } from "vitest"; +import { render } from "vitest-browser-react"; + +import { projectQueryKeys } from "~/lib/projectReactQuery"; +import { WorkspaceFilePreview } from "./WorkspaceFilePreview"; + +describe("WorkspaceFilePreview Markdown frontmatter", () => { + it("hides frontmatter in Preview and preserves it byte-for-byte in Source", async () => { + const workspaceRoot = "/project"; + const filePath = "skills/evidence-to-note/SKILL.md"; + const contents = [ + "---", + "name: scient-evidence-to-note", + 'description: "Turn evidence into a note."', + "---", + "", + "# Evidence to Note", + "", + "Study content.", + ].join("\n"); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } }, + }); + queryClient.setQueryData(projectQueryKeys.readFile(workspaceRoot, filePath), { + relativePath: filePath, + contents, + truncated: false, + }); + const screen = await render( + + + , + ); + + try { + const sourceBody = screen.container.querySelector( + ".editor-file-viewer__plain, .editor-file-viewer__highlight", + ); + expect(sourceBody?.textContent).toBe(contents); + + await page.getByRole("radio", { name: "Preview" }).click(); + const previewBody = screen.container.querySelector(".editor-markdown-preview"); + expect(previewBody?.textContent).not.toContain("scient-evidence-to-note"); + expect(previewBody?.textContent).not.toContain("Turn evidence into a note."); + await expect + .element(page.getByRole("heading", { level: 1 })) + .toHaveTextContent("Evidence to Note"); + expect(previewBody?.querySelectorAll("h1, h2, h3, h4, h5, h6")).toHaveLength(1); + + await page.getByRole("radio", { name: "Source" }).click(); + const restoredSourceBody = screen.container.querySelector( + ".editor-file-viewer__plain, .editor-file-viewer__highlight", + ); + expect(restoredSourceBody?.textContent).toBe(contents); + } finally { + await screen.unmount(); + queryClient.clear(); + } + }); +}); diff --git a/apps/web/src/components/WorkspaceFilePreview.tsx b/apps/web/src/components/WorkspaceFilePreview.tsx index f1f58a08..8bb0848c 100644 --- a/apps/web/src/components/WorkspaceFilePreview.tsx +++ b/apps/web/src/components/WorkspaceFilePreview.tsx @@ -608,6 +608,7 @@ export function WorkspaceFilePreview(props: WorkspaceFilePreviewProps) { text={fileContents} cwd={markdownPreviewCwd(props.workspaceRoot, filePath)} isStreaming={false} + recognizeFrontmatter className="editor-markdown-preview__body text-sm leading-relaxed" {...(canToggleTasks ? { onTaskToggle: handleTaskToggle } : {})} /> diff --git a/bun.lock b/bun.lock index 286d0e4b..58d9a822 100644 --- a/bun.lock +++ b/bun.lock @@ -123,6 +123,7 @@ "react-markdown": "^10.1.0", "rehype-katex": "^7.0.1", "remark-breaks": "^4.0.0", + "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "shadcn": "^4.8.3", @@ -1489,6 +1490,8 @@ "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + "fault": ["fault@2.0.1", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], @@ -1511,6 +1514,8 @@ "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], + "format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], @@ -1821,6 +1826,8 @@ "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], + "mdast-util-frontmatter": ["mdast-util-frontmatter@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "escape-string-regexp": "^5.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0" } }, "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA=="], + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], @@ -1863,6 +1870,8 @@ "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + "micromark-extension-frontmatter": ["micromark-extension-frontmatter@2.0.0", "", { "dependencies": { "fault": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg=="], + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], @@ -2169,6 +2178,8 @@ "remark-breaks": ["remark-breaks@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-newline-to-break": "^2.0.0", "unified": "^11.0.0" } }, "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ=="], + "remark-frontmatter": ["remark-frontmatter@5.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-frontmatter": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0", "unified": "^11.0.0" } }, "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ=="], + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], "remark-math": ["remark-math@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-math": "^3.0.0", "micromark-extension-math": "^3.0.0", "unified": "^11.0.0" } }, "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA=="],