From 6b55b07089733e4ac51e5cc44759efc8067667fe Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Feb 2026 01:06:06 -0800 Subject: [PATCH 1/9] Use shared Shiki highlighting for chat markdown code blocks - Replace `rehype-highlight`/`highlight.js` with `@pierre/diffs` highlighter in `ChatMarkdown` - Add LRU caching and in-flight dedupe for highlighted code, with streaming-aware behavior - Extract shared diff theme resolution utility and add unit tests for new LRU cache --- apps/web/package.json | 2 - apps/web/src/components/ChatMarkdown.tsx | 227 +++++++++++++++++- apps/web/src/components/ChatView.tsx | 6 +- apps/web/src/components/DiffPanel.tsx | 5 +- .../src/components/DiffWorkerPoolProvider.tsx | 7 +- apps/web/src/lib/diffThemes.ts | 10 + apps/web/src/lib/lruCache.test.ts | 43 ++++ apps/web/src/lib/lruCache.ts | 64 +++++ apps/web/src/main.tsx | 1 - bun.lock | 14 -- 10 files changed, 343 insertions(+), 36 deletions(-) create mode 100644 apps/web/src/lib/diffThemes.ts create mode 100644 apps/web/src/lib/lruCache.test.ts create mode 100644 apps/web/src/lib/lruCache.ts diff --git a/apps/web/package.json b/apps/web/package.json index 705b6776cfc0..4d56b6be5db1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,12 +22,10 @@ "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "effect": "catalog:", - "highlight.js": "^11.11.1", "lucide-react": "^0.564.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", - "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0" }, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 2676da0d21bd..9b8438662452 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,18 +1,215 @@ -import { memo, useMemo } from "react"; +import { getSharedHighlighter } from "@pierre/diffs"; +import { + Children, + isValidElement, + memo, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; -import rehypeHighlight from "rehype-highlight"; import remarkGfm from "remark-gfm"; +import { LRUCache } from "../lib/lruCache"; import { readNativeApi } from "../nativeApi"; +import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffThemes"; +import { useTheme } from "../hooks/useTheme"; import { resolveMarkdownFileLinkTarget } from "../markdown-links"; import { preferredTerminalEditor } from "../terminal-links"; interface ChatMarkdownProps { text: string; cwd: string | undefined; + isStreaming?: boolean; } -function ChatMarkdown({ text, cwd }: ChatMarkdownProps) { +const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; +const MAX_HIGHLIGHT_CACHE_ENTRIES = 500; +const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; +const highlightedCodeCache = new LRUCache( + MAX_HIGHLIGHT_CACHE_ENTRIES, + MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, +); +const inFlightHighlights = new Map>(); + +function hashString(input: string): string { + // FNV-1a 32-bit hash. + let hash = 0x811c9dc5; + for (let index = 0; index < input.length; index += 1) { + hash ^= input.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(36); +} + +function extractFenceLanguage(className: string | undefined): string { + const match = className?.match(CODE_FENCE_LANGUAGE_REGEX); + return match?.[1] ?? "text"; +} + +function nodeToPlainText(node: ReactNode): string { + if (typeof node === "string" || typeof node === "number") { + return String(node); + } + if (Array.isArray(node)) { + return node.map((child) => nodeToPlainText(child)).join(""); + } + if (isValidElement<{ children?: ReactNode }>(node)) { + return nodeToPlainText(node.props.children); + } + return ""; +} + +function extractCodeBlock( + children: ReactNode, +): { className: string | undefined; code: string } | null { + const childNodes = Children.toArray(children); + if (childNodes.length !== 1) { + return null; + } + + const onlyChild = childNodes[0]; + if ( + !isValidElement<{ className?: string; children?: ReactNode }>(onlyChild) || + onlyChild.type !== "code" + ) { + return null; + } + + return { + className: onlyChild.props.className, + code: nodeToPlainText(onlyChild.props.children), + }; +} + +async function highlightCodeBlock(code: string, language: string, themeName: DiffThemeName) { + try { + const highlighter = await getSharedHighlighter({ + themes: [themeName], + langs: [language], + }); + return highlighter.codeToHtml(code, { lang: language, theme: themeName }); + } catch { + const highlighter = await getSharedHighlighter({ + themes: [themeName], + langs: ["text"], + }); + return highlighter.codeToHtml(code, { lang: "text", theme: themeName }); + } +} + +function createHighlightCacheKey(code: string, language: string, themeName: DiffThemeName): string { + return `${hashString(code)}:${code.length}:${language}:${themeName}`; +} + +function estimateHighlightedSize(html: string, code: string): number { + return Math.max(html.length * 2, code.length * 3); +} + +function getCachedOrCreateHighlight({ + cacheKey, + code, + language, + themeName, + useCache, +}: { + cacheKey: string; + code: string; + language: string; + themeName: DiffThemeName; + useCache: boolean; +}): Promise { + if (useCache) { + const cached = highlightedCodeCache.get(cacheKey); + if (cached) { + return Promise.resolve(cached); + } + } + + const existingInFlight = inFlightHighlights.get(cacheKey); + if (existingInFlight) { + return existingInFlight; + } + + const request = highlightCodeBlock(code, language, themeName) + .then((html) => { + if (useCache) { + highlightedCodeCache.set(cacheKey, html, estimateHighlightedSize(html, code)); + } + return html; + }) + .finally(() => { + inFlightHighlights.delete(cacheKey); + }); + + inFlightHighlights.set(cacheKey, request); + return request; +} + +interface ShikiCodeBlockProps { + className: string | undefined; + code: string; + themeName: DiffThemeName; + isStreaming: boolean; + fallback: ReactNode; +} + +function ShikiCodeBlock({ className, code, themeName, isStreaming, fallback }: ShikiCodeBlockProps) { + const [highlightedHtml, setHighlightedHtml] = useState(null); + + useEffect(() => { + let cancelled = false; + const language = extractFenceLanguage(className); + const cacheKey = createHighlightCacheKey(code, language, themeName); + + if (!isStreaming) { + const cached = highlightedCodeCache.get(cacheKey); + if (cached) { + setHighlightedHtml(cached); + return () => { + cancelled = true; + }; + } + } + + setHighlightedHtml(null); + void getCachedOrCreateHighlight({ + cacheKey, + code, + language, + themeName, + useCache: !isStreaming, + }) + .then((html) => { + if (cancelled) { + return; + } + setHighlightedHtml(html); + }) + .catch(() => { + if (!cancelled) { + setHighlightedHtml(null); + } + }); + + return () => { + cancelled = true; + }; + }, [className, code, themeName, isStreaming]); + + if (!highlightedHtml) { + return fallback; + } + + return ( +
+ ); +} + +function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { + const { resolvedTheme } = useTheme(); + const diffThemeName = resolveDiffThemeName(resolvedTheme); const markdownComponents = useMemo( () => ({ a({ node: _node, href, ...props }) { @@ -38,17 +235,29 @@ function ChatMarkdown({ text, cwd }: ChatMarkdownProps) { /> ); }, + pre({ node: _node, children, ...props }) { + const codeBlock = extractCodeBlock(children); + if (!codeBlock) { + return
{children}
; + } + + return ( + {children}} + /> + ); + }, }), - [cwd], + [cwd, diffThemeName, isStreaming], ); return (
- + {text}
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8a7b9e48bd3b..349c7ec1c21e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2759,7 +2759,11 @@ const MessagesTimeline = memo(function MessagesTimeline({
)}
- + {(() => { const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id); if (!turnSummary) return null; diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 61243fc64931..acf07f94f484 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -11,6 +11,7 @@ import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch" import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; import { buildPatchCacheKey } from "../lib/diffRendering"; +import { resolveDiffThemeName } from "../lib/diffThemes"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useStore } from "../store"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; @@ -18,10 +19,6 @@ import { ToggleGroup, Toggle } from "./ui/toggle-group"; type DiffRenderMode = "stacked" | "split"; type DiffThemeType = "light" | "dark"; -function resolveDiffThemeName(theme: "light" | "dark") { - return theme === "dark" ? "pierre-dark" : "pierre-light"; -} - type RenderablePatch = | { kind: "files"; diff --git a/apps/web/src/components/DiffWorkerPoolProvider.tsx b/apps/web/src/components/DiffWorkerPoolProvider.tsx index f5930a19b772..47f91efcb9e2 100644 --- a/apps/web/src/components/DiffWorkerPoolProvider.tsx +++ b/apps/web/src/components/DiffWorkerPoolProvider.tsx @@ -2,12 +2,9 @@ import { WorkerPoolContextProvider, useWorkerPool } from "@pierre/diffs/react"; import DiffsWorker from "@pierre/diffs/worker/worker.js?worker"; import { useEffect, useMemo, type ReactNode } from "react"; import { useTheme } from "../hooks/useTheme"; +import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffThemes"; -function resolveDiffThemeName(theme: "light" | "dark") { - return theme === "dark" ? "pierre-dark" : "pierre-light"; -} - -function DiffWorkerThemeSync({ themeName }: { themeName: "pierre-light" | "pierre-dark" }) { +function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { const workerPool = useWorkerPool(); useEffect(() => { diff --git a/apps/web/src/lib/diffThemes.ts b/apps/web/src/lib/diffThemes.ts new file mode 100644 index 000000000000..c38fa8d00059 --- /dev/null +++ b/apps/web/src/lib/diffThemes.ts @@ -0,0 +1,10 @@ +export const DIFF_THEME_NAMES = { + light: "pierre-light", + dark: "pierre-dark", +} as const; + +export type DiffThemeName = (typeof DIFF_THEME_NAMES)[keyof typeof DIFF_THEME_NAMES]; + +export function resolveDiffThemeName(theme: "light" | "dark"): DiffThemeName { + return theme === "dark" ? DIFF_THEME_NAMES.dark : DIFF_THEME_NAMES.light; +} diff --git a/apps/web/src/lib/lruCache.test.ts b/apps/web/src/lib/lruCache.test.ts new file mode 100644 index 000000000000..7f53db2607ed --- /dev/null +++ b/apps/web/src/lib/lruCache.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { LRUCache } from "./lruCache"; + +describe("LRUCache", () => { + it("returns null for missing keys", () => { + const cache = new LRUCache(2, 100); + expect(cache.get("missing")).toBeNull(); + }); + + it("evicts oldest by max entries", () => { + const cache = new LRUCache(2, 1_000); + cache.set("a", "A", 10); + cache.set("b", "B", 10); + cache.set("c", "C", 10); + + expect(cache.get("a")).toBeNull(); + expect(cache.get("b")).toBe("B"); + expect(cache.get("c")).toBe("C"); + }); + + it("promotes on get and evicts least recently used", () => { + const cache = new LRUCache(2, 1_000); + cache.set("a", "A", 10); + cache.set("b", "B", 10); + expect(cache.get("a")).toBe("A"); + + cache.set("c", "C", 10); + expect(cache.get("a")).toBe("A"); + expect(cache.get("b")).toBeNull(); + expect(cache.get("c")).toBe("C"); + }); + + it("evicts by memory budget", () => { + const cache = new LRUCache(10, 25); + cache.set("a", "A", 10); + cache.set("b", "B", 10); + cache.set("c", "C", 10); + + expect(cache.get("a")).toBeNull(); + expect(cache.get("b")).toBe("B"); + expect(cache.get("c")).toBe("C"); + }); +}); diff --git a/apps/web/src/lib/lruCache.ts b/apps/web/src/lib/lruCache.ts new file mode 100644 index 000000000000..141c796e737e --- /dev/null +++ b/apps/web/src/lib/lruCache.ts @@ -0,0 +1,64 @@ +interface CacheEntry { + value: T; + approximateSize: number; +} + +export class LRUCache { + private cache = new Map>(); + private totalSize = 0; + + constructor( + private readonly maxEntries: number, + private readonly maxMemoryBytes: number, + ) {} + + get(key: string): T | null { + const entry = this.cache.get(key); + if (!entry) { + return null; + } + + this.promote(key, entry); + return entry.value; + } + + set(key: string, value: T, approximateSize: number): void { + const existing = this.cache.get(key); + if (existing) { + this.totalSize -= existing.approximateSize; + this.cache.delete(key); + } + + this.evictIfNeeded(approximateSize); + this.cache.set(key, { value, approximateSize }); + this.totalSize += approximateSize; + } + + clear(): void { + this.cache.clear(); + this.totalSize = 0; + } + + private promote(key: string, entry: CacheEntry): void { + this.cache.delete(key); + this.cache.set(key, entry); + } + + private evictIfNeeded(incomingSize: number): void { + while ( + (this.cache.size >= this.maxEntries || this.totalSize + incomingSize > this.maxMemoryBytes) && + this.cache.size > 0 + ) { + const oldestKey = this.cache.keys().next().value; + if (oldestKey === undefined) { + break; + } + + const oldestEntry = this.cache.get(oldestKey); + if (oldestEntry) { + this.totalSize -= oldestEntry.approximateSize; + } + this.cache.delete(oldestKey); + } + } +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 0de3ae3c4e7e..b735484b2b87 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -6,7 +6,6 @@ import { createHashHistory, createRouter, createBrowserHistory } from "@tanstack import { StoreProvider } from "./store"; import "@xterm/xterm/css/xterm.css"; -import "highlight.js/styles/github-dark.css"; import "./index.css"; import { APP_DISPLAY_NAME } from "./branding"; diff --git a/bun.lock b/bun.lock index 053e97675679..952f4c09e4f1 100644 --- a/bun.lock +++ b/bun.lock @@ -71,12 +71,10 @@ "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "effect": "catalog:", - "highlight.js": "^11.11.1", "lucide-react": "^0.564.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", - "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", }, @@ -863,20 +861,14 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="], - "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], - "hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="], - "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="], - "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], - "hookable": ["hookable@6.0.1", "", {}, "sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw=="], "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], @@ -983,8 +975,6 @@ "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], - "lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], @@ -1197,8 +1187,6 @@ "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], - "rehype-highlight": ["rehype-highlight@7.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-text": "^4.0.0", "lowlight": "^3.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA=="], - "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], @@ -1361,8 +1349,6 @@ "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], - "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], - "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], From b2edc7e9962d1d3dc6167759ac8fa8867577fab1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Feb 2026 10:53:55 -0800 Subject: [PATCH 2/9] Align diff panel surfaces with app theme colors - add diff-specific wrapper classes in `DiffPanel` for targeted theming - override diff renderer color variables to match card/background palette - soften chat markdown Shiki code-block background for consistency --- apps/web/src/components/DiffPanel.tsx | 8 +++-- apps/web/src/index.css | 50 +++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index acf07f94f484..d45ef6a26c5a 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -464,7 +464,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
) : renderablePatch.kind === "files" ? ( +
select#reasoning-effort) select { font-size: 0.875rem; } +.chat-markdown .chat-markdown-shiki .shiki { + background: color-mix(in srgb, var(--muted) 78%, var(--background)) !important; +} + .chat-markdown table { width: 100%; border-collapse: collapse; @@ -325,3 +329,49 @@ label:has(> select#reasoning-effort) select { padding: 0.35rem 0.45rem; text-align: left; } + +/* Diffs theme bridge (match diff surfaces to app palette) */ +.diff-render-surface [data-diffs-header], +.diff-render-surface [data-diff], +.diff-render-surface [data-file], +.diff-render-surface [data-error-wrapper], +.diff-render-surface [data-virtualizer-buffer] { + --diffs-light-bg: color-mix(in srgb, var(--card) 92%, var(--background)); + --diffs-dark-bg: color-mix(in srgb, var(--card) 92%, var(--background)); + + --diffs-bg-context-override: color-mix(in srgb, var(--background) 97%, var(--foreground)); + --diffs-bg-hover-override: color-mix(in srgb, var(--background) 94%, var(--foreground)); + --diffs-bg-separator-override: color-mix(in srgb, var(--background) 95%, var(--foreground)); + --diffs-bg-buffer-override: color-mix(in srgb, var(--background) 90%, var(--foreground)); + + --diffs-bg-addition-override: color-mix(in srgb, var(--background) 92%, var(--success)); + --diffs-bg-addition-number-override: color-mix(in srgb, var(--background) 88%, var(--success)); + --diffs-bg-addition-hover-override: color-mix(in srgb, var(--background) 85%, var(--success)); + --diffs-bg-addition-emphasis-override: color-mix(in srgb, var(--background) 80%, var(--success)); + + --diffs-bg-deletion-override: color-mix(in srgb, var(--background) 92%, var(--destructive)); + --diffs-bg-deletion-number-override: color-mix( + in srgb, + var(--background) 88%, + var(--destructive) + ); + --diffs-bg-deletion-hover-override: color-mix(in srgb, var(--background) 85%, var(--destructive)); + --diffs-bg-deletion-emphasis-override: color-mix( + in srgb, + var(--background) 80%, + var(--destructive) + ); +} + +.diff-render-surface [data-file-info] { + background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important; + border-block-color: var(--border) !important; + color: var(--foreground) !important; +} + +.diff-render-file [data-diff], +.diff-render-file [data-file] { + border: 1px solid var(--border); + border-radius: 0.5rem; + overflow: clip; +} From 37f0951239361c63cc42527599172a6b50f0dbc2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Feb 2026 11:10:11 -0800 Subject: [PATCH 3/9] Move diff theme overrides into DiffPanel unsafeCSS - Inject diff palette overrides via `unsafeCSS` in `DiffPanel` - Add `diff-panel-viewport` background and keep file container border/background styling in `index.css` - Switch panel root from `bg-card` to `bg-background` for consistent surface tone --- apps/web/src/components/DiffPanel.tsx | 49 +++++++++++++++++++++++++-- apps/web/src/index.css | 46 ++++--------------------- 2 files changed, 53 insertions(+), 42 deletions(-) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index d45ef6a26c5a..34f8bde99c19 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -19,6 +19,47 @@ import { ToggleGroup, Toggle } from "./ui/toggle-group"; type DiffRenderMode = "stacked" | "split"; type DiffThemeType = "light" | "dark"; +const DIFF_PANEL_UNSAFE_CSS = ` +[data-diffs-header], +[data-diff], +[data-file], +[data-error-wrapper], +[data-virtualizer-buffer] { + --diffs-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; + --diffs-light-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; + --diffs-dark-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; + --diffs-token-light-bg: transparent; + --diffs-token-dark-bg: transparent; + + --diffs-bg-context-override: color-mix(in srgb, var(--background) 97%, var(--foreground)); + --diffs-bg-hover-override: color-mix(in srgb, var(--background) 94%, var(--foreground)); + --diffs-bg-separator-override: color-mix(in srgb, var(--background) 95%, var(--foreground)); + --diffs-bg-buffer-override: color-mix(in srgb, var(--background) 90%, var(--foreground)); + + --diffs-bg-addition-override: color-mix(in srgb, var(--background) 92%, var(--success)); + --diffs-bg-addition-number-override: color-mix(in srgb, var(--background) 88%, var(--success)); + --diffs-bg-addition-hover-override: color-mix(in srgb, var(--background) 85%, var(--success)); + --diffs-bg-addition-emphasis-override: color-mix(in srgb, var(--background) 80%, var(--success)); + + --diffs-bg-deletion-override: color-mix(in srgb, var(--background) 92%, var(--destructive)); + --diffs-bg-deletion-number-override: color-mix(in srgb, var(--background) 88%, var(--destructive)); + --diffs-bg-deletion-hover-override: color-mix(in srgb, var(--background) 85%, var(--destructive)); + --diffs-bg-deletion-emphasis-override: color-mix( + in srgb, + var(--background) 80%, + var(--destructive) + ); + + background-color: var(--diffs-bg) !important; +} + +[data-file-info] { + background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important; + border-block-color: var(--border) !important; + color: var(--foreground) !important; +} +`; + type RenderablePatch = | { kind: "files"; @@ -422,7 +463,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { return (
) : ( <> -
+
{checkpointDiffError && !renderablePatch && (

{checkpointDiffError}

@@ -487,6 +531,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { lineDiffType: "none", theme: resolveDiffThemeName(resolvedTheme), themeType: resolvedTheme as DiffThemeType, + unsafeCSS: DIFF_PANEL_UNSAFE_CSS, }} />
diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 274c1d739e72..3cdadb01fd49 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -331,47 +331,13 @@ label:has(> select#reasoning-effort) select { } /* Diffs theme bridge (match diff surfaces to app palette) */ -.diff-render-surface [data-diffs-header], -.diff-render-surface [data-diff], -.diff-render-surface [data-file], -.diff-render-surface [data-error-wrapper], -.diff-render-surface [data-virtualizer-buffer] { - --diffs-light-bg: color-mix(in srgb, var(--card) 92%, var(--background)); - --diffs-dark-bg: color-mix(in srgb, var(--card) 92%, var(--background)); - - --diffs-bg-context-override: color-mix(in srgb, var(--background) 97%, var(--foreground)); - --diffs-bg-hover-override: color-mix(in srgb, var(--background) 94%, var(--foreground)); - --diffs-bg-separator-override: color-mix(in srgb, var(--background) 95%, var(--foreground)); - --diffs-bg-buffer-override: color-mix(in srgb, var(--background) 90%, var(--foreground)); - - --diffs-bg-addition-override: color-mix(in srgb, var(--background) 92%, var(--success)); - --diffs-bg-addition-number-override: color-mix(in srgb, var(--background) 88%, var(--success)); - --diffs-bg-addition-hover-override: color-mix(in srgb, var(--background) 85%, var(--success)); - --diffs-bg-addition-emphasis-override: color-mix(in srgb, var(--background) 80%, var(--success)); - - --diffs-bg-deletion-override: color-mix(in srgb, var(--background) 92%, var(--destructive)); - --diffs-bg-deletion-number-override: color-mix( - in srgb, - var(--background) 88%, - var(--destructive) - ); - --diffs-bg-deletion-hover-override: color-mix(in srgb, var(--background) 85%, var(--destructive)); - --diffs-bg-deletion-emphasis-override: color-mix( - in srgb, - var(--background) 80%, - var(--destructive) - ); -} - -.diff-render-surface [data-file-info] { - background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important; - border-block-color: var(--border) !important; - color: var(--foreground) !important; -} - -.diff-render-file [data-diff], -.diff-render-file [data-file] { +.diff-panel-viewport { + background: color-mix(in srgb, var(--background) 94%, var(--card)); +} + +.diff-render-file { border: 1px solid var(--border); border-radius: 0.5rem; overflow: clip; + background: color-mix(in srgb, var(--card) 92%, var(--background)); } From 39eea0dcfd9a136fdd533caf057a873e2ca2c6a8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Feb 2026 11:32:19 -0800 Subject: [PATCH 4/9] Fix code highlight cache behavior for streaming markdown - Reuse in-flight highlight requests and allow later non-streaming renders to cache results - Stabilize code block highlight state updates to avoid stale or redundant rerenders - Export and reuse `fnv1a32` from `diffRendering` for shared cache key hashing --- apps/web/src/components/ChatMarkdown.tsx | 92 +++++++++++++++--------- apps/web/src/lib/diffRendering.ts | 6 +- 2 files changed, 62 insertions(+), 36 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 9b8438662452..55ee4483843e 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -14,6 +14,7 @@ import remarkGfm from "remark-gfm"; import { LRUCache } from "../lib/lruCache"; import { readNativeApi } from "../nativeApi"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffThemes"; +import { fnv1a32 } from "../lib/diffRendering"; import { useTheme } from "../hooks/useTheme"; import { resolveMarkdownFileLinkTarget } from "../markdown-links"; import { preferredTerminalEditor } from "../terminal-links"; @@ -31,17 +32,11 @@ const highlightedCodeCache = new LRUCache( MAX_HIGHLIGHT_CACHE_ENTRIES, MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, ); -const inFlightHighlights = new Map>(); - -function hashString(input: string): string { - // FNV-1a 32-bit hash. - let hash = 0x811c9dc5; - for (let index = 0; index < input.length; index += 1) { - hash ^= input.charCodeAt(index); - hash = Math.imul(hash, 0x01000193); - } - return (hash >>> 0).toString(36); +interface InFlightHighlight { + promise: Promise; + enableCache: () => void; } +const inFlightHighlights = new Map(); function extractFenceLanguage(className: string | undefined): string { const match = className?.match(CODE_FENCE_LANGUAGE_REGEX); @@ -100,7 +95,7 @@ async function highlightCodeBlock(code: string, language: string, themeName: Dif } function createHighlightCacheKey(code: string, language: string, themeName: DiffThemeName): string { - return `${hashString(code)}:${code.length}:${language}:${themeName}`; + return `${fnv1a32(code).toString(36)}:${code.length}:${language}:${themeName}`; } function estimateHighlightedSize(html: string, code: string): number { @@ -129,12 +124,16 @@ function getCachedOrCreateHighlight({ const existingInFlight = inFlightHighlights.get(cacheKey); if (existingInFlight) { - return existingInFlight; + if (useCache) { + existingInFlight.enableCache(); + } + return existingInFlight.promise; } - const request = highlightCodeBlock(code, language, themeName) + let shouldCache = useCache; + const requestPromise = highlightCodeBlock(code, language, themeName) .then((html) => { - if (useCache) { + if (shouldCache) { highlightedCodeCache.set(cacheKey, html, estimateHighlightedSize(html, code)); } return html; @@ -143,8 +142,13 @@ function getCachedOrCreateHighlight({ inFlightHighlights.delete(cacheKey); }); - inFlightHighlights.set(cacheKey, request); - return request; + inFlightHighlights.set(cacheKey, { + promise: requestPromise, + enableCache: () => { + shouldCache = true; + }, + }); + return requestPromise; } interface ShikiCodeBlockProps { @@ -156,24 +160,41 @@ interface ShikiCodeBlockProps { } function ShikiCodeBlock({ className, code, themeName, isStreaming, fallback }: ShikiCodeBlockProps) { - const [highlightedHtml, setHighlightedHtml] = useState(null); + const language = extractFenceLanguage(className); + const cacheKey = createHighlightCacheKey(code, language, themeName); + const [highlightState, setHighlightState] = useState<{ + cacheKey: string; + html: string | null; + }>(() => ({ + cacheKey, + html: isStreaming ? null : (highlightedCodeCache.get(cacheKey) ?? null), + })); + const cachedHighlightedHtml = isStreaming ? null : (highlightedCodeCache.get(cacheKey) ?? null); + const highlightedHtml = + cachedHighlightedHtml ?? (highlightState.cacheKey === cacheKey ? highlightState.html : null); useEffect(() => { let cancelled = false; - const language = extractFenceLanguage(className); - const cacheKey = createHighlightCacheKey(code, language, themeName); - if (!isStreaming) { - const cached = highlightedCodeCache.get(cacheKey); - if (cached) { - setHighlightedHtml(cached); - return () => { - cancelled = true; - }; - } + if (cachedHighlightedHtml) { + setHighlightState((current) => { + if (current.cacheKey === cacheKey && current.html === cachedHighlightedHtml) { + return current; + } + return { cacheKey, html: cachedHighlightedHtml }; + }); + return () => { + cancelled = true; + }; } - setHighlightedHtml(null); + setHighlightState((current) => { + if (current.cacheKey === cacheKey) { + return current; + } + return { cacheKey, html: null }; + }); + void getCachedOrCreateHighlight({ cacheKey, code, @@ -185,18 +206,19 @@ function ShikiCodeBlock({ className, code, themeName, isStreaming, fallback }: S if (cancelled) { return; } - setHighlightedHtml(html); + setHighlightState((current) => { + if (current.cacheKey === cacheKey && current.html === html) { + return current; + } + return { cacheKey, html }; + }); }) - .catch(() => { - if (!cancelled) { - setHighlightedHtml(null); - } - }); + .catch(() => undefined); return () => { cancelled = true; }; - }, [className, code, themeName, isStreaming]); + }, [cacheKey, cachedHighlightedHtml, code, isStreaming, language, themeName]); if (!highlightedHtml) { return fallback; diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index a5631ef8583b..c61ad69f535b 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -3,7 +3,11 @@ const FNV_PRIME_32 = 0x01000193; const SECONDARY_HASH_SEED = 0x9e3779b9; const SECONDARY_HASH_MULTIPLIER = 0x85ebca6b; -function fnv1a32(input: string, seed: number, multiplier: number): number { +export function fnv1a32( + input: string, + seed = FNV_OFFSET_BASIS_32, + multiplier = FNV_PRIME_32, +): number { let hash = seed >>> 0; for (let index = 0; index < input.length; index += 1) { hash ^= input.charCodeAt(index); From 5c16da6fba7d0961178cc89cd8ba9b08de915845 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Feb 2026 11:39:36 -0800 Subject: [PATCH 5/9] Add copy button to chat markdown code blocks - Wrap rendered markdown code blocks with a copy action button - Show copy/check icons with transient "Copied" state and cleanup - Style button to appear on hover/focus without shifting code layout --- apps/web/src/components/ChatMarkdown.tsx | 67 +++++++++++++++++++++--- apps/web/src/index.css | 42 +++++++++++++++ 2 files changed, 102 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 55ee4483843e..ae82b805d67c 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,10 +1,13 @@ import { getSharedHighlighter } from "@pierre/diffs"; +import { CheckIcon, CopyIcon } from "lucide-react"; import { Children, isValidElement, + useCallback, memo, useEffect, useMemo, + useRef, useState, type ReactNode, } from "react"; @@ -159,6 +162,54 @@ interface ShikiCodeBlockProps { fallback: ReactNode; } +function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNode }) { + const [copied, setCopied] = useState(false); + const copiedTimerRef = useRef | null>(null); + const handleCopy = useCallback(() => { + if (typeof navigator === "undefined" || navigator.clipboard == null) { + return; + } + void navigator.clipboard + .writeText(code) + .then(() => { + if (copiedTimerRef.current != null) { + clearTimeout(copiedTimerRef.current); + } + setCopied(true); + copiedTimerRef.current = setTimeout(() => { + setCopied(false); + copiedTimerRef.current = null; + }, 1200); + }) + .catch(() => undefined); + }, [code]); + + useEffect( + () => () => { + if (copiedTimerRef.current != null) { + clearTimeout(copiedTimerRef.current); + copiedTimerRef.current = null; + } + }, + [], + ); + + return ( +
+ + {children} +
+ ); +} + function ShikiCodeBlock({ className, code, themeName, isStreaming, fallback }: ShikiCodeBlockProps) { const language = extractFenceLanguage(className); const cacheKey = createHighlightCacheKey(code, language, themeName); @@ -264,13 +315,15 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { } return ( - {children}} - /> + + {children}} + /> + ); }, }), diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 3cdadb01fd49..e752f8ef658c 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -314,6 +314,48 @@ label:has(> select#reasoning-effort) select { font-size: 0.875rem; } +.chat-markdown .chat-markdown-codeblock { + position: relative; + margin: 0.65rem 0; +} + +.chat-markdown .chat-markdown-codeblock pre { + margin: 0; +} + +.chat-markdown .chat-markdown-copy-button { + position: absolute; + top: 0.5rem; + right: 0.5rem; + z-index: 1; + display: inline-flex; + align-items: center; + justify-content: center; + height: 1.5rem; + width: 1.5rem; + border-radius: 0.375rem; + border: 1px solid var(--border); + background: color-mix(in srgb, var(--background) 82%, transparent); + color: var(--muted-foreground); + opacity: 0; + pointer-events: none; + transition: + opacity 120ms ease, + color 120ms ease, + border-color 120ms ease; +} + +.chat-markdown .chat-markdown-codeblock:hover .chat-markdown-copy-button, +.chat-markdown .chat-markdown-codeblock:focus-within .chat-markdown-copy-button { + opacity: 1; + pointer-events: auto; +} + +.chat-markdown .chat-markdown-copy-button:hover { + color: var(--foreground); + border-color: color-mix(in srgb, var(--border) 70%, var(--foreground)); +} + .chat-markdown .chat-markdown-shiki .shiki { background: color-mix(in srgb, var(--muted) 78%, var(--background)) !important; } From 8bfc45d29dc114ab3a69be1a68d07da625b73652 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Feb 2026 15:10:49 -0800 Subject: [PATCH 6/9] simp --- apps/web/src/components/ChatMarkdown.tsx | 214 ++++++++--------------- 1 file changed, 71 insertions(+), 143 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index ae82b805d67c..9d39849802f3 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,8 +1,14 @@ -import { getSharedHighlighter } from "@pierre/diffs"; +import { + getSharedHighlighter, + type DiffsHighlighter, + type SupportedLanguages, +} from "@pierre/diffs"; import { CheckIcon, CopyIcon } from "lucide-react"; import { Children, + Suspense, isValidElement, + use, useCallback, memo, useEffect, @@ -14,12 +20,12 @@ import { import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; -import { LRUCache } from "../lib/lruCache"; -import { readNativeApi } from "../nativeApi"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffThemes"; import { fnv1a32 } from "../lib/diffRendering"; +import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; import { resolveMarkdownFileLinkTarget } from "../markdown-links"; +import { readNativeApi } from "../nativeApi"; import { preferredTerminalEditor } from "../terminal-links"; interface ChatMarkdownProps { @@ -35,11 +41,7 @@ const highlightedCodeCache = new LRUCache( MAX_HIGHLIGHT_CACHE_ENTRIES, MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, ); -interface InFlightHighlight { - promise: Promise; - enableCache: () => void; -} -const inFlightHighlights = new Map(); +const highlighterPromiseCache = new Map>(); function extractFenceLanguage(className: string | undefined): string { const match = className?.match(CODE_FENCE_LANGUAGE_REGEX); @@ -81,85 +83,33 @@ function extractCodeBlock( }; } -async function highlightCodeBlock(code: string, language: string, themeName: DiffThemeName) { - try { - const highlighter = await getSharedHighlighter({ - themes: [themeName], - langs: [language], - }); - return highlighter.codeToHtml(code, { lang: language, theme: themeName }); - } catch { - const highlighter = await getSharedHighlighter({ - themes: [themeName], - langs: ["text"], - }); - return highlighter.codeToHtml(code, { lang: "text", theme: themeName }); - } -} - function createHighlightCacheKey(code: string, language: string, themeName: DiffThemeName): string { return `${fnv1a32(code).toString(36)}:${code.length}:${language}:${themeName}`; } +function createHighlighterPromiseKey(language: string, themeName: DiffThemeName): string { + return `${language}:${themeName}`; +} + function estimateHighlightedSize(html: string, code: string): number { return Math.max(html.length * 2, code.length * 3); } -function getCachedOrCreateHighlight({ - cacheKey, - code, - language, - themeName, - useCache, -}: { - cacheKey: string; - code: string; - language: string; - themeName: DiffThemeName; - useCache: boolean; -}): Promise { - if (useCache) { - const cached = highlightedCodeCache.get(cacheKey); - if (cached) { - return Promise.resolve(cached); - } - } - - const existingInFlight = inFlightHighlights.get(cacheKey); - if (existingInFlight) { - if (useCache) { - existingInFlight.enableCache(); - } - return existingInFlight.promise; - } +function getHighlighterPromise( + language: string, + themeName: DiffThemeName, +): Promise { + const cacheKey = createHighlighterPromiseKey(language, themeName); + const cached = highlighterPromiseCache.get(cacheKey); + if (cached) return cached; - let shouldCache = useCache; - const requestPromise = highlightCodeBlock(code, language, themeName) - .then((html) => { - if (shouldCache) { - highlightedCodeCache.set(cacheKey, html, estimateHighlightedSize(html, code)); - } - return html; - }) - .finally(() => { - inFlightHighlights.delete(cacheKey); - }); - - inFlightHighlights.set(cacheKey, { - promise: requestPromise, - enableCache: () => { - shouldCache = true; - }, + const promise = getSharedHighlighter({ + themes: [themeName], + langs: [language as SupportedLanguages], + preferredHighlighter: "shiki-js", }); - return requestPromise; -} - -interface ShikiCodeBlockProps { - className: string | undefined; - code: string; - themeName: DiffThemeName; - isStreaming: boolean; - fallback: ReactNode; + highlighterPromiseCache.set(cacheKey, promise); + return promise; } function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNode }) { @@ -210,70 +160,47 @@ function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNo ); } -function ShikiCodeBlock({ className, code, themeName, isStreaming, fallback }: ShikiCodeBlockProps) { +interface SuspenseShikiCodeBlockProps { + className: string | undefined; + code: string; + themeName: DiffThemeName; + isStreaming: boolean; +} + +function SuspenseShikiCodeBlock({ + className, + code, + themeName, + isStreaming, +}: SuspenseShikiCodeBlockProps) { const language = extractFenceLanguage(className); const cacheKey = createHighlightCacheKey(code, language, themeName); - const [highlightState, setHighlightState] = useState<{ - cacheKey: string; - html: string | null; - }>(() => ({ - cacheKey, - html: isStreaming ? null : (highlightedCodeCache.get(cacheKey) ?? null), - })); - const cachedHighlightedHtml = isStreaming ? null : (highlightedCodeCache.get(cacheKey) ?? null); - const highlightedHtml = - cachedHighlightedHtml ?? (highlightState.cacheKey === cacheKey ? highlightState.html : null); - - useEffect(() => { - let cancelled = false; + const cachedHighlightedHtml = !isStreaming ? highlightedCodeCache.get(cacheKey) : null; - if (cachedHighlightedHtml) { - setHighlightState((current) => { - if (current.cacheKey === cacheKey && current.html === cachedHighlightedHtml) { - return current; - } - return { cacheKey, html: cachedHighlightedHtml }; - }); - return () => { - cancelled = true; - }; - } - - setHighlightState((current) => { - if (current.cacheKey === cacheKey) { - return current; - } - return { cacheKey, html: null }; - }); - - void getCachedOrCreateHighlight({ - cacheKey, - code, - language, - themeName, - useCache: !isStreaming, - }) - .then((html) => { - if (cancelled) { - return; - } - setHighlightState((current) => { - if (current.cacheKey === cacheKey && current.html === html) { - return current; - } - return { cacheKey, html }; - }); - }) - .catch(() => undefined); + if (cachedHighlightedHtml != null) { + return ( +
+ ); + } - return () => { - cancelled = true; - }; - }, [cacheKey, cachedHighlightedHtml, code, isStreaming, language, themeName]); + const highlighter = use(getHighlighterPromise(language, themeName)); + const highlightedHtml = useMemo( + () => highlighter.codeToHtml(code, { lang: language, theme: themeName }), + [code, highlighter, language, themeName], + ); - if (!highlightedHtml) { - return fallback; - } + useEffect(() => { + if (!isStreaming) { + highlightedCodeCache.set( + cacheKey, + highlightedHtml, + estimateHighlightedSize(highlightedHtml, code), + ); + } + }, [cacheKey, code, highlightedHtml, isStreaming]); return (
@@ -316,13 +243,14 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { return ( - {children}} - /> + {children}}> + + ); }, From c832b7c86051000db07f014e6d6ee65ce4f0ef35 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Feb 2026 15:12:54 -0800 Subject: [PATCH 7/9] simp --- apps/web/src/components/ChatMarkdown.tsx | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 9d39849802f3..dc51bef44263 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -87,28 +87,20 @@ function createHighlightCacheKey(code: string, language: string, themeName: Diff return `${fnv1a32(code).toString(36)}:${code.length}:${language}:${themeName}`; } -function createHighlighterPromiseKey(language: string, themeName: DiffThemeName): string { - return `${language}:${themeName}`; -} - function estimateHighlightedSize(html: string, code: string): number { return Math.max(html.length * 2, code.length * 3); } -function getHighlighterPromise( - language: string, - themeName: DiffThemeName, -): Promise { - const cacheKey = createHighlighterPromiseKey(language, themeName); - const cached = highlighterPromiseCache.get(cacheKey); +function getHighlighterPromise(language: string): Promise { + const cached = highlighterPromiseCache.get(language); if (cached) return cached; const promise = getSharedHighlighter({ - themes: [themeName], + themes: ["pierre-dark", "pierre-light"], langs: [language as SupportedLanguages], preferredHighlighter: "shiki-js", }); - highlighterPromiseCache.set(cacheKey, promise); + highlighterPromiseCache.set(language, promise); return promise; } @@ -186,7 +178,7 @@ function SuspenseShikiCodeBlock({ ); } - const highlighter = use(getHighlighterPromise(language, themeName)); + const highlighter = use(getHighlighterPromise(language)); const highlightedHtml = useMemo( () => highlighter.codeToHtml(code, { lang: language, theme: themeName }), [code, highlighter, language, themeName], From 9e994c1469a214fc6f747e641dcea6d2494f6142 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Feb 2026 15:14:22 -0800 Subject: [PATCH 8/9] kewl --- apps/web/src/components/ChatMarkdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index dc51bef44263..38fe42aac3da 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -96,7 +96,7 @@ function getHighlighterPromise(language: string): Promise { if (cached) return cached; const promise = getSharedHighlighter({ - themes: ["pierre-dark", "pierre-light"], + themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], langs: [language as SupportedLanguages], preferredHighlighter: "shiki-js", }); From c0f8c29b96c53fbc3a9c81005ef9957f4027985f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Feb 2026 15:15:20 -0800 Subject: [PATCH 9/9] even better --- apps/web/src/components/ChatMarkdown.tsx | 2 +- apps/web/src/components/DiffPanel.tsx | 2 +- apps/web/src/components/DiffWorkerPoolProvider.tsx | 2 +- apps/web/src/lib/diffRendering.ts | 11 +++++++++++ apps/web/src/lib/diffThemes.ts | 10 ---------- 5 files changed, 14 insertions(+), 13 deletions(-) delete mode 100644 apps/web/src/lib/diffThemes.ts diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 38fe42aac3da..2cc4c37b44a2 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -20,7 +20,7 @@ import { import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; -import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffThemes"; +import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 34f8bde99c19..92c2af1d0624 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -11,7 +11,7 @@ import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch" import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; import { buildPatchCacheKey } from "../lib/diffRendering"; -import { resolveDiffThemeName } from "../lib/diffThemes"; +import { resolveDiffThemeName } from "../lib/diffRendering"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useStore } from "../store"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; diff --git a/apps/web/src/components/DiffWorkerPoolProvider.tsx b/apps/web/src/components/DiffWorkerPoolProvider.tsx index 47f91efcb9e2..5babd4248ad6 100644 --- a/apps/web/src/components/DiffWorkerPoolProvider.tsx +++ b/apps/web/src/components/DiffWorkerPoolProvider.tsx @@ -2,7 +2,7 @@ import { WorkerPoolContextProvider, useWorkerPool } from "@pierre/diffs/react"; import DiffsWorker from "@pierre/diffs/worker/worker.js?worker"; import { useEffect, useMemo, type ReactNode } from "react"; import { useTheme } from "../hooks/useTheme"; -import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffThemes"; +import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { const workerPool = useWorkerPool(); diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index c61ad69f535b..7218f72978e0 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -1,3 +1,14 @@ +export const DIFF_THEME_NAMES = { + light: "pierre-light", + dark: "pierre-dark", +} as const; + +export type DiffThemeName = (typeof DIFF_THEME_NAMES)[keyof typeof DIFF_THEME_NAMES]; + +export function resolveDiffThemeName(theme: "light" | "dark"): DiffThemeName { + return theme === "dark" ? DIFF_THEME_NAMES.dark : DIFF_THEME_NAMES.light; +} + const FNV_OFFSET_BASIS_32 = 0x811c9dc5; const FNV_PRIME_32 = 0x01000193; const SECONDARY_HASH_SEED = 0x9e3779b9; diff --git a/apps/web/src/lib/diffThemes.ts b/apps/web/src/lib/diffThemes.ts deleted file mode 100644 index c38fa8d00059..000000000000 --- a/apps/web/src/lib/diffThemes.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const DIFF_THEME_NAMES = { - light: "pierre-light", - dark: "pierre-dark", -} as const; - -export type DiffThemeName = (typeof DIFF_THEME_NAMES)[keyof typeof DIFF_THEME_NAMES]; - -export function resolveDiffThemeName(theme: "light" | "dark"): DiffThemeName { - return theme === "dark" ? DIFF_THEME_NAMES.dark : DIFF_THEME_NAMES.light; -}