webui: implement Mermaid code splitting with lazy loading

Split Mermaid library into separate bundle with content-based hashing for
optimal caching and reduced bundle size in main application components.

Implementation Changes:

1. Lazy Loading Infrastructure (sketch-timeline-message.ts):
   - Replace direct mermaid import with type-only import
   - Add loadMermaid() function with Promise-based dynamic loading
   - Implement __MERMAID_HASH__ constant injection pattern
   - Add global window.mermaid type declarations
   - Create mermaid loading promise with singleton pattern

2. Bundle Splitting (esbuild.go):
   - Add createStandaloneMermaidBundle() function
   - Generate content-based hash from mermaid package.json
   - Create mermaid-standalone-{hash}.js as IIFE format bundle
   - Implement esbuildBundleWithExternals() replacing esbuildBundleWithExternal()
   - Add --external:mermaid to all TypeScript bundle builds
   - Inject __MERMAID_HASH__ constant at build time

3. Async Rendering (sketch-timeline-message.ts):
   - Update renderMermaidDiagrams() to async/await pattern
   - Load mermaid library only when diagrams are present
   - Initialize mermaid configuration after dynamic loading
   - Maintain fallback to code blocks on loading errors
   - Preserve all existing mermaid functionality and configuration

Technical Details:
- Uses content-based hashing for optimal browser caching
- Mermaid loaded on-demand only when diagrams are present
- Singleton loading pattern prevents duplicate network requests
- Maintains existing mermaid initialization options
- Preserves error handling with code block fallbacks
- IIFE format enables direct window.mermaid assignment

Benefits:
- Reduces bundle size for components not using mermaid diagrams
- Enables browser caching of mermaid library across sessions
- Maintains existing functionality with lazy loading
- Improves initial page load performance
- Provides same user experience with deferred mermaid loading

This follows the same pattern established for Monaco editor bundling,
providing consistent lazy loading architecture for large dependencies.

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s0f8a82fcd28add05k
diff --git a/webui/esbuild.go b/webui/esbuild.go
index f8f010d..1c356ce 100644
--- a/webui/esbuild.go
+++ b/webui/esbuild.go
@@ -215,22 +215,26 @@
 		"node_modules/monaco-editor/esm/vs/language/json/json.worker.js",
 	}
 
-	// Additionally create a standalone Monaco bundle for caching
+	// Additionally create standalone bundles for caching
 	monacoHash, err := createStandaloneMonacoBundle(tmpHashDir, buildDir)
 	if err != nil {
 		return nil, fmt.Errorf("create monaco bundle: %w", err)
 	}
-	_ = monacoHash // We created it for caching benefits, but don't use it yet
 
