diff --git a/site/src/pages/AgentsPage/components/ChatElements/Response.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/Response.stories.tsx index 1f349536d6..4efeaef10d 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/Response.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/Response.stories.tsx @@ -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 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("```"); diff --git a/site/src/pages/AgentsPage/components/ChatElements/Response.tsx b/site/src/pages/AgentsPage/components/ChatElements/Response.tsx index 9675a9c0be..f18dcef1d7 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/Response.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/Response.tsx @@ -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) => ( {children} ), - // 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) => ( {children} ), - // 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 ( -
+
); } } - return
{node?.children?.map?.(() => null)}
; + return
{getHastText(node)}
; }, }; }; // 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 = { light: createComponents("light", fileViewerTheme.light), dark: createComponents("dark", fileViewerTheme.dark), diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index 59a37ca24f..3b2d106809 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -914,6 +914,7 @@ const GenericToolRenderer: FC = ({ contents: fileContent.content, }} options={fileContentOptions} + style={DIFFS_FONT_STYLE} /> ) : ( diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts index 8fec771eeb..5e5074a7f9 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts @@ -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))",