mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(site/src/pages/AgentsPage/components/ChatElements): align code block rendering (#24966)
This commit is contained in:
@@ -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))",
|
||||
|
||||
Reference in New Issue
Block a user