From 517436f1d36444823e569e76194fa0762f3c0883 Mon Sep 17 00:00:00 2001 From: SivanCola Date: Mon, 3 Aug 2026 23:43:01 +0800 Subject: [PATCH] fix: prevent long chat webview freezes --- CHANGELOG.md | 1 + esbuild.mjs | 2 +- src/markdown.ts | 109 +++++++++++++++++++++++++++++++++++++ src/webview.ts | 79 ++++++--------------------- test/markdown.test.ts | 41 ++++++++++++++ test/vscode/fake-acp.cjs | 25 +++++++++ test/vscode/runTest.ts | 18 +++++- test/vscode/suite/index.ts | 13 +++++ 8 files changed, 223 insertions(+), 65 deletions(-) create mode 100644 src/markdown.ts create mode 100644 test/markdown.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f5d6f09..e8fc1cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Fixed long chat sessions freezing the VS Code renderer by removing selection-change snapshot floods, coalescing Host updates, sending revisioned transcript splices instead of the full history for every ACP chunk, bounding the rendered transcript window, and persisting only lightweight Webview UI state. - Fixed Windows ACP startup for npm-installed Reasonix CLIs by preferring the packaged native executable, resolving `.cmd` and explicitly configured extensionless shims, and safely launching custom command wrappers when no native binary is available. - Updated the chat Webview to follow VS Code theme colors across light and dark themes instead of mixing fixed dark surfaces and highlights into the active theme. +- Prevented malformed or extended Markdown fences from entering a non-progress render loop and freezing the Webview. ## 0.3.0 diff --git a/esbuild.mjs b/esbuild.mjs index 375c658..0ba8d45 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -27,7 +27,7 @@ const builds = [ }, { ...common, - entryPoints: ["test/acpProtocol.test.ts", "test/attachments.test.ts", "test/jsonRpc.test.ts", "test/chatState.test.ts", "test/webviewProtocol.test.ts", "test/sanitize.test.ts", "test/keyboard.test.ts", "test/resourceMentions.test.ts", "test/resourceSuggestions.test.ts", "test/slashCommands.test.ts", "test/composerSuggestions.test.ts", "test/snapshotSync.test.ts", "test/reasonixLauncher.test.ts", "test/themeStyles.test.ts"], + entryPoints: ["test/acpProtocol.test.ts", "test/attachments.test.ts", "test/jsonRpc.test.ts", "test/chatState.test.ts", "test/webviewProtocol.test.ts", "test/sanitize.test.ts", "test/keyboard.test.ts", "test/resourceMentions.test.ts", "test/resourceSuggestions.test.ts", "test/slashCommands.test.ts", "test/composerSuggestions.test.ts", "test/snapshotSync.test.ts", "test/reasonixLauncher.test.ts", "test/themeStyles.test.ts", "test/markdown.test.ts"], outdir: "dist/test", platform: "node", format: "cjs", diff --git a/src/markdown.ts b/src/markdown.ts new file mode 100644 index 0000000..213c36a --- /dev/null +++ b/src/markdown.ts @@ -0,0 +1,109 @@ +export type MarkdownBlock = + | { kind: "code"; language: string; text: string } + | { kind: "unorderedList"; items: string[] } + | { kind: "orderedList"; items: string[] } + | { kind: "heading"; level: 1 | 2 | 3; text: string } + | { kind: "paragraph"; text: string }; + +type Fence = { + marker: "`" | "~"; + length: number; + language: string; +}; + +export function parseMarkdownBlocks(text: string): MarkdownBlock[] { + const lines = text.replace(/\r\n/g, "\n").split("\n"); + const blocks: MarkdownBlock[] = []; + let index = 0; + + while (index < lines.length) { + const line = lines[index] ?? ""; + if (line.trim() === "") { + index += 1; + continue; + } + + const fence = openingFence(line); + if (fence) { + const code: string[] = []; + index += 1; + while (index < lines.length && !isClosingFence(lines[index] ?? "", fence)) { + code.push(lines[index] ?? ""); + index += 1; + } + if (index < lines.length) { + index += 1; + } + blocks.push({ kind: "code", language: fence.language, text: code.join("\n") }); + continue; + } + + if (/^\s*[-*]\s+/.test(line)) { + const items: string[] = []; + while (index < lines.length && /^\s*[-*]\s+/.test(lines[index] ?? "")) { + items.push((lines[index] ?? "").replace(/^\s*[-*]\s+/, "")); + index += 1; + } + blocks.push({ kind: "unorderedList", items }); + continue; + } + + if (/^\s*\d+\.\s+/.test(line)) { + const items: string[] = []; + while (index < lines.length && /^\s*\d+\.\s+/.test(lines[index] ?? "")) { + items.push((lines[index] ?? "").replace(/^\s*\d+\.\s+/, "")); + index += 1; + } + blocks.push({ kind: "orderedList", items }); + continue; + } + + const heading = line.match(/^(#{1,3})\s+(.+)$/); + if (heading) { + blocks.push({ + kind: "heading", + level: heading[1]?.length as 1 | 2 | 3, + text: heading[2] ?? "", + }); + index += 1; + continue; + } + + const paragraph: string[] = []; + do { + paragraph.push(lines[index] ?? ""); + index += 1; + } while (index < lines.length && !startsBlock(lines[index] ?? "")); + blocks.push({ kind: "paragraph", text: paragraph.join("\n") }); + } + + return blocks; +} + +function startsBlock(line: string): boolean { + return line.trim() === "" + || openingFence(line) !== undefined + || /^\s*[-*]\s+/.test(line) + || /^\s*\d+\.\s+/.test(line) + || /^(#{1,3})\s+/.test(line); +} + +function openingFence(line: string): Fence | undefined { + const match = line.match(/^\s{0,3}(`{3,}|~{3,})(.*)$/); + if (!match) { + return undefined; + } + const run = match[1] ?? ""; + const info = (match[2] ?? "").trim(); + return { + marker: run[0] as "`" | "~", + length: run.length, + language: info.split(/\s+/, 1)[0] ?? "", + }; +} + +function isClosingFence(line: string, fence: Fence): boolean { + const match = line.match(/^\s{0,3}(`{3,}|~{3,})\s*$/); + const run = match?.[1] ?? ""; + return run.startsWith(fence.marker) && run.length >= fence.length; +} diff --git a/src/webview.ts b/src/webview.ts index 2d742b6..4d690f3 100644 --- a/src/webview.ts +++ b/src/webview.ts @@ -3,6 +3,7 @@ import { MAX_ATTACHMENTS, type PendingAttachment } from "./attachments"; import type { ChatItem } from "./chatState"; import { getComposerTrigger, replaceComposerTrigger, slashSuggestions, type ComposerTrigger } from "./composerSuggestions"; import { shouldSubmitPromptOnKeydown } from "./keyboard"; +import { parseMarkdownBlocks } from "./markdown"; import type { ResourceSuggestion } from "./resourceSuggestions"; import { applyTranscriptSplice, transcriptWindowStart, type TranscriptSplice } from "./snapshotSync"; import type { HostToWebviewMessage } from "./webviewProtocol"; @@ -2354,80 +2355,34 @@ function detailsBlock(summaryText: string, text: string): HTMLElement { function renderMarkdown(text: string): HTMLElement { const root = document.createElement("div"); root.className = "markdown"; - const lines = text.replace(/\r\n/g, "\n").split("\n"); - let i = 0; - while (i < lines.length) { - const line = lines[i] ?? ""; - if (line.trim() === "") { - i += 1; - continue; - } - - const fence = line.match(/^```([\w.+-]*)\s*$/); - if (fence) { - const language = fence[1] ?? ""; - const code: string[] = []; - i += 1; - while (i < lines.length && !/^```\s*$/.test(lines[i] ?? "")) { - code.push(lines[i] ?? ""); - i += 1; - } - if (i < lines.length) { - i += 1; - } - root.append(codeBlock(code.join("\n"), language)); - continue; - } - - if (/^\s*[-*]\s+/.test(line)) { + for (const block of parseMarkdownBlocks(text)) { + if (block.kind === "code") { + root.append(codeBlock(block.text, block.language)); + } else if (block.kind === "unorderedList") { const list = document.createElement("ul"); - while (i < lines.length && /^\s*[-*]\s+/.test(lines[i] ?? "")) { + for (const text of block.items) { const item = document.createElement("li"); - appendInline(item, (lines[i] ?? "").replace(/^\s*[-*]\s+/, "")); + appendInline(item, text); list.append(item); - i += 1; } root.append(list); - continue; - } - - if (/^\s*\d+\.\s+/.test(line)) { + } else if (block.kind === "orderedList") { const list = document.createElement("ol"); - while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i] ?? "")) { + for (const text of block.items) { const item = document.createElement("li"); - appendInline(item, (lines[i] ?? "").replace(/^\s*\d+\.\s+/, "")); + appendInline(item, text); list.append(item); - i += 1; } root.append(list); - continue; - } - - const heading = line.match(/^(#{1,3})\s+(.+)$/); - if (heading) { - const level = heading[1]?.length ?? 2; - const h = document.createElement(`h${level + 2}`) as HTMLHeadingElement; - appendInline(h, heading[2] ?? ""); + } else if (block.kind === "heading") { + const h = document.createElement(`h${block.level + 2}`) as HTMLHeadingElement; + appendInline(h, block.text); root.append(h); - i += 1; - continue; - } - - const paragraph: string[] = []; - while ( - i < lines.length && - (lines[i] ?? "").trim() !== "" && - !/^```/.test(lines[i] ?? "") && - !/^\s*[-*]\s+/.test(lines[i] ?? "") && - !/^\s*\d+\.\s+/.test(lines[i] ?? "") && - !/^(#{1,3})\s+/.test(lines[i] ?? "") - ) { - paragraph.push(lines[i] ?? ""); - i += 1; + } else { + const p = document.createElement("p"); + appendInline(p, block.text); + root.append(p); } - const p = document.createElement("p"); - appendInline(p, paragraph.join("\n")); - root.append(p); } return root; } diff --git a/test/markdown.test.ts b/test/markdown.test.ts new file mode 100644 index 0000000..e21b630 --- /dev/null +++ b/test/markdown.test.ts @@ -0,0 +1,41 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { parseMarkdownBlocks } from "../src/markdown"; + +test("parseMarkdownBlocks accepts common and extended fence info without stalling", () => { + assert.deepEqual(parseMarkdownBlocks("```c#\nConsole.WriteLine();\n```"), [ + { kind: "code", language: "c#", text: "Console.WriteLine();" }, + ]); + assert.deepEqual(parseMarkdownBlocks("````python title=demo\nprint('ok')\n````"), [ + { kind: "code", language: "python", text: "print('ok')" }, + ]); + assert.deepEqual(parseMarkdownBlocks("~~~{.matlab}\ndisp('ok')\n~~~"), [ + { kind: "code", language: "{.matlab}", text: "disp('ok')" }, + ]); +}); + +test("parseMarkdownBlocks consumes unclosed and unusual fences to completion", () => { + assert.deepEqual(parseMarkdownBlocks("```python title=demo\nprint('ok')"), [ + { kind: "code", language: "python", text: "print('ok')" }, + ]); + assert.deepEqual(parseMarkdownBlocks("````\nraw\n```\nstill raw"), [ + { kind: "code", language: "", text: "raw\n```\nstill raw" }, + ]); +}); + +test("parseMarkdownBlocks preserves the supported block structure", () => { + assert.deepEqual(parseMarkdownBlocks("# Heading\n\n- one\n- two\n\n1. first\n\nparagraph\ncontinued"), [ + { kind: "heading", level: 1, text: "Heading" }, + { kind: "unorderedList", items: ["one", "two"] }, + { kind: "orderedList", items: ["first"] }, + { kind: "paragraph", text: "paragraph\ncontinued" }, + ]); +}); + +test("parseMarkdownBlocks handles a large transcript payload in one pass", () => { + const input = Array.from({ length: 10_000 }, (_, index) => `line ${index}`).join("\n"); + const blocks = parseMarkdownBlocks(input); + assert.equal(blocks.length, 1); + assert.equal(blocks[0]?.kind, "paragraph"); + assert.equal(blocks[0]?.kind === "paragraph" ? blocks[0].text.length : 0, input.length); +}); diff --git a/test/vscode/fake-acp.cjs b/test/vscode/fake-acp.cjs index 2204526..9d916cd 100755 --- a/test/vscode/fake-acp.cjs +++ b/test/vscode/fake-acp.cjs @@ -296,6 +296,31 @@ function handlePrompt(message) { log({ method: "process/disconnect-probe" }); process.exit(23); } + if (text.includes("long_webview_probe")) { + const payload = "x".repeat(1_560); + for (let index = 0; index < 205; index += 1) { + notify("session/update", { + sessionId, + update: { + sessionUpdate: index % 2 === 0 ? "agent_message_chunk" : "agent_thought_chunk", + content: { type: "text", text: `history-${index} ${payload}` }, + }, + }); + } + notify("session/update", { + sessionId, + update: { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "```c# title=demo\nlong-history-tail\n```" }, + }, + }); + log({ method: "webview/long-history-sent", itemCount: 206 }); + result(message.id, { stopReason: "end_turn" }); + return; + } + if (text.includes("post_history_probe")) { + log({ method: "webview/post-history-prompt" }); + } if (text.includes("permission_probe")) { const toolCallId = "fake-permission-tool"; notify("session/update", { sessionId, update: { sessionUpdate: "tool_call", toolCallId, title: "write_file", kind: "edit", status: "pending", rawInput: { path: "sample.ts" }, locations: [{ path: message.params?.cwd || "sample.ts", line: 1 }] } }); diff --git a/test/vscode/runTest.ts b/test/vscode/runTest.ts index 79ee009..4505139 100644 --- a/test/vscode/runTest.ts +++ b/test/vscode/runTest.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { runTests } from "@vscode/test-electron"; +import { downloadAndUnzipVSCode, runTests } from "@vscode/test-electron"; async function main(): Promise { const extensionDevelopmentPath = path.resolve(__dirname, "../../.."); @@ -18,8 +18,9 @@ async function main(): Promise { fs.writeFileSync(path.join(workspacePath, "src", "helper.ts"), "export const helper = true;\n"); writeFakeAcpWrapper(fakeAcp, fakeAcpScript); + const testExecutable = vscodeExecutablePath ?? await downloadAndUnzipVSCode("stable"); await runTests({ - ...(vscodeExecutablePath ? { vscodeExecutablePath } : {}), + vscodeExecutablePath: existingVSCodeExecutable(testExecutable), extensionDevelopmentPath, extensionTestsPath, launchArgs: [workspacePath, "--user-data-dir", userDataDir, "--disable-extensions", "--disable-workspace-trust"], @@ -34,6 +35,19 @@ async function main(): Promise { }); } +function existingVSCodeExecutable(downloadedExecutable: string): string { + if (fs.existsSync(downloadedExecutable)) { + return downloadedExecutable; + } + if (process.platform === "darwin" && downloadedExecutable.endsWith("/MacOS/Electron")) { + const renamedExecutable = downloadedExecutable.slice(0, -"Electron".length) + "Code"; + if (fs.existsSync(renamedExecutable)) { + return renamedExecutable; + } + } + return downloadedExecutable; +} + function writeFakeAcpWrapper(target: string, script: string): void { if (process.platform === "win32") { fs.writeFileSync(target, `@echo off\r\n"${process.execPath}" "${script}" %*\r\n`); diff --git a/test/vscode/suite/index.ts b/test/vscode/suite/index.ts index 512bfbe..e4868a7 100644 --- a/test/vscode/suite/index.ts +++ b/test/vscode/suite/index.ts @@ -193,6 +193,19 @@ export async function run(): Promise { await waitForLog(fakeLog, "session/prompt/cancelled"); await slowTurn; + await vscode.commands.executeCommand("reasonix.test.webviewMessage", { + command: "sendPrompt", + text: "long_webview_probe", + }); + await waitForLog(fakeLog, "webview/long-history-sent"); + await waitForSnapshot((state) => state.items?.some((item: TestRecord) => item.text?.includes("long-history-tail"))); + await vscode.commands.executeCommand("reasonix.openChat"); + await vscode.commands.executeCommand("reasonix.test.webviewMessage", { + command: "sendPrompt", + text: "post_history_probe", + }); + await waitForLog(fakeLog, "webview/post-history-prompt"); + await vscode.commands.executeCommand("reasonix.test.webviewMessage", { command: "sendPrompt", text: "disconnect_probe" }); await waitForLog(fakeLog, "process/disconnect-probe"); await waitForLog(fakeLog, "session/resume");