diff --git a/ui/src/components/markdown-code-blocks.ts b/ui/src/components/markdown-code-blocks.ts index f6c97713051a..3cabe55d2693 100644 --- a/ui/src/components/markdown-code-blocks.ts +++ b/ui/src/components/markdown-code-blocks.ts @@ -268,12 +268,13 @@ function codeClassAttribute(lang: string, highlighted: string): string { function renderCodeElement( text: string, lang: string, - options: { blockArt?: boolean } = {}, + options: { blockArt?: boolean; highlight?: boolean } = {}, ): string { if (options.blockArt || isMarkdownBlockArtText(text)) { return `
${escapeMarkdownHtml(text)}
`; } - const highlighted = highlightCodeHtml(text, lang); + const highlighted = + options.highlight === false ? escapeMarkdownHtml(text) : highlightCodeHtml(text, lang); const classAttr = codeClassAttribute(lang, highlighted); return `
${highlighted}
`; } @@ -301,10 +302,10 @@ export function renderMarkdownCodeBlock( text: string, lang: string, env: unknown, - options: { blockArt?: boolean; copyText?: string } = {}, + options: { blockArt?: boolean; copyText?: string; highlight?: boolean } = {}, ): string { const blockArt = options.blockArt || isMarkdownBlockArtText(text); - const codeBlock = renderCodeElement(text, lang, { blockArt }); + const codeBlock = renderCodeElement(text, lang, { blockArt, highlight: options.highlight }); if (!shouldRenderCodeBlockCopy(env) && !shouldRenderCodeBlockInteraction(env)) { return codeBlock; } diff --git a/ui/src/components/markdown-parser.ts b/ui/src/components/markdown-parser.ts index a11972e0d306..bde8bfbcff4e 100644 --- a/ui/src/components/markdown-parser.ts +++ b/ui/src/components/markdown-parser.ts @@ -656,7 +656,12 @@ export function createMarkdownParser(): MarkdownIt { }); // Fenced and indented blocks share one interaction and overflow surface. - markdownParser.renderer.rules.fence = (tokens, index, _options, env) => { + markdownParser.renderer.rules.fence = ( + tokens, + index, + _options, + env: Partial | undefined, + ) => { const token = tokens[index]; if (!token) { return ""; @@ -664,8 +669,12 @@ export function createMarkdownParser(): MarkdownIt { // token.info contains the full fence info string (e.g., "json title=foo"); // extract only the first whitespace-separated token as the language. const language = token.info.trim().split(/\s+/)[0] || ""; + // An unfinished fence consumes the remaining input; only container closers can + // follow it. Invalid fence-looking prose must not de-highlight an earlier block. return renderMarkdownCodeBlock(token.content, language, env, { copyText: markdownCodeBlockCopyText(token.content), + highlight: + !env?.streamingOpenFence || tokens.findLastIndex(({ nesting }) => nesting !== -1) !== index, }); }; // Override indented code blocks (code_block) with the same treatment as fence diff --git a/ui/src/components/markdown-render-options.ts b/ui/src/components/markdown-render-options.ts index 642dfc594893..ca5f3515e6f0 100644 --- a/ui/src/components/markdown-render-options.ts +++ b/ui/src/components/markdown-render-options.ts @@ -16,7 +16,9 @@ export type MarkdownRenderOptions = { tableInteractions?: MarkdownTableInteractions; }; -export type MarkdownRenderEnv = Required; +export type MarkdownRenderEnv = Required & { + streamingOpenFence?: boolean; +}; export function normalizeMarkdownRenderOptions( options: MarkdownRenderOptions = {}, diff --git a/ui/src/components/markdown.test.ts b/ui/src/components/markdown.test.ts index 7df0a72c6e99..563ca3c4680b 100644 --- a/ui/src/components/markdown.test.ts +++ b/ui/src/components/markdown.test.ts @@ -1100,15 +1100,41 @@ describe("toStreamingMarkdownHtml", () => { expect(html).toBe("

prices are $$50 and

\n"); }); - it("streams an open code fence as a live-highlighted code block", () => { + it("streams an open code fence without syntax highlighting", () => { const html = toStreamingMarkdownHtml("Intro\n\n```ts\nconst x = 1 < 2"); const fragment = htmlFragment(html); + const code = fragment.querySelector("code.language-ts"); expect(fragment.querySelector("p")?.textContent).toBe("Intro"); - expect(fragment.querySelector("code.language-ts")?.textContent).toContain("const x = 1 < 2"); + expect(code?.textContent).toContain("const x = 1 < 2"); + expect(code?.classList.contains("hljs")).toBe(false); + expect(code?.querySelector("span")).toBeNull(); expect(html).not.toContain("markdown-plain-text-fallback"); }); + it("highlights only completed fences inside an open details block", () => { + const html = toStreamingMarkdownHtml( + "
\nLogs\n\n```ts\nconst closed = 1;\n```\n\n```ts\nconst open = 2;", + ); + const code = htmlFragment(html).querySelectorAll("details code.language-ts"); + + expect(code).toHaveLength(2); + expect(code[0]?.classList.contains("hljs")).toBe(true); + expect(code[0]?.querySelector("span")).not.toBeNull(); + expect(code[1]?.classList.contains("hljs")).toBe(false); + expect(code[1]?.querySelector("span")).toBeNull(); + }); + + it("keeps a completed fence highlighted when a later backtick fence has invalid info", () => { + const html = toStreamingMarkdownHtml( + "- ```ts\n const closed = 1;\n ```\n\n ```bad`info\n trailing text", + ); + const code = htmlFragment(html).querySelector("code.language-ts"); + + expect(code?.textContent).toContain("const closed = 1;"); + expect(code?.classList.contains("hljs")).toBe(true); + }); + it("streams an open list code fence through blank lines", () => { const html = toStreamingMarkdownHtml("- ```ts\n const x = 1;\n\n const y = 2;"); const fragment = htmlFragment(html); @@ -1116,6 +1142,7 @@ describe("toStreamingMarkdownHtml", () => { expect(code?.textContent).toContain("const x = 1;"); expect(code?.textContent).toContain("const y = 2;"); + expect(code?.classList.contains("hljs")).toBe(false); expect(html).not.toContain("markdown-plain-text-fallback"); }); @@ -1137,14 +1164,17 @@ describe("toStreamingMarkdownHtml", () => { expect(code?.textContent).toContain("const x = 1;"); expect(code?.textContent).toContain("const y = 2;"); + expect(code?.classList.contains("hljs")).toBe(false); expect(html).not.toContain("markdown-plain-text-fallback"); }); it("renders a completed code fence once the closing fence arrives", () => { - const html = toStreamingMarkdownHtml("```ts\nconst x = 1;\n```"); + const markdown = "```ts\nconst x = 1;\n```"; + const html = toStreamingMarkdownHtml(markdown); expect(html).toContain(' { await server?.close(); }); + it("highlights a streamed code fence only after its closing marker arrives", async () => { + const context = await browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1440 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page); + + try { + await page.goto(`${server.baseUrl}chat`); + await page.locator(".agent-chat__composer-combobox textarea").fill("show TypeScript"); + await page.getByRole("button", { name: "Send message" }).click(); + const sendRequest = await gateway.waitForRequest("chat.send"); + const runId = requireString( + requireRecord(sendRequest.params).idempotencyKey, + "chat send idempotency key", + ); + const openFence = "```ts\nconst value = 1 < 2;"; + const emitDelta = async (text: string, deltaText: string) => { + await gateway.emitGatewayEvent("chat", { + deltaText, + message: { + content: [{ text, type: "text" }], + role: "assistant", + timestamp: Date.now(), + }, + runId, + sessionKey: "main", + state: "delta", + }); + }; + + await emitDelta(openFence, openFence); + const streamingCode = page.locator(".chat-bubble.streaming code.language-ts"); + await expect.poll(() => streamingCode.textContent()).toContain("const value = 1 < 2;"); + expect(await streamingCode.locator("span").count()).toBe(0); + expect(await streamingCode.evaluate((code) => code.classList.contains("hljs"))).toBe(false); + expect(await page.locator(".chat-bubble.streaming .code-block-copy").count()).toBe(1); + if (captureProof) { + await page.screenshot({ path: path.join(artifactDir, "stream-open-unhighlighted.png") }); + } + + const completedFence = `${openFence}\n\`\`\``; + await emitDelta(completedFence, "\n```"); + await expect.poll(() => streamingCode.getAttribute("class")).toContain("hljs"); + expect(await streamingCode.locator("span").count()).toBeGreaterThan(0); + if (captureProof) { + await page.screenshot({ path: path.join(artifactDir, "stream-closed-highlighted.png") }); + } + + await gateway.emitChatFinal({ runId, text: completedFence }); + await expect.poll(() => page.locator(".chat-thread code.language-ts.hljs").count()).toBe(1); + } finally { + await context.close(); + } + }); + it.each(["dark", "light"] as const)( "previews long fences, reveals them, and wraps overflowing lines in %s mode", async (theme) => {