mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
perf(ui): skip highlighting unfinished code blocks while streaming (#127754)
* perf(ui): defer open-fence highlighting * perf(ui): simplify unfinished fence highlighting Co-authored-by: vyctorbrzezowski <krzyszchweski@gmail.com> * docs: avoid concurrent changelog insertion conflict * docs: respect release-owned changelog policy Release-note context and before-after UI proof remain in the pull request. Co-authored-by: vyctorbrzezowski <krzyszchweski@gmail.com> --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
committed by
GitHub
parent
2fdfd64a1a
commit
e22dc4b287
@@ -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 `<pre><code class="markdown-block-art">${escapeMarkdownHtml(text)}</code></pre>`;
|
||||
}
|
||||
const highlighted = highlightCodeHtml(text, lang);
|
||||
const highlighted =
|
||||
options.highlight === false ? escapeMarkdownHtml(text) : highlightCodeHtml(text, lang);
|
||||
const classAttr = codeClassAttribute(lang, highlighted);
|
||||
return `<pre><code${classAttr}>${highlighted}</code></pre>`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<MarkdownRenderEnv> | 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
|
||||
|
||||
@@ -16,7 +16,9 @@ export type MarkdownRenderOptions = {
|
||||
tableInteractions?: MarkdownTableInteractions;
|
||||
};
|
||||
|
||||
export type MarkdownRenderEnv = Required<MarkdownRenderOptions>;
|
||||
export type MarkdownRenderEnv = Required<MarkdownRenderOptions> & {
|
||||
streamingOpenFence?: boolean;
|
||||
};
|
||||
|
||||
export function normalizeMarkdownRenderOptions(
|
||||
options: MarkdownRenderOptions = {},
|
||||
|
||||
@@ -1100,15 +1100,41 @@ describe("toStreamingMarkdownHtml", () => {
|
||||
expect(html).toBe("<p>prices are $$50 and</p>\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(
|
||||
"<details>\n<summary>Logs</summary>\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('<code class="hljs language-ts"');
|
||||
expect(html).toContain("const x = 1;");
|
||||
expect(html).not.toContain("markdown-plain-text-fallback");
|
||||
expect(html).toBe(toSanitizedMarkdownHtml(markdown));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -607,7 +607,7 @@ export function toStreamingMarkdownHtml(
|
||||
}
|
||||
const tailHtml =
|
||||
tailRepairStart === null
|
||||
? renderSanitizedMarkdown(streamingTail, renderOptions)
|
||||
? renderSanitizedMarkdown(streamingTail, { ...renderOptions, streamingOpenFence: true })
|
||||
: renderSanitizedMarkdown(
|
||||
repairStreamingMarkdownTail(streamingTail, tailRepairStart - boundary),
|
||||
renderOptions,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
startControlUiE2eServer,
|
||||
type ControlUiE2eServer,
|
||||
} from "../test-helpers/control-ui-e2e.ts";
|
||||
import { requireRecord, requireString } from "./chat-flow.test-support.ts";
|
||||
|
||||
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
|
||||
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
|
||||
@@ -73,6 +74,64 @@ describeControlUiE2e("Control UI fenced code blocks", () => {
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user