fix(site/src/pages/AgentsPage/components/ChatElements): align code block rendering (#24966)

This commit is contained in:
Danielle Maywood
2026-05-05 14:28:39 +01:00
committed by GitHub
parent f585d3e9db
commit 5322755691
4 changed files with 163 additions and 32 deletions
@@ -60,6 +60,128 @@ export const FencedFileBlock: Story = {
args: {
children: sampleFileMarkdown,
},
play: async ({ canvasElement }) => {
await expectCodeBlock(canvasElement, /func ValidateToken/, {
highlighted: true,
});
},
};
const singleLineCodeBlockMarkdown = `
\`\`\`
07c3697 feat: update agent skills
\`\`\`
`;
const findCodeBlockHost = async (canvasElement: HTMLElement, text: RegExp) => {
let host: HTMLElement | undefined;
await waitFor(() => {
const hosts = Array.from(
canvasElement.querySelectorAll("diffs-container"),
).filter(
(element): element is HTMLElement => element instanceof HTMLElement,
);
host = hosts.find((element) => {
text.lastIndex = 0;
return text.test(element.shadowRoot?.textContent ?? "");
});
expect(host).toBeDefined();
});
if (!host) {
throw new Error("Expected fenced code to render inside FileViewer.");
}
return host;
};
const expectCodeBlock = async (
canvasElement: HTMLElement,
text: RegExp,
options: { highlighted?: boolean } = {},
) => {
const host = await findCodeBlockHost(canvasElement, text);
expect(host).toBeInTheDocument();
expect(host.style.getPropertyValue("--diffs-font-size")).toBe("12px");
expect(host.style.getPropertyValue("--diffs-line-height")).toBe("20px");
const shadowRoot = host.shadowRoot;
if (!shadowRoot) {
throw new Error("Expected FileViewer to render code in its shadow root.");
}
expect(shadowRoot.textContent ?? "").not.toContain("```");
const pre = shadowRoot.querySelector(
"pre[data-file][data-disable-line-numbers]",
);
expect(pre).toBeInTheDocument();
if (!(pre instanceof HTMLElement)) {
throw new Error("Expected FileViewer to render a pre element.");
}
const code = shadowRoot.querySelector("[data-code]");
expect(code).toBeInTheDocument();
if (!(code instanceof HTMLElement)) {
throw new Error("Expected FileViewer to render a code container.");
}
const line = shadowRoot.querySelector("[data-line]");
expect(line).toBeInTheDocument();
if (!(line instanceof HTMLElement)) {
throw new Error("Expected FileViewer to render code lines.");
}
const gutter = shadowRoot.querySelector("[data-column-number]");
expect(gutter).toBeInTheDocument();
if (!(gutter instanceof HTMLElement)) {
throw new Error("Expected FileViewer to render its line-number gutter.");
}
const preStyles = getComputedStyle(pre);
expect(preStyles.fontSize).toBe("12px");
expect(preStyles.lineHeight).toBe("20px");
const codeStyles = getComputedStyle(code);
expect(codeStyles.paddingTop).toBe("8px");
expect(codeStyles.paddingBottom).toBe("8px");
expect(codeStyles.paddingBottom).toBe(codeStyles.paddingTop);
const lineStyles = getComputedStyle(line);
expect(lineStyles.paddingLeft).toBe("12px");
expect(lineStyles.paddingRight).toBe("12px");
expect(lineStyles.paddingRight).toBe(lineStyles.paddingLeft);
expect(lineStyles.minHeight).toBe("20px");
const gutterStyles = getComputedStyle(gutter);
expect(gutterStyles.minWidth).toBe("0px");
expect(gutterStyles.paddingLeft).toBe("0px");
expect(gutterStyles.paddingRight).toBe("0px");
if (options.highlighted) {
let highlightedToken: HTMLElement | null = null;
await waitFor(() => {
const token = shadowRoot.querySelector("span[style*='color']");
expect(token).toBeInTheDocument();
if (!(token instanceof HTMLElement)) {
throw new Error("Expected FileViewer to render highlighted tokens.");
}
highlightedToken = token;
});
if (!highlightedToken) {
throw new Error("Expected FileViewer to render highlighted tokens.");
}
expect(getComputedStyle(highlightedToken).color).not.toBe(lineStyles.color);
}
return host;
};
export const SingleLineFencedBlock: Story = {
args: {
children: singleLineCodeBlockMarkdown,
},
play: async ({ canvasElement }) => {
await expectCodeBlock(canvasElement, /07c3697 feat/);
},
};
export const MarkdownAndLinksLight: Story = {
@@ -124,22 +246,17 @@ export const StreamingInlineMarkdown: Story = {
};
// Verifies that an incomplete fenced code block in streaming mode
// renders inside a code element rather than showing raw backticks.
// The FileViewer renders a <diffs-container> web component whose
// content lives in Shadow DOM, so we assert on DOM structure rather
// than text content inside the web component.
// renders as code rather than showing raw backticks.
export const StreamingCodeFence: Story = {
args: {
children: "```ts\nconst x = 1",
streaming: true,
},
play: async ({ canvasElement }) => {
// The code fence should be parsed into a FileViewer (web component),
// not rendered as raw backtick text.
await waitFor(() => {
const viewer = canvasElement.querySelector("diffs-container");
expect(viewer).toBeInTheDocument();
await expectCodeBlock(canvasElement, /const x = 1/, {
highlighted: true,
});
// The raw triple-backtick should not appear as visible text.
const bodyText = canvasElement.textContent ?? "";
expect(bodyText).not.toContain("```");
@@ -3,7 +3,7 @@ import {
File as FileViewer,
type SupportedLanguages,
} from "@pierre/diffs/react";
import type { ComponentPropsWithRef, ReactNode } from "react";
import type { ComponentPropsWithRef, CSSProperties, ReactNode } from "react";
import {
type Components,
defaultRehypePlugins,
@@ -31,14 +31,6 @@ const chatRehypePlugins = [
defaultRehypePlugins.harden,
];
const fileViewerCSS =
"pre, [data-line], [data-diffs-header] { background-color: transparent !important; }";
const fileViewerTheme = {
light: "github-light",
dark: "github-dark-high-contrast",
} as const;
type HastNode = {
type?: string;
value?: string;
@@ -59,8 +51,6 @@ type MarkdownComponentProps = {
className?: string;
};
type FileViewerThemeType = "light" | "dark";
/**
* Recursively extracts text from a HAST node tree. This is plain
* data (not React elements), so it's reliable to traverse.
@@ -86,6 +76,30 @@ const getClassNames = (className: string[] | string | undefined): string[] => {
);
};
const fileViewerTheme = {
light: "github-light",
dark: "github-dark-high-contrast",
} as const;
type FileViewerThemeType = keyof typeof fileViewerTheme;
const markdownFileViewerCSS = [
":host { background-color: transparent !important; }",
"pre, [data-code], [data-line], [data-diffs-header] { background-color: transparent !important; }",
"[data-code] { padding-block: 8px !important; overflow: auto clip !important; scrollbar-width: none !important; }",
"[data-code]::-webkit-scrollbar { width: 0 !important; height: 0 !important; }",
"[data-disable-line-numbers][data-file] { --diffs-grid-number-column-width: 0px !important; }",
"[data-disable-line-numbers] [data-column-number] { min-width: 0 !important; padding: 0 !important; }",
"[data-line] { min-height: 20px !important; padding-inline: 12px !important; }",
].join(" ");
const markdownFileViewerStyle = {
"--diffs-font-family": '"Geist Mono Variable", monospace, monospace',
"--diffs-header-font-family": '"Geist Variable", system-ui, sans-serif',
"--diffs-font-size": "12px",
"--diffs-line-height": "20px",
} as CSSProperties;
const createComponents = (
fileViewerThemeType: FileViewerThemeType,
viewerTheme: (typeof fileViewerTheme)[FileViewerThemeType],
@@ -185,14 +199,12 @@ const createComponents = (
td: ({ children }: MarkdownComponentProps) => (
<td className="px-4 py-2">{children}</td>
),
// Inline code only — fenced blocks are handled by the pre override.
// Inline code only, fenced blocks are handled by the pre override.
code: ({ children }: MarkdownComponentProps) => (
<code className="rounded bg-surface-quaternary/25 px-1 py-0.5 font-mono text-content-primary">
{children}
</code>
),
// Fenced code blocks: extract language and content from the HAST
// node directly (plain data), then render with FileViewer.
pre: ({ node }: MarkdownComponentProps) => {
const codeChild = node?.children?.[0];
if (codeChild?.tagName === "code") {
@@ -200,11 +212,11 @@ const createComponents = (
const langClass = classes.find((c: string) =>
c.startsWith("language-"),
);
const lang = langClass ? langClass.replace("language-", "") : "text";
const lang = langClass?.replace(/^language-/, "") ?? "text";
const content = getHastText(codeChild).trimEnd();
if (content) {
return (
<div className="my-4 overflow-hidden rounded-xl border border-solid border-border-default text-2xs">
<div className="my-4 overflow-hidden rounded-md border border-solid border-border-default bg-surface-primary">
<FileViewer
file={{
name: `block.${lang}`,
@@ -218,22 +230,23 @@ const createComponents = (
disableFileHeader: true,
disableLineNumbers: true,
theme: viewerTheme,
unsafeCSS: fileViewerCSS,
unsafeCSS: markdownFileViewerCSS,
}}
style={markdownFileViewerStyle}
/>
</div>
);
}
}
return <pre>{node?.children?.map?.(() => null)}</pre>;
return <pre>{getHastText(node)}</pre>;
},
};
};
// Precompute component maps for both themes at module scope so
// every Response instance shares the same stable references.
// This prevents Streamdown from discarding its cached render
// tree on each parent re-render.
// every Response instance shares the same stable references. This
// prevents Streamdown from discarding its cached render tree on each
// parent re-render.
const componentsByTheme: Record<FileViewerThemeType, Components> = {
light: createComponents("light", fileViewerTheme.light),
dark: createComponents("dark", fileViewerTheme.dark),
@@ -914,6 +914,7 @@ const GenericToolRenderer: FC<ToolRendererProps> = ({
contents: fileContent.content,
}}
options={fileContentOptions}
style={DIFFS_FONT_STYLE}
/>
</ScrollArea>
) : (
@@ -1,7 +1,7 @@
import type { FileDiffMetadata } from "@pierre/diffs";
import { parsePatchFiles } from "@pierre/diffs";
import * as Diff from "diff";
import type React from "react";
import type { CSSProperties } from "react";
import * as Yup from "yup";
import { asRecord, asString, isValid } from "../runtimeTypeUtils";
@@ -420,7 +420,7 @@ export const DIFFS_FONT_STYLE = {
"--diffs-header-font-family": '"Geist Variable", system-ui, sans-serif',
"--diffs-font-size": "11px",
"--diffs-line-height": "1.5",
} as React.CSSProperties;
} as CSSProperties;
export const BORDER_BG_STYLE = {
background: "hsl(var(--border-default))",