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=="],