From 56e5a8f254e02e7484dc64d3ee4e3bd569e5f600 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 6 Dec 2025 14:48:50 -0600 Subject: [PATCH 01/14] perf: defer hunk highlighting offscreen --- docs/AGENTS.md | 2 + src/browser/components/AIView.tsx | 9 +- .../RightSidebar/CodeReview/HunkViewer.tsx | 5 +- .../RightSidebar/CodeReview/ReviewPanel.tsx | 61 +----- .../utils/highlighting/highlightDiffChunk.ts | 41 +--- .../highlighting/highlightWorkerClient.ts | 178 ++++++++++++++++++ src/browser/workers/highlightWorker.ts | 86 +++++++++ src/common/utils/git/diffParser.test.ts | 3 +- src/common/utils/git/diffParser.ts | 54 ++++++ 9 files changed, 343 insertions(+), 96 deletions(-) create mode 100644 src/browser/utils/highlighting/highlightWorkerClient.ts create mode 100644 src/browser/workers/highlightWorker.ts diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 9abdccd37..4d25fba4d 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -117,6 +117,8 @@ Avoid mock-heavy tests that verify implementation details rather than behavior. - Parent components own localStorage interactions; children announce intent only. - Use `usePersistedState`/`readPersistedState`/`updatePersistedState` helpers—never call `localStorage` directly. +- When a component needs to read persisted state it doesn't own (to avoid layout flash), use `readPersistedState` in `useState` initializer: `useState(() => readPersistedState(key, default))`. +- When multiple components need the same persisted value, use `usePersistedState` with identical keys and `{ listener: true }` for automatic cross-component sync. - Avoid destructuring props in function signatures; access via `props.field` to keep rename-friendly code. ## Module Imports diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 19369c654..dafe91e91 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -22,7 +22,7 @@ import { ProviderOptionsProvider } from "@/browser/contexts/ProviderOptionsConte import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { useAutoScroll } from "@/browser/hooks/useAutoScroll"; import { useOpenTerminal } from "@/browser/hooks/useOpenTerminal"; -import { usePersistedState } from "@/browser/hooks/usePersistedState"; +import { readPersistedState, usePersistedState } from "@/browser/hooks/usePersistedState"; import { useThinking } from "@/browser/contexts/ThinkingContext"; import { useWorkspaceState, @@ -75,8 +75,11 @@ const AIViewInner: React.FC = ({ const chatAreaRef = useRef(null); // Track active tab to conditionally enable resize functionality - // RightSidebar notifies us of tab changes via onTabChange callback - const [activeTab, setActiveTab] = useState("costs"); + // Initialize from persisted value to avoid layout flash; RightSidebar owns the state + // and notifies us of changes via onTabChange callback + const [activeTab, setActiveTab] = useState(() => + readPersistedState("right-sidebar-tab", "costs") + ); const isReviewTabActive = activeTab === "review"; diff --git a/src/browser/components/RightSidebar/CodeReview/HunkViewer.tsx b/src/browser/components/RightSidebar/CodeReview/HunkViewer.tsx index 29deaa163..38e496a94 100644 --- a/src/browser/components/RightSidebar/CodeReview/HunkViewer.tsx +++ b/src/browser/components/RightSidebar/CodeReview/HunkViewer.tsx @@ -46,8 +46,9 @@ export const HunkViewer = React.memo( // Track if hunk is visible in viewport for lazy syntax highlighting // Use ref for visibility to avoid re-renders when visibility changes - const isVisibleRef = React.useRef(true); // Start visible to avoid flash - const [isVisible, setIsVisible] = React.useState(true); + // Start as not visible to avoid eagerly highlighting off-screen hunks + const isVisibleRef = React.useRef(false); + const [isVisible, setIsVisible] = React.useState(false); // Use IntersectionObserver to track visibility React.useEffect(() => { diff --git a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx index c8a0bb2c3..8a4d648f3 100644 --- a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx @@ -28,7 +28,7 @@ import { ReviewControls } from "./ReviewControls"; import { FileTree } from "./FileTree"; import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { useReviewState } from "@/browser/hooks/useReviewState"; -import { parseDiff, extractAllHunks } from "@/common/utils/git/diffParser"; +import { parseDiff, extractAllHunks, buildGitDiffCommand } from "@/common/utils/git/diffParser"; import { getReviewSearchStateKey } from "@/common/constants/storage"; import { Tooltip, TooltipWrapper } from "@/browser/components/Tooltip"; import { parseNumstat, buildFileTree, extractNewPath } from "@/common/utils/git/numstatParser"; @@ -62,61 +62,6 @@ interface DiagnosticInfo { hunkCount: number; } -/** - * Build git diff command based on diffBase and includeUncommitted flag - * Shared logic between numstat (file tree) and diff (hunks) commands - * Exported for testing - * - * Git diff semantics: - * - `git diff A...HEAD` (three-dot): Shows commits on current branch since branching from A - * → Uses merge-base(A, HEAD) as comparison point, so changes to A after branching don't appear - * - `git diff $(git merge-base A HEAD)`: Shows all changes from branch point to working directory - * → Includes both committed changes on the branch AND uncommitted working directory changes - * → Single unified diff (no duplicate hunks from concatenation) - * - `git diff HEAD`: Shows only uncommitted changes (working directory vs HEAD) - * - `git diff --staged`: Shows only staged changes (index vs HEAD) - * - * The key insight: When includeUncommitted is true, we compare from the merge-base directly - * to the working directory. This gives a stable comparison point (doesn't change when base - * ref moves forward) while including both committed and uncommitted work in a single diff. - * - * @param diffBase - Base reference ("main", "HEAD", "--staged") - * @param includeUncommitted - Include uncommitted working directory changes - * @param pathFilter - Optional path filter (e.g., ' -- "src/foo.ts"') - * @param command - "diff" (unified) or "numstat" (file stats) - */ -export function buildGitDiffCommand( - diffBase: string, - includeUncommitted: boolean, - pathFilter: string, - command: "diff" | "numstat" -): string { - const flags = command === "numstat" ? " -M --numstat" : " -M"; - - if (diffBase === "--staged") { - // Staged changes, optionally with unstaged appended as separate diff - const base = `git diff --staged${flags}${pathFilter}`; - return includeUncommitted ? `${base} && git diff HEAD${flags}${pathFilter}` : base; - } - - if (diffBase === "HEAD") { - // Uncommitted changes only (working vs HEAD) - return `git diff HEAD${flags}${pathFilter}`; - } - - // Branch diff: use three-dot for committed only, or merge-base for committed+uncommitted - if (includeUncommitted) { - // Use merge-base to get a unified diff from branch point to working directory - // This includes both committed changes on the branch AND uncommitted working changes - // Single command avoids duplicate hunks from concatenation - // Stable comparison point: merge-base doesn't change when diffBase ref moves forward - return `git diff $(git merge-base ${diffBase} HEAD)${flags}${pathFilter}`; - } else { - // Three-dot: committed changes only (merge-base to HEAD) - return `git diff ${diffBase}...HEAD${flags}${pathFilter}`; - } -} - export const ReviewPanel: React.FC = ({ workspaceId, workspacePath, @@ -130,6 +75,7 @@ export const ReviewPanel: React.FC = ({ const [hunks, setHunks] = useState([]); const [selectedHunkId, setSelectedHunkId] = useState(null); const [isLoadingHunks, setIsLoadingHunks] = useState(true); + const [hasLoadedOnce, setHasLoadedOnce] = useState(false); const [isLoadingTree, setIsLoadingTree] = useState(true); const [error, setError] = useState(null); const [diagnosticInfo, setDiagnosticInfo] = useState(null); @@ -322,6 +268,7 @@ export const ReviewPanel: React.FC = ({ setError(errorMsg); } finally { setIsLoadingHunks(false); + setHasLoadedOnce(true); } }; @@ -660,7 +607,7 @@ export const ReviewPanel: React.FC = ({
{error}
- ) : isLoadingHunks && hunks.length === 0 && !fileTree ? ( + ) : !hasLoadedOnce ? (
Loading diff...
diff --git a/src/browser/utils/highlighting/highlightDiffChunk.ts b/src/browser/utils/highlighting/highlightDiffChunk.ts index 53a9af6cb..a3fc02e0d 100644 --- a/src/browser/utils/highlighting/highlightDiffChunk.ts +++ b/src/browser/utils/highlighting/highlightDiffChunk.ts @@ -1,18 +1,15 @@ -import { - getShikiHighlighter, - mapToShikiLang, - SHIKI_DARK_THEME, - SHIKI_LIGHT_THEME, - MAX_DIFF_SIZE_BYTES, -} from "./shikiHighlighter"; +import { MAX_DIFF_SIZE_BYTES } from "./shikiHighlighter"; +import { highlightCode } from "./highlightWorkerClient"; import type { DiffChunk } from "./diffChunking"; /** - * Chunk-based diff highlighting with Shiki + * Chunk-based diff highlighting with Shiki (via Web Worker) + * + * Highlighting runs off-main-thread to avoid blocking UI during large diffs. * * Current approach: Parse Shiki HTML to extract individual line HTMLs * - Groups consecutive lines by type (add/remove/context) - * - Highlights each chunk with Shiki + * - Highlights each chunk with Shiki in web worker * - Extracts per-line HTML for individual rendering * * Future optimization: Could render entire blocks and use CSS to style @@ -70,31 +67,11 @@ export async function highlightDiffChunk( } const code = chunk.lines.join("\n"); + const workerTheme = isLightTheme(themeMode) ? "light" : "dark"; try { - const highlighter = await getShikiHighlighter(); - const shikiLang = mapToShikiLang(language); - - // Load language on-demand if not already loaded - // This is race-safe: concurrent loads of the same language are idempotent - const loadedLangs = highlighter.getLoadedLanguages(); - if (!loadedLangs.includes(shikiLang)) { - try { - // TypeScript doesn't know shikiLang is valid, but we handle errors gracefully - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument - await highlighter.loadLanguage(shikiLang as any); - } catch { - // Language not available in Shiki bundle - fall back to plain text - console.warn(`Language '${shikiLang}' not available in Shiki, using plain text`); - return createFallbackChunk(chunk); - } - } - - const shikiTheme = isLightTheme(themeMode) ? SHIKI_LIGHT_THEME : SHIKI_DARK_THEME; - const html = highlighter.codeToHtml(code, { - lang: shikiLang, - theme: shikiTheme, - }); + // Highlight via worker (cached, off main thread) + const html = await highlightCode(code, language, workerTheme); // Parse HTML to extract line contents const lines = extractLinesFromHtml(html); diff --git a/src/browser/utils/highlighting/highlightWorkerClient.ts b/src/browser/utils/highlighting/highlightWorkerClient.ts new file mode 100644 index 000000000..0033b8e93 --- /dev/null +++ b/src/browser/utils/highlighting/highlightWorkerClient.ts @@ -0,0 +1,178 @@ +/** + * Syntax highlighting client with LRU caching + * + * Provides async API for off-main-thread syntax highlighting via Web Worker. + * Results are cached to avoid redundant highlighting of identical code. + * + * Falls back to main-thread highlighting in test environments where + * Web Workers aren't available. + */ + +import { LRUCache } from "lru-cache"; +import CRC32 from "crc-32"; +import type { HighlightRequest, HighlightResponse } from "@/browser/workers/highlightWorker"; +import { + getShikiHighlighter, + mapToShikiLang, + SHIKI_DARK_THEME, + SHIKI_LIGHT_THEME, +} from "./shikiHighlighter"; + +// ───────────────────────────────────────────────────────────────────────────── +// LRU Cache +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Cache for highlighted HTML results + * Key: CRC32 hash of (language:theme:code) + * Value: Shiki HTML output + */ +const highlightCache = new LRUCache({ + max: 10000, // High limit — rely on maxSize for eviction + maxSize: 8 * 1024 * 1024, // 8MB total + sizeCalculation: (html) => html.length * 2, // Rough bytes for JS strings +}); + +function getCacheKey(code: string, language: string, theme: string): number { + return CRC32.str(`${language}:${theme}:${code}`); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Worker Management +// ───────────────────────────────────────────────────────────────────────────── + +let worker: Worker | null = null; +let workerFailed = false; +let requestId = 0; +const pendingRequests = new Map< + number, + { + resolve: (html: string) => void; + reject: (error: Error) => void; + } +>(); + +function getWorker(): Worker | null { + if (workerFailed) return null; + if (worker) return worker; + + try { + // Use relative path - @/ alias doesn't work in worker context + worker = new Worker(new URL("../../workers/highlightWorker.ts", import.meta.url), { + type: "module", + }); + + worker.onmessage = (event: MessageEvent) => { + const { id, html, error } = event.data; + const pending = pendingRequests.get(id); + if (!pending) return; + + pendingRequests.delete(id); + if (error) { + pending.reject(new Error(error)); + } else if (html !== undefined) { + pending.resolve(html); + } else { + pending.reject(new Error("No HTML returned from worker")); + } + }; + + worker.onerror = (error) => { + console.error("Highlight worker error:", error); + // Mark worker as failed so subsequent calls use main-thread fallback + workerFailed = true; + worker = null; + // Reject all pending requests on worker error + for (const [id, pending] of pendingRequests) { + pending.reject(new Error("Worker error")); + pendingRequests.delete(id); + } + }; + + return worker; + } catch { + // Workers not available (e.g., test environment) + workerFailed = true; + return null; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Main-thread Fallback +// ───────────────────────────────────────────────────────────────────────────── + +let warnedMainThread = false; + +async function highlightMainThread( + code: string, + language: string, + theme: "dark" | "light" +): Promise { + if (!warnedMainThread) { + warnedMainThread = true; + console.warn( + "[highlightWorkerClient] Syntax highlighting running on main thread (worker unavailable)" + ); + } + + const highlighter = await getShikiHighlighter(); + const shikiLang = mapToShikiLang(language); + + // Load language on-demand + const loadedLangs = highlighter.getLoadedLanguages(); + if (!loadedLangs.includes(shikiLang)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + await highlighter.loadLanguage(shikiLang as any); + } + + const shikiTheme = theme === "light" ? SHIKI_LIGHT_THEME : SHIKI_DARK_THEME; + return highlighter.codeToHtml(code, { + lang: shikiLang, + theme: shikiTheme, + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Highlight code with syntax highlighting (cached, off-main-thread) + * + * Results are cached by (code, language, theme) to avoid redundant work. + * Highlighting runs in a Web Worker to avoid blocking the main thread. + * + * @param code - Source code to highlight + * @param language - Language identifier (e.g., "typescript", "python") + * @param theme - Theme variant ("dark" or "light") + * @returns Promise resolving to HTML string with syntax highlighting + * @throws Error if highlighting fails (caller should fallback to plain text) + */ +export async function highlightCode( + code: string, + language: string, + theme: "dark" | "light" +): Promise { + // Check cache first + const cacheKey = getCacheKey(code, language, theme); + const cached = highlightCache.get(cacheKey); + if (cached) return cached; + + // Dispatch to worker or main-thread fallback + const w = getWorker(); + let html: string; + + if (!w) { + html = await highlightMainThread(code, language, theme); + } else { + const id = requestId++; + html = await new Promise((resolve, reject) => { + pendingRequests.set(id, { resolve, reject }); + w.postMessage({ id, code, language, theme } satisfies HighlightRequest); + }); + } + + // Cache result + highlightCache.set(cacheKey, html); + return html; +} diff --git a/src/browser/workers/highlightWorker.ts b/src/browser/workers/highlightWorker.ts new file mode 100644 index 000000000..39f2be6ad --- /dev/null +++ b/src/browser/workers/highlightWorker.ts @@ -0,0 +1,86 @@ +/** + * Web Worker for syntax highlighting (Shiki) + * Moves expensive highlighting work off the main thread + */ + +import { createHighlighter, type Highlighter } from "shiki"; +import { SHIKI_DARK_THEME, SHIKI_LIGHT_THEME } from "../utils/highlighting/shiki-shared"; + +// Message types for worker communication +export interface HighlightRequest { + id: number; + code: string; + language: string; + theme: "dark" | "light"; +} + +export interface HighlightResponse { + id: number; + html?: string; + error?: string; +} + +// Singleton highlighter instance within worker +let highlighter: Highlighter | null = null; +let highlighterPromise: Promise | null = null; + +async function getHighlighter(): Promise { + if (highlighter) return highlighter; + // Must use if-check instead of ??= to prevent race condition + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (!highlighterPromise) { + highlighterPromise = createHighlighter({ + themes: [SHIKI_DARK_THEME, SHIKI_LIGHT_THEME], + langs: [], + }); + } + highlighter = await highlighterPromise; + return highlighter; +} + +// Map detected language to Shiki language ID +function mapToShikiLang(detectedLang: string): string { + const mapping: Record = { + text: "plaintext", + sh: "bash", + }; + return mapping[detectedLang] || detectedLang; +} + +self.onmessage = async (event: MessageEvent) => { + const { id, code, language, theme } = event.data; + + try { + const hl = await getHighlighter(); + const shikiLang = mapToShikiLang(language); + + // Load language on-demand + const loadedLangs = hl.getLoadedLanguages(); + if (!loadedLangs.includes(shikiLang)) { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + await hl.loadLanguage(shikiLang as any); + } catch { + // Language not available - signal error so caller can fallback + self.postMessage({ + id, + error: `Language '${shikiLang}' not available`, + } satisfies HighlightResponse); + return; + } + } + + const shikiTheme = theme === "light" ? SHIKI_LIGHT_THEME : SHIKI_DARK_THEME; + const html = hl.codeToHtml(code, { + lang: shikiLang, + theme: shikiTheme, + }); + + self.postMessage({ id, html } satisfies HighlightResponse); + } catch (err) { + self.postMessage({ + id, + error: err instanceof Error ? err.message : String(err), + } satisfies HighlightResponse); + } +}; diff --git a/src/common/utils/git/diffParser.test.ts b/src/common/utils/git/diffParser.test.ts index a16b0a617..bd6b25195 100644 --- a/src/common/utils/git/diffParser.test.ts +++ b/src/common/utils/git/diffParser.test.ts @@ -9,8 +9,7 @@ import { writeFileSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; import { execSync } from "child_process"; -import { parseDiff, extractAllHunks } from "./diffParser"; -import { buildGitDiffCommand } from "@/browser/components/RightSidebar/CodeReview/ReviewPanel"; +import { parseDiff, extractAllHunks, buildGitDiffCommand } from "./diffParser"; describe("git diff parser (real repository)", () => { let testRepoPath: string; diff --git a/src/common/utils/git/diffParser.ts b/src/common/utils/git/diffParser.ts index ee0defe6e..43a58ba3a 100644 --- a/src/common/utils/git/diffParser.ts +++ b/src/common/utils/git/diffParser.ts @@ -174,3 +174,57 @@ export function parseDiff(diffOutput: string): FileDiff[] { export function extractAllHunks(fileDiffs: FileDiff[]): DiffHunk[] { return fileDiffs.flatMap((file) => file.hunks); } + +/** + * Build git diff command based on diffBase and includeUncommitted flag + * Shared logic between numstat (file tree) and diff (hunks) commands + * + * Git diff semantics: + * - `git diff A...HEAD` (three-dot): Shows commits on current branch since branching from A + * → Uses merge-base(A, HEAD) as comparison point, so changes to A after branching don't appear + * - `git diff $(git merge-base A HEAD)`: Shows all changes from branch point to working directory + * → Includes both committed changes on the branch AND uncommitted working directory changes + * → Single unified diff (no duplicate hunks from concatenation) + * - `git diff HEAD`: Shows only uncommitted changes (working directory vs HEAD) + * - `git diff --staged`: Shows only staged changes (index vs HEAD) + * + * The key insight: When includeUncommitted is true, we compare from the merge-base directly + * to the working directory. This gives a stable comparison point (doesn't change when base + * ref moves forward) while including both committed and uncommitted work in a single diff. + * + * @param diffBase - Base reference ("main", "HEAD", "--staged") + * @param includeUncommitted - Include uncommitted working directory changes + * @param pathFilter - Optional path filter (e.g., ' -- "src/foo.ts"') + * @param command - "diff" (unified) or "numstat" (file stats) + */ +export function buildGitDiffCommand( + diffBase: string, + includeUncommitted: boolean, + pathFilter: string, + command: "diff" | "numstat" +): string { + const flags = command === "numstat" ? " -M --numstat" : " -M"; + + if (diffBase === "--staged") { + // Staged changes, optionally with unstaged appended as separate diff + const base = `git diff --staged${flags}${pathFilter}`; + return includeUncommitted ? `${base} && git diff HEAD${flags}${pathFilter}` : base; + } + + if (diffBase === "HEAD") { + // Uncommitted changes only (working vs HEAD) + return `git diff HEAD${flags}${pathFilter}`; + } + + // Branch diff: use three-dot for committed only, or merge-base for committed+uncommitted + if (includeUncommitted) { + // Use merge-base to get a unified diff from branch point to working directory + // This includes both committed changes on the branch AND uncommitted working changes + // Single command avoids duplicate hunks from concatenation + // Stable comparison point: merge-base doesn't change when diffBase ref moves forward + return `git diff $(git merge-base ${diffBase} HEAD)${flags}${pathFilter}`; + } else { + // Three-dot: committed changes only (merge-base to HEAD) + return `git diff ${diffBase}...HEAD${flags}${pathFilter}`; + } +} From 21b74091ece3b92acaa35279964b47d711eb8122 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 18:34:27 -0600 Subject: [PATCH 02/14] refactor: extract right-sidebar storage keys to constants --- src/browser/components/AIView.tsx | 4 ++-- src/browser/components/RightSidebar.tsx | 5 +++-- src/common/constants/storage.ts | 13 +++++++++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index dafe91e91..d30a89d47 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -5,7 +5,7 @@ import { InterruptedBarrier } from "./Messages/ChatBarrier/InterruptedBarrier"; import { StreamingBarrier } from "./Messages/ChatBarrier/StreamingBarrier"; import { RetryBarrier } from "./Messages/ChatBarrier/RetryBarrier"; import { PinnedTodoList } from "./PinnedTodoList"; -import { getAutoRetryKey, VIM_ENABLED_KEY } from "@/common/constants/storage"; +import { getAutoRetryKey, VIM_ENABLED_KEY, RIGHT_SIDEBAR_TAB_KEY } from "@/common/constants/storage"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; import { ChatInput, type ChatInputAPI } from "./ChatInput/index"; import { RightSidebar, type TabType } from "./RightSidebar"; @@ -78,7 +78,7 @@ const AIViewInner: React.FC = ({ // Initialize from persisted value to avoid layout flash; RightSidebar owns the state // and notifies us of changes via onTabChange callback const [activeTab, setActiveTab] = useState(() => - readPersistedState("right-sidebar-tab", "costs") + readPersistedState(RIGHT_SIDEBAR_TAB_KEY, "costs") ); const isReviewTabActive = activeTab === "review"; diff --git a/src/browser/components/RightSidebar.tsx b/src/browser/components/RightSidebar.tsx index e796e5b9c..f3d355c78 100644 --- a/src/browser/components/RightSidebar.tsx +++ b/src/browser/components/RightSidebar.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { RIGHT_SIDEBAR_TAB_KEY, RIGHT_SIDEBAR_COLLAPSED_KEY } from "@/common/constants/storage"; import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { useWorkspaceUsage } from "@/browser/stores/WorkspaceStore"; import { useProviderOptions } from "@/browser/hooks/useProviderOptions"; @@ -100,7 +101,7 @@ const RightSidebarComponent: React.FC = ({ isCreating = false, }) => { // Global tab preference (not per-workspace) - const [selectedTab, setSelectedTab] = usePersistedState("right-sidebar-tab", "costs"); + const [selectedTab, setSelectedTab] = usePersistedState(RIGHT_SIDEBAR_TAB_KEY, "costs"); // Trigger for focusing Review panel (preserves hunk selection) const [focusTrigger, setFocusTrigger] = React.useState(0); @@ -167,7 +168,7 @@ const RightSidebarComponent: React.FC = ({ // Persist collapsed state globally (not per-workspace) since chat area width is shared // This prevents animation flash when switching workspaces - sidebar maintains its state const [showCollapsed, setShowCollapsed] = usePersistedState( - "right-sidebar:collapsed", + RIGHT_SIDEBAR_COLLAPSED_KEY, false ); diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 5987a6be4..603be1856 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -193,6 +193,19 @@ export function getStatusUrlKey(workspaceId: string): string { return `statusUrl:${workspaceId}`; } + +/** + * Right sidebar tab selection (global) + * Format: "right-sidebar-tab" + */ +export const RIGHT_SIDEBAR_TAB_KEY = "right-sidebar-tab"; + +/** + * Right sidebar collapsed state (global) + * Format: "right-sidebar:collapsed" + */ +export const RIGHT_SIDEBAR_COLLAPSED_KEY = "right-sidebar:collapsed"; + /** * Get the localStorage key for unified Review search state per workspace * Stores: { input: string, useRegex: boolean, matchCase: boolean } From 54bb2788c23ec21c6c58baa92081e0cc8ed20b76 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 18:42:04 -0600 Subject: [PATCH 03/14] refactor: unify all syntax highlighting through worker client - Migrate MarkdownComponents.tsx to use highlightCode() from worker client - Delete shikiHighlighter.ts, inline fallback into highlightWorkerClient.ts - Move MAX_DIFF_SIZE_BYTES to highlightDiffChunk.ts (only consumer) - All highlighting now shares LRU cache and worker (or fallback) --- src/browser/components/AIView.tsx | 6 ++- .../Messages/MarkdownComponents.tsx | 37 ++-------------- .../utils/highlighting/highlightDiffChunk.ts | 5 ++- .../highlighting/highlightWorkerClient.ts | 30 ++++++++++--- .../utils/highlighting/shikiHighlighter.ts | 43 ------------------- src/common/constants/storage.ts | 1 - 6 files changed, 37 insertions(+), 85 deletions(-) delete mode 100644 src/browser/utils/highlighting/shikiHighlighter.ts diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index d30a89d47..1da709782 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -5,7 +5,11 @@ import { InterruptedBarrier } from "./Messages/ChatBarrier/InterruptedBarrier"; import { StreamingBarrier } from "./Messages/ChatBarrier/StreamingBarrier"; import { RetryBarrier } from "./Messages/ChatBarrier/RetryBarrier"; import { PinnedTodoList } from "./PinnedTodoList"; -import { getAutoRetryKey, VIM_ENABLED_KEY, RIGHT_SIDEBAR_TAB_KEY } from "@/common/constants/storage"; +import { + getAutoRetryKey, + VIM_ENABLED_KEY, + RIGHT_SIDEBAR_TAB_KEY, +} from "@/common/constants/storage"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; import { ChatInput, type ChatInputAPI } from "./ChatInput/index"; import { RightSidebar, type TabType } from "./RightSidebar"; diff --git a/src/browser/components/Messages/MarkdownComponents.tsx b/src/browser/components/Messages/MarkdownComponents.tsx index 9f1072940..f08aa812a 100644 --- a/src/browser/components/Messages/MarkdownComponents.tsx +++ b/src/browser/components/Messages/MarkdownComponents.tsx @@ -1,14 +1,9 @@ import type { ReactNode } from "react"; import React, { useState, useEffect } from "react"; import { Mermaid } from "./Mermaid"; -import { - getShikiHighlighter, - mapToShikiLang, - SHIKI_DARK_THEME, - SHIKI_LIGHT_THEME, -} from "@/browser/utils/highlighting/shikiHighlighter"; -import { useTheme } from "@/browser/contexts/ThemeContext"; +import { highlightCode } from "@/browser/utils/highlighting/highlightWorkerClient"; import { extractShikiLines } from "@/browser/utils/highlighting/shiki-shared"; +import { useTheme } from "@/browser/contexts/ThemeContext"; import { CopyButton } from "@/browser/components/ui/CopyButton"; interface CodeProps { @@ -57,37 +52,13 @@ const CodeBlock: React.FC = ({ code, language }) => { useEffect(() => { let cancelled = false; const isLight = themeMode === "light" || themeMode === "solarized-light"; - const shikiTheme = isLight ? SHIKI_LIGHT_THEME : SHIKI_DARK_THEME; + const theme = isLight ? "light" : "dark"; setHighlightedLines(null); async function highlight() { try { - const highlighter = await getShikiHighlighter(); - const shikiLang = mapToShikiLang(language); - - // Load language on-demand if not already loaded - // This is race-safe: concurrent loads of the same language are idempotent - const loadedLangs = highlighter.getLoadedLanguages(); - if (!loadedLangs.includes(shikiLang)) { - try { - // TypeScript doesn't know shikiLang is valid, but we handle errors gracefully - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument - await highlighter.loadLanguage(shikiLang as any); - } catch { - // Language not available in Shiki bundle - fall back to plain text - console.warn(`Language '${shikiLang}' not available in Shiki, using plain text`); - if (!cancelled) { - setHighlightedLines(null); - } - return; - } - } - - const html = highlighter.codeToHtml(code, { - lang: shikiLang, - theme: shikiTheme, - }); + const html = await highlightCode(code, language, theme); if (!cancelled) { const lines = extractShikiLines(html); diff --git a/src/browser/utils/highlighting/highlightDiffChunk.ts b/src/browser/utils/highlighting/highlightDiffChunk.ts index a3fc02e0d..231f78d69 100644 --- a/src/browser/utils/highlighting/highlightDiffChunk.ts +++ b/src/browser/utils/highlighting/highlightDiffChunk.ts @@ -1,4 +1,3 @@ -import { MAX_DIFF_SIZE_BYTES } from "./shikiHighlighter"; import { highlightCode } from "./highlightWorkerClient"; import type { DiffChunk } from "./diffChunking"; @@ -17,6 +16,10 @@ import type { DiffChunk } from "./diffChunking"; * and reduce dangerouslySetInnerHTML usage. */ +// Maximum diff size to highlight (in bytes) +// Diffs larger than this fall back to plain text for performance +const MAX_DIFF_SIZE_BYTES = 32768; // 32kb + export interface HighlightedLine { html: string; // HTML content (already escaped and tokenized) lineNumber: number; diff --git a/src/browser/utils/highlighting/highlightWorkerClient.ts b/src/browser/utils/highlighting/highlightWorkerClient.ts index 0033b8e93..e81bbd550 100644 --- a/src/browser/utils/highlighting/highlightWorkerClient.ts +++ b/src/browser/utils/highlighting/highlightWorkerClient.ts @@ -10,13 +10,9 @@ import { LRUCache } from "lru-cache"; import CRC32 from "crc-32"; +import { createHighlighter, type Highlighter } from "shiki"; import type { HighlightRequest, HighlightResponse } from "@/browser/workers/highlightWorker"; -import { - getShikiHighlighter, - mapToShikiLang, - SHIKI_DARK_THEME, - SHIKI_LIGHT_THEME, -} from "./shikiHighlighter"; +import { mapToShikiLang, SHIKI_DARK_THEME, SHIKI_LIGHT_THEME } from "./shiki-shared"; // ───────────────────────────────────────────────────────────────────────────── // LRU Cache @@ -37,6 +33,27 @@ function getCacheKey(code: string, language: string, theme: string): number { return CRC32.str(`${language}:${theme}:${code}`); } +// ───────────────────────────────────────────────────────────────────────────── +// Main-thread Shiki (fallback only) +// ───────────────────────────────────────────────────────────────────────────── + +let highlighterPromise: Promise | null = null; + +/** + * Get or create main-thread Shiki highlighter (for fallback when worker unavailable) + */ +function getShikiHighlighter(): Promise { + // Must use if-check instead of ??= to prevent race condition + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (!highlighterPromise) { + highlighterPromise = createHighlighter({ + themes: [SHIKI_DARK_THEME, SHIKI_LIGHT_THEME], + langs: [], + }); + } + return highlighterPromise; +} + // ───────────────────────────────────────────────────────────────────────────── // Worker Management // ───────────────────────────────────────────────────────────────────────────── @@ -44,6 +61,7 @@ function getCacheKey(code: string, language: string, theme: string): number { let worker: Worker | null = null; let workerFailed = false; let requestId = 0; + const pendingRequests = new Map< number, { diff --git a/src/browser/utils/highlighting/shikiHighlighter.ts b/src/browser/utils/highlighting/shikiHighlighter.ts deleted file mode 100644 index 7d725dd20..000000000 --- a/src/browser/utils/highlighting/shikiHighlighter.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { createHighlighter, type Highlighter } from "shiki"; -import { SHIKI_DARK_THEME, SHIKI_LIGHT_THEME } from "./shiki-shared"; - -export { SHIKI_DARK_THEME, SHIKI_LIGHT_THEME } from "./shiki-shared"; - -// Maximum diff size to highlight (in bytes) -// Diffs larger than this will fall back to plain text for performance -export const MAX_DIFF_SIZE_BYTES = 32768; // 32kb - -// Singleton promise (cached to prevent race conditions) -// Multiple concurrent calls will await the same Promise -let highlighterPromise: Promise | null = null; - -/** - * Get or create Shiki highlighter instance - * Lazy-loads WASM and themes on first call - * Thread-safe: concurrent calls share the same initialization Promise - */ -export async function getShikiHighlighter(): Promise { - // Must use if-check instead of ??= to prevent race condition - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (!highlighterPromise) { - highlighterPromise = createHighlighter({ - themes: [SHIKI_DARK_THEME, SHIKI_LIGHT_THEME], - langs: [], // Load languages on-demand via highlightDiffChunk - }); - } - return highlighterPromise; -} - -/** - * Map file extensions/languages to Shiki language IDs - * Reuses existing getLanguageFromPath logic - */ -export function mapToShikiLang(detectedLang: string): string { - // Most languages match 1:1, but handle special cases - const mapping: Record = { - text: "plaintext", - sh: "bash", - // Add more mappings if needed - }; - return mapping[detectedLang] || detectedLang; -} diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 603be1856..d1ba0f8ca 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -193,7 +193,6 @@ export function getStatusUrlKey(workspaceId: string): string { return `statusUrl:${workspaceId}`; } - /** * Right sidebar tab selection (global) * Format: "right-sidebar-tab" From 37801253a88fbbde49466ee2d8032f01e9e234b8 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 18:45:11 -0600 Subject: [PATCH 04/14] fix: use 64-bit hash for highlight cache keys Combine two CRC32 hashes with different seeds to create a 64-bit key, reducing collision probability for the LRU cache. --- bun.lock | 1 - .../utils/highlighting/highlightWorkerClient.ts | 16 +++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/bun.lock b/bun.lock index 753085edc..d60ccf5fc 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "mux", diff --git a/src/browser/utils/highlighting/highlightWorkerClient.ts b/src/browser/utils/highlighting/highlightWorkerClient.ts index e81bbd550..89c1125d7 100644 --- a/src/browser/utils/highlighting/highlightWorkerClient.ts +++ b/src/browser/utils/highlighting/highlightWorkerClient.ts @@ -15,22 +15,28 @@ import type { HighlightRequest, HighlightResponse } from "@/browser/workers/high import { mapToShikiLang, SHIKI_DARK_THEME, SHIKI_LIGHT_THEME } from "./shiki-shared"; // ───────────────────────────────────────────────────────────────────────────── -// LRU Cache +// LRU Cache with 64-bit hashing // ───────────────────────────────────────────────────────────────────────────── /** * Cache for highlighted HTML results - * Key: CRC32 hash of (language:theme:code) + * Key: 64-bit hash of (language:theme:code) as bigint * Value: Shiki HTML output + * + * Uses two CRC32 hashes (with different seeds) combined into a 64-bit key + * to reduce collision probability vs a single 32-bit hash. */ -const highlightCache = new LRUCache({ +const highlightCache = new LRUCache({ max: 10000, // High limit — rely on maxSize for eviction maxSize: 8 * 1024 * 1024, // 8MB total sizeCalculation: (html) => html.length * 2, // Rough bytes for JS strings }); -function getCacheKey(code: string, language: string, theme: string): number { - return CRC32.str(`${language}:${theme}:${code}`); +function getCacheKey(code: string, language: string, theme: string): bigint { + const input = `${language}:${theme}:${code}`; + const lo = CRC32.str(input) >>> 0; // unsigned 32-bit + const hi = CRC32.str(input, 0x9e3779b9) >>> 0; // different seed + return (BigInt(hi) << 32n) | BigInt(lo); } // ───────────────────────────────────────────────────────────────────────────── From 3e8ea03f35a002eee9ecc02a23393eed2c98699f Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 18:46:54 -0600 Subject: [PATCH 05/14] fix: use SHA-256 (first 64 bits) for highlight cache keys Web Crypto API is built-in and hardware-accelerated, no external deps needed. --- .../highlighting/highlightWorkerClient.ts | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/browser/utils/highlighting/highlightWorkerClient.ts b/src/browser/utils/highlighting/highlightWorkerClient.ts index 89c1125d7..ed0d4bf28 100644 --- a/src/browser/utils/highlighting/highlightWorkerClient.ts +++ b/src/browser/utils/highlighting/highlightWorkerClient.ts @@ -9,34 +9,32 @@ */ import { LRUCache } from "lru-cache"; -import CRC32 from "crc-32"; import { createHighlighter, type Highlighter } from "shiki"; import type { HighlightRequest, HighlightResponse } from "@/browser/workers/highlightWorker"; import { mapToShikiLang, SHIKI_DARK_THEME, SHIKI_LIGHT_THEME } from "./shiki-shared"; // ───────────────────────────────────────────────────────────────────────────── -// LRU Cache with 64-bit hashing +// LRU Cache with SHA-256 hashing // ───────────────────────────────────────────────────────────────────────────── /** * Cache for highlighted HTML results - * Key: 64-bit hash of (language:theme:code) as bigint + * Key: First 64 bits of SHA-256 hash (hex string) * Value: Shiki HTML output - * - * Uses two CRC32 hashes (with different seeds) combined into a 64-bit key - * to reduce collision probability vs a single 32-bit hash. */ -const highlightCache = new LRUCache({ +const highlightCache = new LRUCache({ max: 10000, // High limit — rely on maxSize for eviction maxSize: 8 * 1024 * 1024, // 8MB total sizeCalculation: (html) => html.length * 2, // Rough bytes for JS strings }); -function getCacheKey(code: string, language: string, theme: string): bigint { - const input = `${language}:${theme}:${code}`; - const lo = CRC32.str(input) >>> 0; // unsigned 32-bit - const hi = CRC32.str(input, 0x9e3779b9) >>> 0; // different seed - return (BigInt(hi) << 32n) | BigInt(lo); +async function getCacheKey(code: string, language: string, theme: string): Promise { + const data = new TextEncoder().encode(`${language}:${theme}:${code}`); + const hash = await crypto.subtle.digest("SHA-256", data); + // Take first 8 bytes (64 bits) as hex + return Array.from(new Uint8Array(hash).slice(0, 8)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); } // ───────────────────────────────────────────────────────────────────────────── @@ -178,7 +176,7 @@ export async function highlightCode( theme: "dark" | "light" ): Promise { // Check cache first - const cacheKey = getCacheKey(code, language, theme); + const cacheKey = await getCacheKey(code, language, theme); const cached = highlightCache.get(cacheKey); if (cached) return cached; From 66f7fa8c99440ce12eddfa5d9b3579bffcb66537 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 18:51:07 -0600 Subject: [PATCH 06/14] refactor: use Comlink to simplify worker communication Replaces manual message ID tracking, pending request maps, and postMessage/onmessage boilerplate with Comlink's proxy-based RPC. - Worker exposes API via Comlink.expose() - Client wraps worker via Comlink.wrap() - Exceptions propagate naturally - ~30 lines removed --- bun.lock | 3 + package.json | 1 + .../highlighting/highlightWorkerClient.ts | 61 +++++-------------- src/browser/workers/highlightWorker.ts | 49 ++++----------- 4 files changed, 29 insertions(+), 85 deletions(-) diff --git a/bun.lock b/bun.lock index d60ccf5fc..c1f630d13 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,7 @@ "ai": "^5.0.101", "ai-tokenizer": "^1.0.4", "chalk": "^5.6.2", + "comlink": "^4.4.2", "commander": "^14.0.2", "cors": "^2.8.5", "crc-32": "^1.2.2", @@ -1736,6 +1737,8 @@ "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "comlink": ["comlink@4.4.2", "", {}, "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g=="], + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], "commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], diff --git a/package.json b/package.json index 441506da3..0ed6aa134 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "ai": "^5.0.101", "ai-tokenizer": "^1.0.4", "chalk": "^5.6.2", + "comlink": "^4.4.2", "commander": "^14.0.2", "cors": "^2.8.5", "crc-32": "^1.2.2", diff --git a/src/browser/utils/highlighting/highlightWorkerClient.ts b/src/browser/utils/highlighting/highlightWorkerClient.ts index ed0d4bf28..4a2ced66f 100644 --- a/src/browser/utils/highlighting/highlightWorkerClient.ts +++ b/src/browser/utils/highlighting/highlightWorkerClient.ts @@ -9,8 +9,9 @@ */ import { LRUCache } from "lru-cache"; +import * as Comlink from "comlink"; import { createHighlighter, type Highlighter } from "shiki"; -import type { HighlightRequest, HighlightResponse } from "@/browser/workers/highlightWorker"; +import type { HighlightWorkerAPI } from "@/browser/workers/highlightWorker"; import { mapToShikiLang, SHIKI_DARK_THEME, SHIKI_LIGHT_THEME } from "./shiki-shared"; // ───────────────────────────────────────────────────────────────────────────── @@ -59,59 +60,29 @@ function getShikiHighlighter(): Promise { } // ───────────────────────────────────────────────────────────────────────────── -// Worker Management +// Worker Management (via Comlink) // ───────────────────────────────────────────────────────────────────────────── -let worker: Worker | null = null; +let workerAPI: Comlink.Remote | null = null; let workerFailed = false; -let requestId = 0; -const pendingRequests = new Map< - number, - { - resolve: (html: string) => void; - reject: (error: Error) => void; - } ->(); - -function getWorker(): Worker | null { +function getWorkerAPI(): Comlink.Remote | null { if (workerFailed) return null; - if (worker) return worker; + if (workerAPI) return workerAPI; try { // Use relative path - @/ alias doesn't work in worker context - worker = new Worker(new URL("../../workers/highlightWorker.ts", import.meta.url), { + const worker = new Worker(new URL("../../workers/highlightWorker.ts", import.meta.url), { type: "module", }); - worker.onmessage = (event: MessageEvent) => { - const { id, html, error } = event.data; - const pending = pendingRequests.get(id); - if (!pending) return; - - pendingRequests.delete(id); - if (error) { - pending.reject(new Error(error)); - } else if (html !== undefined) { - pending.resolve(html); - } else { - pending.reject(new Error("No HTML returned from worker")); - } - }; - - worker.onerror = (error) => { - console.error("Highlight worker error:", error); - // Mark worker as failed so subsequent calls use main-thread fallback + worker.onerror = () => { workerFailed = true; - worker = null; - // Reject all pending requests on worker error - for (const [id, pending] of pendingRequests) { - pending.reject(new Error("Worker error")); - pendingRequests.delete(id); - } + workerAPI = null; }; - return worker; + workerAPI = Comlink.wrap(worker); + return workerAPI; } catch { // Workers not available (e.g., test environment) workerFailed = true; @@ -181,17 +152,13 @@ export async function highlightCode( if (cached) return cached; // Dispatch to worker or main-thread fallback - const w = getWorker(); + const api = getWorkerAPI(); let html: string; - if (!w) { + if (!api) { html = await highlightMainThread(code, language, theme); } else { - const id = requestId++; - html = await new Promise((resolve, reject) => { - pendingRequests.set(id, { resolve, reject }); - w.postMessage({ id, code, language, theme } satisfies HighlightRequest); - }); + html = await api.highlight(code, language, theme); } // Cache result diff --git a/src/browser/workers/highlightWorker.ts b/src/browser/workers/highlightWorker.ts index 39f2be6ad..a8f68c154 100644 --- a/src/browser/workers/highlightWorker.ts +++ b/src/browser/workers/highlightWorker.ts @@ -3,23 +3,10 @@ * Moves expensive highlighting work off the main thread */ +import * as Comlink from "comlink"; import { createHighlighter, type Highlighter } from "shiki"; import { SHIKI_DARK_THEME, SHIKI_LIGHT_THEME } from "../utils/highlighting/shiki-shared"; -// Message types for worker communication -export interface HighlightRequest { - id: number; - code: string; - language: string; - theme: "dark" | "light"; -} - -export interface HighlightResponse { - id: number; - html?: string; - error?: string; -} - // Singleton highlighter instance within worker let highlighter: Highlighter | null = null; let highlighterPromise: Promise | null = null; @@ -47,40 +34,26 @@ function mapToShikiLang(detectedLang: string): string { return mapping[detectedLang] || detectedLang; } -self.onmessage = async (event: MessageEvent) => { - const { id, code, language, theme } = event.data; - - try { +const api = { + async highlight(code: string, language: string, theme: "dark" | "light"): Promise { const hl = await getHighlighter(); const shikiLang = mapToShikiLang(language); // Load language on-demand const loadedLangs = hl.getLoadedLanguages(); if (!loadedLangs.includes(shikiLang)) { - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument - await hl.loadLanguage(shikiLang as any); - } catch { - // Language not available - signal error so caller can fallback - self.postMessage({ - id, - error: `Language '${shikiLang}' not available`, - } satisfies HighlightResponse); - return; - } + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + await hl.loadLanguage(shikiLang as any); } const shikiTheme = theme === "light" ? SHIKI_LIGHT_THEME : SHIKI_DARK_THEME; - const html = hl.codeToHtml(code, { + return hl.codeToHtml(code, { lang: shikiLang, theme: shikiTheme, }); - - self.postMessage({ id, html } satisfies HighlightResponse); - } catch (err) { - self.postMessage({ - id, - error: err instanceof Error ? err.message : String(err), - } satisfies HighlightResponse); - } + }, }; + +export type HighlightWorkerAPI = typeof api; + +Comlink.expose(api); From ff65709d6242ab08bed6197d44aacdaf698d6015 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 18:53:59 -0600 Subject: [PATCH 07/14] fix: reset review state when switching workspaces Reset hasLoadedOnce and hunks when workspace changes to prevent showing stale 'No changes found' message from previous workspace. --- .../components/RightSidebar/CodeReview/ReviewPanel.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx index 8a4d648f3..c61f4991b 100644 --- a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx @@ -198,6 +198,10 @@ export const ReviewPanel: React.FC = ({ if (!api || isCreating) return; let cancelled = false; + // Reset state when workspace changes to prevent showing stale data + setHasLoadedOnce(false); + setHunks([]); + const loadDiff = async () => { setIsLoadingHunks(true); setError(null); From af3ade4c14c294c1aaecf27d57f43b5fcda4c516 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 18:57:06 -0600 Subject: [PATCH 08/14] refactor: use discriminated union for diff loading state Replace independent state variables (isLoadingHunks, hasLoadedOnce, error, hunks, truncationWarning) with a single DiffState union: type DiffState = | { status: 'loading' } | { status: 'loaded'; hunks: DiffHunk[]; truncationWarning: string | null } | { status: 'error'; message: string } This makes invalid states unrepresentable - it's now impossible to show 'No changes found' while loading because the type system enforces that hunks only exist in the 'loaded' state. --- .../RightSidebar/CodeReview/ReviewPanel.tsx | 64 ++++++++++--------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx index c61f4991b..a93c13942 100644 --- a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx @@ -62,6 +62,15 @@ interface DiagnosticInfo { hunkCount: number; } +/** + * Discriminated union for diff loading state. + * Makes it impossible to show "No changes" while loading. + */ +type DiffState = + | { status: "loading" } + | { status: "loaded"; hunks: DiffHunk[]; truncationWarning: string | null } + | { status: "error"; message: string }; + export const ReviewPanel: React.FC = ({ workspaceId, workspacePath, @@ -72,14 +81,13 @@ export const ReviewPanel: React.FC = ({ const { api } = useAPI(); const panelRef = useRef(null); const searchInputRef = useRef(null); - const [hunks, setHunks] = useState([]); + + // Unified diff state - discriminated union makes invalid states unrepresentable + const [diffState, setDiffState] = useState({ status: "loading" }); + const [selectedHunkId, setSelectedHunkId] = useState(null); - const [isLoadingHunks, setIsLoadingHunks] = useState(true); - const [hasLoadedOnce, setHasLoadedOnce] = useState(false); const [isLoadingTree, setIsLoadingTree] = useState(true); - const [error, setError] = useState(null); const [diagnosticInfo, setDiagnosticInfo] = useState(null); - const [truncationWarning, setTruncationWarning] = useState(null); const [isPanelFocused, setIsPanelFocused] = useState(false); const [refreshTrigger, setRefreshTrigger] = useState(0); const [fileTree, setFileTree] = useState(null); @@ -117,6 +125,9 @@ export const ReviewPanel: React.FC = ({ // Initialize review state hook const { isRead, toggleRead, markAsRead, markAsUnread } = useReviewState(workspaceId); + // Derive hunks from diffState for use in filters and rendering + const hunks = diffState.status === "loaded" ? diffState.hunks : []; + const [filters, setFilters] = useState({ showReadHunks: showReadHunks, diffBase: diffBase, @@ -198,14 +209,10 @@ export const ReviewPanel: React.FC = ({ if (!api || isCreating) return; let cancelled = false; - // Reset state when workspace changes to prevent showing stale data - setHasLoadedOnce(false); - setHunks([]); + // Immediately transition to loading state - atomic, no flash possible + setDiffState({ status: "loading" }); const loadDiff = async () => { - setIsLoadingHunks(true); - setError(null); - setTruncationWarning(null); try { // Git-level filters (affect what data is fetched): // - diffBase: what to diff against @@ -233,8 +240,7 @@ export const ReviewPanel: React.FC = ({ if (!diffResult.success) { // Real error (not truncation-related) console.error("Git diff failed:", diffResult.error); - setError(diffResult.error); - setHunks([]); + setDiffState({ status: "error", message: diffResult.error ?? "Unknown error" }); setDiagnosticInfo(null); return; } @@ -253,26 +259,24 @@ export const ReviewPanel: React.FC = ({ hunkCount: allHunks.length, }); - // Set truncation warning only when not filtering by path - if (truncationInfo && !selectedFilePath) { - setTruncationWarning( - `Diff truncated (${truncationInfo.reason}). Filter by file to see more.` - ); - } + // Build truncation warning (only when not filtering by path) + const truncationWarning = + truncationInfo && !selectedFilePath + ? `Diff truncated (${truncationInfo.reason}). Filter by file to see more.` + : null; - setHunks(allHunks); + // Single atomic state update with all data + setDiffState({ status: "loaded", hunks: allHunks, truncationWarning }); // Auto-select first hunk if none selected if (allHunks.length > 0 && !selectedHunkId) { setSelectedHunkId(allHunks[0].id); } } catch (err) { + if (cancelled) return; const errorMsg = `Failed to load diff: ${err instanceof Error ? err.message : String(err)}`; console.error(errorMsg); - setError(errorMsg); - } finally { - setIsLoadingHunks(false); - setHasLoadedOnce(true); + setDiffState({ status: "error", message: errorMsg }); } }; @@ -601,25 +605,25 @@ export const ReviewPanel: React.FC = ({ stats={stats} onFiltersChange={setFilters} onRefresh={() => setRefreshTrigger((prev) => prev + 1)} - isLoading={isLoadingHunks || isLoadingTree} + isLoading={diffState.status === "loading" || isLoadingTree} workspaceId={workspaceId} workspacePath={workspacePath} refreshTrigger={refreshTrigger} /> - {error ? ( + {diffState.status === "error" ? (
- {error} + {diffState.message}
- ) : !hasLoadedOnce ? ( + ) : diffState.status === "loading" ? (
Loading diff...
) : (
- {truncationWarning && ( + {diffState.truncationWarning && (
- {truncationWarning} + {diffState.truncationWarning}
)} From 4c82c7d40c1b6dbb7161af0982fc63b95535a8af Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 18:59:49 -0600 Subject: [PATCH 09/14] fix: preserve diff data during refresh, reset on workspace switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add workspaceId to DiffState to distinguish between: - Refreshing same workspace: show stale data while loading (no flash) - Switching workspaces: show loading indicator (no stale data) The state machine now correctly handles: 1. Initial load → 'loading' → 'loaded' 2. Refresh (same workspace) → 'refreshing' (keeps hunks visible) → 'loaded' 3. Workspace switch → 'loading' (clears stale hunks) → 'loaded' --- .../RightSidebar/CodeReview/ReviewPanel.tsx | 40 +++++++++++++++---- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx index a93c13942..092a758d9 100644 --- a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx @@ -65,10 +65,14 @@ interface DiagnosticInfo { /** * Discriminated union for diff loading state. * Makes it impossible to show "No changes" while loading. + * + * The workspaceId in loaded/refreshing states prevents showing stale data + * from a different workspace during transitions. */ type DiffState = | { status: "loading" } - | { status: "loaded"; hunks: DiffHunk[]; truncationWarning: string | null } + | { status: "refreshing"; workspaceId: string; hunks: DiffHunk[]; truncationWarning: string | null } + | { status: "loaded"; workspaceId: string; hunks: DiffHunk[]; truncationWarning: string | null } | { status: "error"; message: string }; export const ReviewPanel: React.FC = ({ @@ -126,7 +130,12 @@ export const ReviewPanel: React.FC = ({ const { isRead, toggleRead, markAsRead, markAsUnread } = useReviewState(workspaceId); // Derive hunks from diffState for use in filters and rendering - const hunks = diffState.status === "loaded" ? diffState.hunks : []; + // Only show hunks if they belong to the current workspace + const hunks = + (diffState.status === "loaded" || diffState.status === "refreshing") && + diffState.workspaceId === workspaceId + ? diffState.hunks + : []; const [filters, setFilters] = useState({ showReadHunks: showReadHunks, @@ -209,8 +218,23 @@ export const ReviewPanel: React.FC = ({ if (!api || isCreating) return; let cancelled = false; - // Immediately transition to loading state - atomic, no flash possible - setDiffState({ status: "loading" }); + // Transition to appropriate loading state: + // - "refreshing" if we have data for THIS workspace (keeps UI stable) + // - "loading" if no data or different workspace (shows loading indicator) + setDiffState((prev) => { + const hasDataForWorkspace = + (prev.status === "loaded" || prev.status === "refreshing") && + prev.workspaceId === workspaceId; + if (hasDataForWorkspace) { + return { + status: "refreshing", + workspaceId, + hunks: prev.hunks, + truncationWarning: prev.truncationWarning, + }; + } + return { status: "loading" }; + }); const loadDiff = async () => { try { @@ -266,7 +290,7 @@ export const ReviewPanel: React.FC = ({ : null; // Single atomic state update with all data - setDiffState({ status: "loaded", hunks: allHunks, truncationWarning }); + setDiffState({ status: "loaded", workspaceId, hunks: allHunks, truncationWarning }); // Auto-select first hunk if none selected if (allHunks.length > 0 && !selectedHunkId) { @@ -605,7 +629,7 @@ export const ReviewPanel: React.FC = ({ stats={stats} onFiltersChange={setFilters} onRefresh={() => setRefreshTrigger((prev) => prev + 1)} - isLoading={diffState.status === "loading" || isLoadingTree} + isLoading={diffState.status === "loading" || diffState.status === "refreshing" || isLoadingTree} workspaceId={workspaceId} workspacePath={workspacePath} refreshTrigger={refreshTrigger} @@ -615,7 +639,9 @@ export const ReviewPanel: React.FC = ({
{diffState.message}
- ) : diffState.status === "loading" ? ( + ) : diffState.status === "loading" || + ((diffState.status === "loaded" || diffState.status === "refreshing") && + diffState.workspaceId !== workspaceId) ? (
Loading diff...
From a4e35abdfee57445d782387db5d1575ae760ab2c Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 19:03:03 -0600 Subject: [PATCH 10/14] fix: wrap derived hunks in useMemo to fix lint warnings --- .../RightSidebar/CodeReview/ReviewPanel.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx index 092a758d9..aff0248d4 100644 --- a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx @@ -131,11 +131,14 @@ export const ReviewPanel: React.FC = ({ // Derive hunks from diffState for use in filters and rendering // Only show hunks if they belong to the current workspace - const hunks = - (diffState.status === "loaded" || diffState.status === "refreshing") && - diffState.workspaceId === workspaceId - ? diffState.hunks - : []; + const hunks = useMemo( + () => + (diffState.status === "loaded" || diffState.status === "refreshing") && + diffState.workspaceId === workspaceId + ? diffState.hunks + : [], + [diffState, workspaceId] + ); const [filters, setFilters] = useState({ showReadHunks: showReadHunks, From 5d0b88f7b40b5c6d9c0ae722ce609b8f8b6e1e63 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 19:08:35 -0600 Subject: [PATCH 11/14] fix: use key={workspaceId} to reset ReviewPanel state on workspace switch Instead of tracking workspaceId in state and checking it everywhere, use React's key prop to force remount when workspace changes. This is simpler and more reliable: - Component unmounts, clearing all state - New instance mounts with fresh { status: 'loading' } - No stale data from previous workspace can leak through Simplifies DiffState by removing workspaceId from the union. --- src/browser/components/RightSidebar.tsx | 1 + .../RightSidebar/CodeReview/ReviewPanel.tsx | 31 +++++++------------ 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/src/browser/components/RightSidebar.tsx b/src/browser/components/RightSidebar.tsx index f3d355c78..0b6722fc8 100644 --- a/src/browser/components/RightSidebar.tsx +++ b/src/browser/components/RightSidebar.tsx @@ -298,6 +298,7 @@ const RightSidebarComponent: React.FC = ({ className="h-full" > = ({ @@ -87,6 +87,7 @@ export const ReviewPanel: React.FC = ({ const searchInputRef = useRef(null); // Unified diff state - discriminated union makes invalid states unrepresentable + // Note: Parent renders with key={workspaceId}, so component remounts on workspace change const [diffState, setDiffState] = useState({ status: "loading" }); const [selectedHunkId, setSelectedHunkId] = useState(null); @@ -130,14 +131,12 @@ export const ReviewPanel: React.FC = ({ const { isRead, toggleRead, markAsRead, markAsUnread } = useReviewState(workspaceId); // Derive hunks from diffState for use in filters and rendering - // Only show hunks if they belong to the current workspace const hunks = useMemo( () => - (diffState.status === "loaded" || diffState.status === "refreshing") && - diffState.workspaceId === workspaceId + diffState.status === "loaded" || diffState.status === "refreshing" ? diffState.hunks : [], - [diffState, workspaceId] + [diffState] ); const [filters, setFilters] = useState({ @@ -222,16 +221,12 @@ export const ReviewPanel: React.FC = ({ let cancelled = false; // Transition to appropriate loading state: - // - "refreshing" if we have data for THIS workspace (keeps UI stable) - // - "loading" if no data or different workspace (shows loading indicator) + // - "refreshing" if we have data (keeps UI stable during refresh) + // - "loading" if no data yet setDiffState((prev) => { - const hasDataForWorkspace = - (prev.status === "loaded" || prev.status === "refreshing") && - prev.workspaceId === workspaceId; - if (hasDataForWorkspace) { + if (prev.status === "loaded" || prev.status === "refreshing") { return { status: "refreshing", - workspaceId, hunks: prev.hunks, truncationWarning: prev.truncationWarning, }; @@ -293,7 +288,7 @@ export const ReviewPanel: React.FC = ({ : null; // Single atomic state update with all data - setDiffState({ status: "loaded", workspaceId, hunks: allHunks, truncationWarning }); + setDiffState({ status: "loaded", hunks: allHunks, truncationWarning }); // Auto-select first hunk if none selected if (allHunks.length > 0 && !selectedHunkId) { @@ -642,9 +637,7 @@ export const ReviewPanel: React.FC = ({
{diffState.message}
- ) : diffState.status === "loading" || - ((diffState.status === "loaded" || diffState.status === "refreshing") && - diffState.workspaceId !== workspaceId) ? ( + ) : diffState.status === "loading" ? (
Loading diff...
From 0e3dbf32f56acbb7270d2f6fd81c2ebb45a8c1a9 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 19:11:08 -0600 Subject: [PATCH 12/14] ci: trigger new check run --- .../components/RightSidebar/CodeReview/ReviewPanel.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx index eea38c036..7f7f22bf3 100644 --- a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx @@ -133,9 +133,7 @@ export const ReviewPanel: React.FC = ({ // Derive hunks from diffState for use in filters and rendering const hunks = useMemo( () => - diffState.status === "loaded" || diffState.status === "refreshing" - ? diffState.hunks - : [], + diffState.status === "loaded" || diffState.status === "refreshing" ? diffState.hunks : [], [diffState] ); @@ -627,7 +625,9 @@ export const ReviewPanel: React.FC = ({ stats={stats} onFiltersChange={setFilters} onRefresh={() => setRefreshTrigger((prev) => prev + 1)} - isLoading={diffState.status === "loading" || diffState.status === "refreshing" || isLoadingTree} + isLoading={ + diffState.status === "loading" || diffState.status === "refreshing" || isLoadingTree + } workspaceId={workspaceId} workspacePath={workspacePath} refreshTrigger={refreshTrigger} From 5e0ffe3f4763cf2540da86b10476d354c6f8dbe5 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 19:13:33 -0600 Subject: [PATCH 13/14] debug: add logging to diagnose worker creation Also adds worker name for DevTools visibility. TODO: For 20k+ line diffs, the main thread bottleneck is likely DOM rendering (thousands of HunkViewer components), not highlighting. Consider adding virtualization (@tanstack/react-virtual) to only render visible hunks. --- src/browser/utils/highlighting/highlightWorkerClient.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/browser/utils/highlighting/highlightWorkerClient.ts b/src/browser/utils/highlighting/highlightWorkerClient.ts index 4a2ced66f..bcd3df378 100644 --- a/src/browser/utils/highlighting/highlightWorkerClient.ts +++ b/src/browser/utils/highlighting/highlightWorkerClient.ts @@ -74,17 +74,21 @@ function getWorkerAPI(): Comlink.Remote | null { // Use relative path - @/ alias doesn't work in worker context const worker = new Worker(new URL("../../workers/highlightWorker.ts", import.meta.url), { type: "module", + name: "shiki-highlighter", // Shows up in DevTools }); - worker.onerror = () => { + worker.onerror = (e) => { + console.error("[highlightWorkerClient] Worker failed to load:", e); workerFailed = true; workerAPI = null; }; + console.log("[highlightWorkerClient] Worker created successfully"); workerAPI = Comlink.wrap(worker); return workerAPI; - } catch { + } catch (e) { // Workers not available (e.g., test environment) + console.error("[highlightWorkerClient] Failed to create worker:", e); workerFailed = true; return null; } From 15b85610064e0c3966e071a1fcbe7ce53a48f469 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 19:27:13 -0600 Subject: [PATCH 14/14] fix: restrict shiki imports to worker only - Change main-thread fallback to use dynamic import() so shiki isn't bundled with the main thread code path - Add ESLint rule to enforce shiki is only imported in highlightWorker.ts - Type-only imports are allowed (erased at compile time) This ensures the 9.7MB @shikijs/langs package is only loaded in the web worker, not blocking the main thread during app startup. --- eslint.config.mjs | 22 +++++++++++++++++++ .../highlighting/highlightWorkerClient.ts | 15 ++++++++----- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 5eb19775c..3f05f32dd 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -490,6 +490,28 @@ export default defineConfig([ ], }, }, + { + // Shiki must only be imported in the highlight worker to avoid blocking main thread + // Type-only imports are allowed (erased at compile time) + files: ["src/**/*.ts", "src/**/*.tsx"], + ignores: ["src/browser/workers/highlightWorker.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: ["shiki"], + importNamePattern: "^(?!type\\s)", + allowTypeImports: true, + message: + "Shiki must only be imported in highlightWorker.ts to avoid blocking the main thread. Use highlightCode() from highlightWorkerClient.ts instead.", + }, + ], + }, + ], + }, + }, { // Renderer process (frontend) architectural boundary - prevent Node.js API usage files: ["src/**/*.ts", "src/**/*.tsx"], diff --git a/src/browser/utils/highlighting/highlightWorkerClient.ts b/src/browser/utils/highlighting/highlightWorkerClient.ts index bcd3df378..27e2bd67a 100644 --- a/src/browser/utils/highlighting/highlightWorkerClient.ts +++ b/src/browser/utils/highlighting/highlightWorkerClient.ts @@ -10,7 +10,7 @@ import { LRUCache } from "lru-cache"; import * as Comlink from "comlink"; -import { createHighlighter, type Highlighter } from "shiki"; +import type { Highlighter } from "shiki"; import type { HighlightWorkerAPI } from "@/browser/workers/highlightWorker"; import { mapToShikiLang, SHIKI_DARK_THEME, SHIKI_LIGHT_THEME } from "./shiki-shared"; @@ -46,15 +46,18 @@ let highlighterPromise: Promise | null = null; /** * Get or create main-thread Shiki highlighter (for fallback when worker unavailable) + * Uses dynamic import to avoid loading Shiki on main thread unless actually needed. */ -function getShikiHighlighter(): Promise { +async function getShikiHighlighter(): Promise { // Must use if-check instead of ??= to prevent race condition // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing if (!highlighterPromise) { - highlighterPromise = createHighlighter({ - themes: [SHIKI_DARK_THEME, SHIKI_LIGHT_THEME], - langs: [], - }); + highlighterPromise = import("shiki").then(({ createHighlighter }) => + createHighlighter({ + themes: [SHIKI_DARK_THEME, SHIKI_LIGHT_THEME], + langs: [], + }) + ); } return highlighterPromise; }