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: Partialprices 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( + " {
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) => {