ui: add worker pool support for @pierre/diffs

Philip Zeyliger and Claude created

Prompt: When using the diffs library for the diff view in the conversation timeline, we should set up the worker pool support. See https://diffs.com/docs#worker-pool-setup . Make it so.

Offload syntax highlighting to background threads for better
performance when rendering large diffs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Change summary

ui/package.json        |  10 +
ui/pnpm-lock.yaml      | 106 +++++++++++++-----------
ui/scripts/build.js    |  13 ++
ui/src/App.tsx         | 189 +++++++++++++++++++++++++++----------------
ui/src/diffs-worker.ts |  11 ++
5 files changed, 204 insertions(+), 125 deletions(-)

Detailed changes

ui/package.json 🔗

@@ -20,7 +20,7 @@
     "test:e2e:debug": "pnpm run build && playwright test --debug"
   },
   "dependencies": {
-    "@pierre/diffs": "^1.0.9",
+    "@pierre/diffs": "^1.0.10",
     "@xterm/addon-fit": "^0.11.0",
     "@xterm/addon-web-links": "^0.12.0",
     "@xterm/xterm": "^6.0.0",
@@ -49,6 +49,12 @@
   "pnpm": {
     "onlyBuiltDependencies": [
       "esbuild"
-    ]
+    ],
+    "overrides": {
+      "shiki": "^3.22.0",
+      "@shikijs/core": "^3.22.0",
+      "@shikijs/engine-javascript": "^3.22.0",
+      "@shikijs/transformers": "^3.22.0"
+    }
   }
 }

ui/pnpm-lock.yaml 🔗

@@ -4,13 +4,19 @@ settings:
   autoInstallPeers: true
   excludeLinksFromLockfile: false
 
+overrides:
+  shiki: ^3.22.0
+  '@shikijs/core': ^3.22.0
+  '@shikijs/engine-javascript': ^3.22.0
+  '@shikijs/transformers': ^3.22.0
+
 importers:
 
   .:
     dependencies:
       '@pierre/diffs':
-        specifier: ^1.0.9
-        version: 1.0.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+        specifier: ^1.0.10
+        version: 1.0.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
       '@xterm/addon-fit':
         specifier: ^0.11.0
         version: 0.11.0
@@ -429,8 +435,8 @@ packages:
     resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
     engines: {node: '>=18.18'}
 
-  '@pierre/diffs@1.0.9':
-    resolution: {integrity: sha512-PiRhcAzz0yuifRTe2DmTsKOHQgGf1qgh5W9OVO3/c+DONrjo1PM5tquOxndCAZDTdPxwzGuWc6jPUaDrfu6nDg==}
+  '@pierre/diffs@1.0.10':
+    resolution: {integrity: sha512-ahkpfS30NfaB+PBxnf0/Mc20ySBRTQmM28a7Ojpd0UZixmTyhGhJfBFjvmhX8dSzR22lB3h3OIMMxpB4yYTIOQ==}
     peerDependencies:
       react: ^18.3.1 || ^19.0.0
       react-dom: ^18.3.1 || ^19.0.0
@@ -440,26 +446,26 @@ packages:
     engines: {node: '>=18'}
     hasBin: true
 
-  '@shikijs/core@3.21.0':
-    resolution: {integrity: sha512-AXSQu/2n1UIQekY8euBJlvFYZIw0PHY63jUzGbrOma4wPxzznJXTXkri+QcHeBNaFxiiOljKxxJkVSoB3PjbyA==}
+  '@shikijs/core@3.22.0':
+    resolution: {integrity: sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA==}
 
-  '@shikijs/engine-javascript@3.21.0':
-    resolution: {integrity: sha512-ATwv86xlbmfD9n9gKRiwuPpWgPENAWCLwYCGz9ugTJlsO2kOzhOkvoyV/UD+tJ0uT7YRyD530x6ugNSffmvIiQ==}
+  '@shikijs/engine-javascript@3.22.0':
+    resolution: {integrity: sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw==}
 
-  '@shikijs/engine-oniguruma@3.21.0':
-    resolution: {integrity: sha512-OYknTCct6qiwpQDqDdf3iedRdzj6hFlOPv5hMvI+hkWfCKs5mlJ4TXziBG9nyabLwGulrUjHiCq3xCspSzErYQ==}
+  '@shikijs/engine-oniguruma@3.22.0':
+    resolution: {integrity: sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA==}
 
