Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
41 changes: 41 additions & 0 deletions apps/web/src/components/ChatMarkdown.browser.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ChatMarkdown
text={source}
cwd={undefined}
isStreaming={false}
recognizeFrontmatter
onTaskToggle={onTaskToggle}
/>,
);

try {
await page.getByRole("checkbox", { name: "" }).click();

expect(onTaskToggle).toHaveBeenCalledOnce();
expect(onTaskToggle).toHaveBeenCalledWith({ sourceLine: 8, checked: true });
} finally {
await screen.unmount();
}
});
});
96 changes: 95 additions & 1 deletion apps/web/src/components/ChatMarkdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -32,6 +36,7 @@ async function renderMarkdown(
isStreaming={options.isStreaming ?? false}
markers={markers}
{...(options.directionHint ? { directionHint: options.directionHint } : {})}
{...(options.recognizeFrontmatter ? { recognizeFrontmatter: true } : {})}
/>,
);
}
Expand Down Expand Up @@ -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("<hr");
expect(markup).toContain('<h1 dir="ltr">Evidence to Note</h1>');
expect(markup.match(/<h[1-6](?:\s|>)/g) ?? []).toEqual(["<h1 "]);
});

it("recognizes quoted and multiline YAML frontmatter with CRLF line endings", async () => {
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('<h1 dir="ltr">Evidence to Note</h1>');
});

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('<h1 dir="rtl">הכנה למבחן</h1>');
expect(markup).toContain('<p dir="rtl">סיכום רפואי בעברית.</p>');
});

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("<hr/>");
expect(markup).toContain('<p dir="ltr">Paragraph after the rule.</p>');
});

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("<hr/>");
expect(markup).toContain('<h2 dir="ltr">name: scient-evidence-to-note');
expect(markup).toContain('<h1 dir="ltr">Evidence to Note</h1>');
});

it("keeps all-caps dollar identifiers literal", async () => {
const markup = await renderMarkdown("Use $USD$ for price and $PATH$ for shell lookup.");

Expand Down
24 changes: 19 additions & 5 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -122,6 +123,12 @@ interface ChatMarkdownProps {
mentionReferences?: ReadonlyArray<ProviderMentionReference> | undefined;
/** Terminal selections rendered as inline chips inside user-message markdown. */
terminalContexts?: ReadonlyArray<ParsedTerminalContextEntry> | 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
Expand Down Expand Up @@ -1174,6 +1181,7 @@ function ChatMarkdown({
variant = "assistant",
mentionReferences,
terminalContexts,
recognizeFrontmatter = false,
}: ChatMarkdownProps) {
const { resolvedTheme } = useTheme();
const diffThemeName = resolveDiffThemeName(resolvedTheme);
Expand Down Expand Up @@ -1214,13 +1222,19 @@ function ChatMarkdown({
[isUserVariant, mentionReferences, terminalContexts],
);
const remarkPlugins = useMemo<MarkdownRemarkPlugins>(() => {
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({
Expand Down
68 changes: 68 additions & 0 deletions apps/web/src/components/WorkspaceFilePreview.browser.tsx
Original file line number Diff line number Diff line change
@@ -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(
<QueryClientProvider client={queryClient}>
<WorkspaceFilePreview
workspaceRoot={workspaceRoot}
filePath={filePath}
markdownPreviewDefault={false}
/>
</QueryClientProvider>,
);

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();
}
});
});
1 change: 1 addition & 0 deletions apps/web/src/components/WorkspaceFilePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {})}
/>
Expand Down
11 changes: 11 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading