fix: add heading anchors to markdown artifact previews (#3805)

* fix: add markdown heading anchors for artifacts

* fix: scope markdown anchors to artifact previews

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Huixin615 2026-06-27 22:59:47 +08:00 committed by GitHub
parent 7a6c4a994a
commit b5cac5e721
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 145 additions and 2 deletions

View file

@ -79,6 +79,7 @@
"react-resizable-panels": "^4.4.1",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"shiki": "3.15.0",

View file

@ -188,6 +188,9 @@ importers:
rehype-raw:
specifier: ^7.0.0
version: 7.0.0
rehype-slug:
specifier: ^6.0.0
version: 6.0.0
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
@ -3698,6 +3701,9 @@ packages:
hast-util-from-parse5@8.0.3:
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
hast-util-heading-rank@3.0.0:
resolution: {integrity: sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==}
hast-util-is-element@3.0.0:
resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
@ -4979,6 +4985,9 @@ packages:
rehype-recma@1.0.0:
resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==}
rehype-slug@6.0.0:
resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==}
remark-frontmatter@5.0.0:
resolution: {integrity: sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==}
@ -9540,6 +9549,10 @@ snapshots:
vfile-location: 5.0.3
web-namespaces: 2.0.1
hast-util-heading-rank@3.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-is-element@3.0.0:
dependencies:
'@types/hast': 3.0.4
@ -11238,6 +11251,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
rehype-slug@6.0.0:
dependencies:
'@types/hast': 3.0.4
github-slugger: 2.0.0
hast-util-heading-rank: 3.0.0
hast-util-to-string: 3.0.1
unist-util-visit: 5.1.0
remark-frontmatter@5.0.0:
dependencies:
'@types/mdast': 4.0.4

View file

@ -42,7 +42,6 @@ import { writeTextToClipboard } from "@/core/clipboard";
import { useI18n } from "@/core/i18n/hooks";
import { findToolCallResult } from "@/core/messages/utils";
import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown";
import { SafeStreamdown } from "@/core/streamdown/components";
import {
canBrowserPreviewFile,
@ -59,6 +58,7 @@ import { useThread } from "../messages/context";
import { Tooltip } from "../tooltip";
import { useArtifacts } from "./context";
import { artifactMarkdownPlugins } from "./markdown-preview-plugins";
const WRITE_FILE_PREVIEW_REFRESH_INTERVAL_MS = 3000;
@ -464,7 +464,7 @@ export function ArtifactFilePreview({
<div className="size-full px-4">
<SafeStreamdown
className="size-full"
{...streamdownPlugins}
{...artifactMarkdownPlugins}
components={{ a: ArtifactLink }}
>
{content ?? ""}

View file

@ -0,0 +1,15 @@
import rehypeSlug from "rehype-slug";
import { type ClipboardSafeStreamdownProps } from "@/components/ai-elements/streamdown";
import { streamdownPlugins } from "@/core/streamdown";
const baseRehypePlugins = streamdownPlugins.rehypePlugins ?? [];
export const artifactMarkdownPlugins = {
...streamdownPlugins,
rehypePlugins: [
...baseRehypePlugins.slice(0, 1),
rehypeSlug,
...baseRehypePlugins.slice(1),
] as ClipboardSafeStreamdownProps["rehypePlugins"],
};

View file

@ -8,6 +8,7 @@ const JSON_ARTIFACT_PATH = "/artifact-fixtures/report.json";
const IN_PROGRESS_THREAD_ID = "00000000-0000-0000-0000-000000003119";
const COMPLETE_THREAD_ID = "00000000-0000-0000-0000-000000003120";
const MARKDOWN_THREAD_ID = "00000000-0000-0000-0000-000000003121";
const MARKDOWN_ANCHOR_THREAD_ID = "00000000-0000-0000-0000-000000003123";
const JSON_THREAD_ID = "00000000-0000-0000-0000-000000003122";
function writeFileMessages({
@ -140,6 +141,69 @@ test.describe("Artifact preview stability", () => {
await expect(artifactsPanel.getByText("测试内容 1")).toBeVisible();
});
test("scrolls markdown artifact preview to heading anchors", async ({
page,
}) => {
const filler = Array.from(
{ length: 40 },
(_, index) => `填充段落 ${index + 1}`,
).join("\n\n");
mockLangGraphAPI(page, {
threads: [
{
thread_id: MARKDOWN_ANCHOR_THREAD_ID,
title: "Markdown artifact anchor navigation",
messages: writeFileMessages({
path: MARKDOWN_ARTIFACT_PATH,
content: [
"# Report",
"",
"- [概述](#概述)",
"",
filler,
"",
"## 概述",
"",
"目标章节内容",
].join("\n"),
}),
},
],
});
await page.goto(`/workspace/chats/${MARKDOWN_ANCHOR_THREAD_ID}`);
await expect(page.getByText(MARKDOWN_ARTIFACT_PATH)).toBeVisible({
timeout: 15_000,
});
await page.getByText(MARKDOWN_ARTIFACT_PATH).click();
const artifactsPanel = page.locator("#artifacts");
await expect(artifactsPanel.getByText("report.md")).toBeVisible();
const targetHeading = artifactsPanel.locator("h2#概述");
await expect(targetHeading).toHaveCount(1);
await artifactsPanel.getByRole("link", { name: "概述" }).click();
await expect
.poll(async () =>
targetHeading.evaluate((element) => {
const panel = document.querySelector("#artifacts");
if (!panel) {
return false;
}
const panelRect = panel.getBoundingClientRect();
const headingRect = element.getBoundingClientRect();
return (
headingRect.top >= panelRect.top &&
headingRect.top <= panelRect.bottom
);
}),
)
.toBe(true);
});
test("renders code view for an in-progress non-preview write artifact", async ({
page,
}) => {

View file

@ -0,0 +1,42 @@
import { expect, test } from "@rstest/core";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { artifactMarkdownPlugins } from "@/components/workspace/artifacts/markdown-preview-plugins";
import { ArtifactLink } from "@/components/workspace/citations/artifact-link";
import { SafeStreamdown, streamdownPlugins } from "@/core/streamdown";
function renderArtifactMarkdown(content: string) {
return renderToStaticMarkup(
createElement(
SafeStreamdown,
{ ...artifactMarkdownPlugins, components: { a: ArtifactLink } },
content,
),
);
}
function renderSharedMarkdown(content: string) {
return renderToStaticMarkup(
createElement(SafeStreamdown, streamdownPlugins, content),
);
}
test("adds GitHub-style heading anchors to artifact markdown previews", () => {
const html = renderArtifactMarkdown(
["[概述](#概述)", "", "## 概述"].join("\n"),
);
expect(html).toContain('href="#%E6%A6%82%E8%BF%B0"');
expect(html).toContain('id="概述"');
expect(html).not.toContain("target=");
});
test("does not add heading anchors to the shared streamdown plugin config", () => {
const html = [
renderSharedMarkdown("## Summary"),
renderSharedMarkdown("## Summary"),
].join("");
expect(html).not.toContain('id="summary"');
});