-  '@shikijs/langs@3.21.0':
-    resolution: {integrity: sha512-g6mn5m+Y6GBJ4wxmBYqalK9Sp0CFkUqfNzUy2pJglUginz6ZpWbaWjDB4fbQ/8SHzFjYbtU6Ddlp1pc+PPNDVA==}
+  '@shikijs/langs@3.22.0':
+    resolution: {integrity: sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA==}
 
-  '@shikijs/themes@3.21.0':
-    resolution: {integrity: sha512-BAE4cr9EDiZyYzwIHEk7JTBJ9CzlPuM4PchfcA5ao1dWXb25nv6hYsoDiBq2aZK9E3dlt3WB78uI96UESD+8Mw==}
+  '@shikijs/themes@3.22.0':
+    resolution: {integrity: sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g==}
 
-  '@shikijs/transformers@3.21.0':
-    resolution: {integrity: sha512-CZwvCWWIiRRiFk9/JKzdEooakAP8mQDtBOQ1TKiCaS2E1bYtyBCOkUzS8akO34/7ufICQ29oeSfkb3tT5KtrhA==}
+  '@shikijs/transformers@3.22.0':
+    resolution: {integrity: sha512-E7eRV7mwDBjueLF6852n2oYeJYxBq3NSsDk+uyruYAXONv4U8holGmIrT+mPRJQ1J1SNOH6L8G19KRzmBawrFw==}
 
-  '@shikijs/types@3.21.0':
-    resolution: {integrity: sha512-zGrWOxZ0/+0ovPY7PvBU2gIS9tmhSUUt30jAcNV0Bq0gb2S98gwfjIs1vxlmH5zM7/4YxLamT6ChlqqAJmPPjA==}
+  '@shikijs/types@3.22.0':
+    resolution: {integrity: sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg==}
 
   '@shikijs/vscode-textmate@10.0.2':
     resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
@@ -720,8 +726,8 @@ packages:
   devlop@1.1.0:
     resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
 
-  diff@8.0.2:
-    resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==}
+  diff@8.0.3:
+    resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
     engines: {node: '>=0.3.1'}
 
   doctrine@2.1.0:
@@ -1381,8 +1387,8 @@ packages:
     resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
     engines: {node: '>=8'}
 
-  shiki@3.21.0:
-    resolution: {integrity: sha512-N65B/3bqL/TI2crrXr+4UivctrAGEjmsib5rPMMPpFp1xAx/w03v8WZ9RDDFYteXoEgY7qZ4HGgl5KBIu1153w==}
+  shiki@3.22.0:
+    resolution: {integrity: sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g==}
 
   side-channel-list@1.0.0:
     resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
@@ -1766,54 +1772,54 @@ snapshots:
 
   '@humanwhocodes/retry@0.4.3': {}
 
-  '@pierre/diffs@1.0.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+  '@pierre/diffs@1.0.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
     dependencies:
-      '@shikijs/core': 3.21.0
-      '@shikijs/engine-javascript': 3.21.0
-      '@shikijs/transformers': 3.21.0
-      diff: 8.0.2
+      '@shikijs/core': 3.22.0
+      '@shikijs/engine-javascript': 3.22.0
+      '@shikijs/transformers': 3.22.0
+      diff: 8.0.3
       hast-util-to-html: 9.0.5
       lru_map: 0.4.1
       react: 18.3.1
       react-dom: 18.3.1(react@18.3.1)
-      shiki: 3.21.0
+      shiki: 3.22.0
 
   '@playwright/test@1.57.0':
     dependencies:
       playwright: 1.57.0
 
-  '@shikijs/core@3.21.0':
+  '@shikijs/core@3.22.0':
     dependencies:
-      '@shikijs/types': 3.21.0
+      '@shikijs/types': 3.22.0
       '@shikijs/vscode-textmate': 10.0.2
       '@types/hast': 3.0.4
       hast-util-to-html: 9.0.5
 
-  '@shikijs/engine-javascript@3.21.0':
+  '@shikijs/engine-javascript@3.22.0':
     dependencies:
-      '@shikijs/types': 3.21.0
+      '@shikijs/types': 3.22.0
       '@shikijs/vscode-textmate': 10.0.2
       oniguruma-to-es: 4.3.4
 
-  '@shikijs/engine-oniguruma@3.21.0':
+  '@shikijs/engine-oniguruma@3.22.0':
     dependencies:
-      '@shikijs/types': 3.21.0
+      '@shikijs/types': 3.22.0
       '@shikijs/vscode-textmate': 10.0.2
 
-  '@shikijs/langs@3.21.0':
+  '@shikijs/langs@3.22.0':
     dependencies:
-      '@shikijs/types': 3.21.0
+      '@shikijs/types': 3.22.0
 
-  '@shikijs/themes@3.21.0':
+  '@shikijs/themes@3.22.0':
     dependencies:
-      '@shikijs/types': 3.21.0
+      '@shikijs/types': 3.22.0
 
-  '@shikijs/transformers@3.21.0':
+  '@shikijs/transformers@3.22.0':
     dependencies:
-      '@shikijs/core': 3.21.0
-      '@shikijs/types': 3.21.0
+      '@shikijs/core': 3.22.0
+      '@shikijs/types': 3.22.0
 
-  '@shikijs/types@3.21.0':
+  '@shikijs/types@3.22.0':
     dependencies:
       '@shikijs/vscode-textmate': 10.0.2
       '@types/hast': 3.0.4
@@ -2133,7 +2139,7 @@ snapshots:
     dependencies:
       dequal: 2.0.3
 
-  diff@8.0.2: {}
+  diff@8.0.3: {}
 
   doctrine@2.1.0:
     dependencies:
@@ -2995,14 +3001,14 @@ snapshots:
 
   shebang-regex@3.0.0: {}
 
-  shiki@3.21.0:
+  shiki@3.22.0:
     dependencies:
-      '@shikijs/core': 3.21.0
-      '@shikijs/engine-javascript': 3.21.0
-      '@shikijs/engine-oniguruma': 3.21.0
-      '@shikijs/langs': 3.21.0
-      '@shikijs/themes': 3.21.0
-      '@shikijs/types': 3.21.0
+      '@shikijs/core': 3.22.0
+      '@shikijs/engine-javascript': 3.22.0
+      '@shikijs/engine-oniguruma': 3.22.0
+      '@shikijs/langs': 3.22.0
+      '@shikijs/themes': 3.22.0
+      '@shikijs/types': 3.22.0
       '@shikijs/vscode-textmate': 10.0.2
       '@types/hast': 3.0.4
 

ui/scripts/build.js 🔗

@@ -31,6 +31,17 @@ async function build() {
       sourcemap: true,
     });
 