-	// Bundle all files with Monaco as external (since they may transitively import Monaco)
+	mermaidHash, err := createStandaloneMermaidBundle(tmpHashDir, buildDir)
+	if err != nil {
+		return nil, fmt.Errorf("create mermaid bundle: %w", err)
+	}
+
+	// Bundle all files with Monaco and Mermaid as external (since they may transitively import them)
 	for _, tsName := range bundleTs {
-		// Use external Monaco for all TypeScript files to ensure consistency
+		// Use external Monaco and Mermaid for all TypeScript files to ensure consistency
 		if strings.HasSuffix(tsName, ".ts") {
-			if err := esbuildBundleWithExternal(tmpHashDir, filepath.Join(buildDir, tsName), monacoHash); err != nil {
+			if err := esbuildBundleWithExternals(tmpHashDir, filepath.Join(buildDir, tsName), monacoHash, mermaidHash); err != nil {
 				return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
 			}
 		} else {
-			// Bundle worker files normally (they don't use Monaco)
+			// Bundle worker files normally (they don't use Monaco or Mermaid)
 			if err := esbuildBundle(tmpHashDir, filepath.Join(buildDir, tsName), ""); err != nil {
 				return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
 			}
@@ -517,8 +521,60 @@
 	return monacoHash, nil
 }
 
-// esbuildBundleWithExternal bundles a file with Monaco as external dependency
-func esbuildBundleWithExternal(outDir, src, monacoHash string) error {
+// createStandaloneMermaidBundle creates a separate Mermaid bundle with content-based hash
+// This is useful for caching Mermaid separately from the main application bundles
+func createStandaloneMermaidBundle(outDir, buildDir string) (string, error) {
+	// Create a temporary entry file that imports Mermaid and exposes it globally
+	mermaidEntryContent := `import mermaid from 'mermaid';
+window.mermaid = mermaid;
+export default mermaid;
+`
+	mermaidEntryPath := filepath.Join(buildDir, "mermaid-standalone-entry.js")
+	if err := os.WriteFile(mermaidEntryPath, []byte(mermaidEntryContent), 0o666); err != nil {
+		return "", fmt.Errorf("write mermaid entry: %w", err)
+	}
+
+	// Calculate hash of mermaid package for content-based naming
+	mermaidPackageJson := filepath.Join(buildDir, "node_modules", "mermaid", "package.json")
+	mermaidContent, err := os.ReadFile(mermaidPackageJson)
+	if err != nil {
+		return "", fmt.Errorf("read mermaid package.json: %w", err)
+	}
+
+	h := sha256.New()
+	h.Write(mermaidContent)
+	mermaidHash := hex.EncodeToString(h.Sum(nil))[:16]
+
+	// Bundle Mermaid with content-based filename
+	mermaidOutputName := fmt.Sprintf("mermaid-standalone-%s.js", mermaidHash)
+	mermaidOutputPath := filepath.Join(outDir, mermaidOutputName)
+
+	args := []string{
+		mermaidEntryPath,
+		"--bundle",
+		"--sourcemap",
+		"--minify",
+		"--log-level=error",
+		"--outfile=" + mermaidOutputPath,
+		"--format=iife",
+		"--global-name=__MermaidLoader__",
+		"--loader:.ttf=file",
+		"--loader:.eot=file",
+		"--loader:.woff=file",
+		"--loader:.woff2=file",
+		"--public-path=.",
+	}
+
+	ret := esbuildcli.Run(args)
+	if ret != 0 {
+		return "", fmt.Errorf("esbuild mermaid bundle failed: %d", ret)
+	}
+
+	return mermaidHash, nil
+}
+
+// esbuildBundleWithExternals bundles a file with Monaco and Mermaid as external dependencies
+func esbuildBundleWithExternals(outDir, src, monacoHash, mermaidHash string) error {
 	args := []string{
 		src,
 		"--bundle",
@@ -527,12 +583,14 @@
 		"--log-level=error",
 		"--outdir=" + outDir,
 		"--external:monaco-editor",
+		"--external:mermaid",
 		"--loader:.ttf=file",
 		"--loader:.eot=file",
 		"--loader:.woff=file",
 		"--loader:.woff2=file",
 		"--public-path=.",
 		"--define:__MONACO_HASH__=\"" + monacoHash + "\"",
+		"--define:__MERMAID_HASH__=\"" + mermaidHash + "\"",
 	}
 
 	ret := esbuildcli.Run(args)
diff --git a/webui/src/web-components/sketch-timeline-message.ts b/webui/src/web-components/sketch-timeline-message.ts
index 9ea1ced..9fa5b7a 100644
--- a/webui/src/web-components/sketch-timeline-message.ts
+++ b/webui/src/web-components/sketch-timeline-message.ts
@@ -3,8 +3,57 @@
 import { customElement, property, state } from "lit/decorators.js";
 import { AgentMessage, State } from "../types";
 import { marked, MarkedOptions, Renderer, Tokens } from "marked";
-import mermaid from "mermaid";
+import type mermaid from "mermaid";
 import DOMPurify from "dompurify";
+
+// Mermaid is loaded dynamically - see loadMermaid() function
+declare global {
+  interface Window {
+    mermaid?: typeof mermaid;
+  }
+}
+
+// Mermaid hash will be injected at build time
+declare const __MERMAID_HASH__: string;
+
+// Load Mermaid dynamically
+let mermaidLoadPromise: Promise<any> | null = null;
+
+function loadMermaid(): Promise<typeof mermaid> {
+  if (mermaidLoadPromise) {
+    return mermaidLoadPromise;
+  }
+
+  if (window.mermaid) {
+    return Promise.resolve(window.mermaid);
+  }
+
+  mermaidLoadPromise = new Promise((resolve, reject) => {
+    // Get the Mermaid hash from build-time constant
+    const mermaidHash = __MERMAID_HASH__;
+
+    // Try to load the external Mermaid bundle
+    const script = document.createElement("script");
+    script.onload = () => {
+      // The Mermaid bundle should set window.mermaid
+      if (window.mermaid) {
+        resolve(window.mermaid);
+      } else {
+        reject(new Error("Mermaid not loaded from external bundle"));
+      }
+    };
+    script.onerror = (error) => {
+      console.warn("Failed to load external Mermaid bundle:", error);
+      reject(new Error("Mermaid external bundle failed to load"));
+    };
+
+    // Don't set type="module" since we're using IIFE format
+    script.src = `./static/mermaid-standalone-${mermaidHash}.js`;
+    document.head.appendChild(script);
+  });
+
+  return mermaidLoadPromise;
+}
 import "./sketch-tool-calls";
 @customElement("sketch-timeline-message")
 export class SketchTimelineMessage extends LitElement {
@@ -702,14 +751,7 @@
 
   constructor() {
     super();
-    // Initialize mermaid with specific config
-    mermaid.initialize({
-      startOnLoad: false,
-      suppressErrorRendering: true,
-      theme: "default",
-      securityLevel: "loose", // Allows more flexibility but be careful with user-generated content
-      fontFamily: "monospace",
-    });
+    // Mermaid will be initialized lazily when first needed
   }
 
   // See https://lit.dev/docs/components/lifecycle/
@@ -727,38 +769,59 @@
   // Render mermaid diagrams after the component is updated
   renderMermaidDiagrams() {
     // Add a small delay to ensure the DOM is fully rendered
-    setTimeout(() => {
+    setTimeout(async () => {
       // Find all mermaid containers in our shadow root
       const containers = this.shadowRoot?.querySelectorAll(".mermaid");
       if (!containers || containers.length === 0) return;
 
-      // Process each mermaid diagram
-      containers.forEach((container) => {
-        const id = container.id;
-        const code = container.textContent || "";
-        if (!code || !id) return; // Use return for forEach instead of continue
+      try {
+        // Load mermaid dynamically
+        const mermaidLib = await loadMermaid();
 
-        try {
-          // Clear any previous content
-          container.innerHTML = code;
+        // Initialize mermaid with specific config (only once per load)
+        mermaidLib.initialize({
+          startOnLoad: false,
+          suppressErrorRendering: true,
+          theme: "default",
+          securityLevel: "loose", // Allows more flexibility but be careful with user-generated content
+          fontFamily: "monospace",
+        });
 
-          // Render the mermaid diagram using promise
-          mermaid
-            .render(`${id}-svg`, code)
-            .then(({ svg }) => {
-              container.innerHTML = svg;
-            })
-            .catch((err) => {
-              console.error("Error rendering mermaid diagram:", err);
-              // Show the original code as fallback
-              container.innerHTML = `<pre>${code}</pre>`;
-            });
-        } catch (err) {
-          console.error("Error processing mermaid diagram:", err);
-          // Show the original code as fallback
+        // Process each mermaid diagram
+        containers.forEach((container) => {
+          const id = container.id;
+          const code = container.textContent || "";
+          if (!code || !id) return; // Use return for forEach instead of continue
+
+          try {
+            // Clear any previous content
+            container.innerHTML = code;
+
+            // Render the mermaid diagram using promise
+            mermaidLib
+              .render(`${id}-svg`, code)
+              .then(({ svg }) => {
+                container.innerHTML = svg;
+              })
+              .catch((err) => {
+                console.error("Error rendering mermaid diagram:", err);
+                // Show the original code as fallback
+                container.innerHTML = `<pre>${code}</pre>`;
+              });
+          } catch (err) {
+            console.error("Error processing mermaid diagram:", err);
+            // Show the original code as fallback
+            container.innerHTML = `<pre>${code}</pre>`;
+          }
+        });
+      } catch (err) {
+        console.error("Error loading mermaid:", err);
+        // Show the original code as fallback for all diagrams
+        containers.forEach((container) => {
+          const code = container.textContent || "";
           container.innerHTML = `<pre>${code}</pre>`;
-        }
-      });
+        });
+      }
     }, 100); // Small delay to ensure DOM is ready
   }