+    // Build @pierre/diffs worker for syntax highlighting (IIFE format for web worker)
+    log('Building diffs worker...');
+    await esbuild.build({
+      entryPoints: ['src/diffs-worker.ts'],
+      bundle: true,
+      outfile: 'dist/diffs-worker.js',
+      format: 'iife',
+      minify: isProd,
+      sourcemap: true,
+    });
+
     // Build Monaco editor as a separate chunk (JS + CSS)
     log('Building Monaco editor bundle...');
     await esbuild.build({
@@ -105,7 +116,7 @@ async function build() {
     // Generate gzip versions of large files and remove originals to reduce binary size
     // The server will decompress on-the-fly for the rare clients that don't support gzip
     log('\nGenerating gzip compressed files...');
-    const filesToCompress = ['monaco-editor.js', 'editor.worker.js', 'main.js', 'monaco-editor.css', 'styles.css', 'main.css'];
+    const filesToCompress = ['monaco-editor.js', 'editor.worker.js', 'diffs-worker.js', 'main.js', 'monaco-editor.css', 'styles.css', 'main.css'];
     const checksums = {};
     let totalOrigSize = 0;
     let totalGzSize = 0;

ui/src/App.tsx 🔗

@@ -1,4 +1,6 @@
 import React, { useState, useEffect, useCallback, useRef } from "react";
+import { WorkerPoolContextProvider } from "@pierre/diffs/react";
+import type { SupportedLanguages } from "@pierre/diffs";
 import ChatInterface from "./components/ChatInterface";
 import ConversationDrawer from "./components/ConversationDrawer";
 import CommandPalette from "./components/CommandPalette";
@@ -6,6 +8,44 @@ import ModelsModal from "./components/ModelsModal";
 import { Conversation, ConversationWithState, ConversationListUpdate } from "./types";
 import { api } from "./services/api";
 
+// Worker pool configuration for @pierre/diffs syntax highlighting
+// Workers run tokenization off the main thread for better performance with large diffs
+const diffsPoolOptions = {
+  workerFactory: () => new Worker("/diffs-worker.js"),
+};
+
+// Languages to preload in the highlighter (matches PatchTool.tsx langMap)
+const diffsHighlighterOptions = {
+  langs: [
+    "typescript",
+    "tsx",
+    "javascript",
+    "jsx",
+    "python",
+    "ruby",
+    "go",
+    "rust",
+    "java",
+    "c",
+    "cpp",
+    "csharp",
+    "php",
+    "swift",
+    "kotlin",
+    "scala",
+    "bash",
+    "sql",
+    "html",
+    "css",
+    "scss",
+    "json",
+    "xml",
+    "yaml",
+    "toml",
+    "markdown",
+  ] as SupportedLanguages[],
+};
+
 // Check if a slug is a generated ID (format: cXXXX where X is alphanumeric)
 function isGeneratedId(slug: string | null): boolean {
   if (!slug) return true;
@@ -387,81 +427,86 @@ function App() {
   };
 
   return (
-    <div className="app-container">
-      {/* Conversations drawer */}
-      <ConversationDrawer
-        isOpen={drawerOpen}
-        isCollapsed={drawerCollapsed}
-        onClose={() => setDrawerOpen(false)}
-        onToggleCollapse={toggleDrawerCollapsed}
-        conversations={conversations}
-        currentConversationId={currentConversationId}
-        viewedConversation={viewedConversation}
-        onSelectConversation={selectConversation}
-        onNewConversation={startNewConversation}
-        onConversationArchived={handleConversationArchived}
-        onConversationUnarchived={handleConversationUnarchived}
-        onConversationRenamed={handleConversationRenamed}
-        subagentUpdate={subagentUpdate}
-        subagentStateUpdate={subagentStateUpdate}
-      />
-
-      {/* Main chat interface */}
-      <div className="main-content">
-        <ChatInterface
-          conversationId={currentConversationId}
-          onOpenDrawer={() => setDrawerOpen(true)}
+    <WorkerPoolContextProvider
+      poolOptions={diffsPoolOptions}
+      highlighterOptions={diffsHighlighterOptions}
+    >
+      <div className="app-container">
+        {/* Conversations drawer */}
+        <ConversationDrawer
+          isOpen={drawerOpen}
+          isCollapsed={drawerCollapsed}
+          onClose={() => setDrawerOpen(false)}
+          onToggleCollapse={toggleDrawerCollapsed}
+          conversations={conversations}
+          currentConversationId={currentConversationId}
+          viewedConversation={viewedConversation}
+          onSelectConversation={selectConversation}
           onNewConversation={startNewConversation}
-          currentConversation={currentConversation}
-          onConversationUpdate={updateConversation}
-          onConversationListUpdate={handleConversationListUpdate}
-          onConversationStateUpdate={handleConversationStateUpdate}
-          onFirstMessage={handleFirstMessage}
-          onContinueConversation={handleContinueConversation}
-          mostRecentCwd={mostRecentCwd}
-          isDrawerCollapsed={drawerCollapsed}
-          onToggleDrawerCollapse={toggleDrawerCollapsed}
-          openDiffViewerTrigger={diffViewerTrigger}
-          modelsRefreshTrigger={modelsRefreshTrigger}
-          onOpenModelsModal={() => setModelsModalOpen(true)}
+          onConversationArchived={handleConversationArchived}
+          onConversationUnarchived={handleConversationUnarchived}
+          onConversationRenamed={handleConversationRenamed}
+          subagentUpdate={subagentUpdate}
+          subagentStateUpdate={subagentStateUpdate}
         />
-      </div>
 
-      {/* Command Palette */}
-      <CommandPalette
-        isOpen={commandPaletteOpen}
-        onClose={() => setCommandPaletteOpen(false)}
-        conversations={conversations}
-        onNewConversation={() => {
-          startNewConversation();
-          setCommandPaletteOpen(false);
-        }}
-        onSelectConversation={(conversation) => {
-          selectConversation(conversation);
-          setCommandPaletteOpen(false);
-        }}
-        onOpenDiffViewer={() => {
-          setDiffViewerTrigger((prev) => prev + 1);
-          setCommandPaletteOpen(false);
-        }}
-        onOpenModelsModal={() => {
-          setModelsModalOpen(true);
-          setCommandPaletteOpen(false);
-        }}
-        hasCwd={!!(currentConversation?.cwd || mostRecentCwd)}
-      />
-
-      <ModelsModal
-        isOpen={modelsModalOpen}
-        onClose={() => setModelsModalOpen(false)}
-        onModelsChanged={() => setModelsRefreshTrigger((prev) => prev + 1)}
-      />
-
-      {/* Backdrop for mobile drawer */}
-      {drawerOpen && (
-        <div className="backdrop hide-on-desktop" onClick={() => setDrawerOpen(false)} />
-      )}
-    </div>
+        {/* Main chat interface */}
+        <div className="main-content">
+          <ChatInterface
+            conversationId={currentConversationId}
+            onOpenDrawer={() => setDrawerOpen(true)}
+            onNewConversation={startNewConversation}
+            currentConversation={currentConversation}
+            onConversationUpdate={updateConversation}
+            onConversationListUpdate={handleConversationListUpdate}
+            onConversationStateUpdate={handleConversationStateUpdate}
+            onFirstMessage={handleFirstMessage}
+            onContinueConversation={handleContinueConversation}
+            mostRecentCwd={mostRecentCwd}
+            isDrawerCollapsed={drawerCollapsed}
+            onToggleDrawerCollapse={toggleDrawerCollapsed}
+            openDiffViewerTrigger={diffViewerTrigger}
+            modelsRefreshTrigger={modelsRefreshTrigger}
+            onOpenModelsModal={() => setModelsModalOpen(true)}
+          />
+        </div>
+
+        {/* Command Palette */}
+        <CommandPalette
+          isOpen={commandPaletteOpen}
+          onClose={() => setCommandPaletteOpen(false)}
+          conversations={conversations}
+          onNewConversation={() => {
+            startNewConversation();
+            setCommandPaletteOpen(false);
+          }}
+          onSelectConversation={(conversation) => {
+            selectConversation(conversation);
+            setCommandPaletteOpen(false);
+          }}
+          onOpenDiffViewer={() => {
+            setDiffViewerTrigger((prev) => prev + 1);
+            setCommandPaletteOpen(false);
+          }}
+          onOpenModelsModal={() => {
+            setModelsModalOpen(true);
+            setCommandPaletteOpen(false);
+          }}
+          hasCwd={!!(currentConversation?.cwd || mostRecentCwd)}
+        />
+
+        <ModelsModal
+          isOpen={modelsModalOpen}
+          onClose={() => setModelsModalOpen(false)}
+          onModelsChanged={() => setModelsRefreshTrigger((prev) => prev + 1)}
+        />
+
+        {/* Backdrop for mobile drawer */}
+        {drawerOpen && (
+          <div className="backdrop hide-on-desktop" onClick={() => setDrawerOpen(false)} />
+        )}
+      </div>
+    </WorkerPoolContextProvider>
   );
 }
 

ui/src/diffs-worker.ts 🔗

@@ -0,0 +1,11 @@
+// Web Worker for @pierre/diffs syntax highlighting
+// This offloads tokenization to background threads for better performance
+// Note: This file is built as IIFE and runs in a Worker context
+//
+// We import and reference to prevent tree-shaking - the worker.js file
+// sets up self.addEventListener("message", ...) which is a side effect
+import * as diffsWorker from "@pierre/diffs/worker/worker.js";
+
+// Prevent tree-shaking by referencing the import
+// The worker module registers message handlers as a side effect
+(globalThis as unknown as { __diffsWorker: unknown }).__diffsWorker = diffsWorker;