diff --git a/Makefile b/Makefile
index 831db8a06..5a50405ae 100644
--- a/Makefile
+++ b/Makefile
@@ -1313,6 +1313,13 @@ build-ui-test-server: build-mock-backend react-ui protogen-go
test-ui-e2e: build-ui-test-server
cd core/http/react-ui && npm install && npx playwright install --with-deps chromium && npx playwright test
+## Optional Playwright worker count for the UI e2e targets below. Pass
+## UI_TEST_WORKERS=N (e.g. `make test-ui-coverage UI_TEST_WORKERS=20`) to
+## override Playwright's default (cores/2). Empty by default so Playwright
+## picks its own worker count.
+UI_TEST_WORKERS ?=
+PLAYWRIGHT_WORKERS_FLAG = $(if $(UI_TEST_WORKERS),--workers=$(UI_TEST_WORKERS),)
+
## Fast Playwright e2e run used by the pre-commit hook on React UI changes.
## Force-rebuilds the (non-instrumented) dist so the suite tests the working
## tree — not a stale dist the `react-ui` skip-guard would leave — re-embeds
@@ -1322,22 +1329,24 @@ test-ui-e2e: build-ui-test-server
test-ui: build-mock-backend protogen-go
cd core/http/react-ui && bun install && bun run build
$(GOCMD) build -o tests/e2e-ui/ui-test-server ./tests/e2e-ui
- cd core/http/react-ui && sh $(CURDIR)/scripts/ensure-playwright-browser.sh && bunx playwright test
+ cd core/http/react-ui && sh $(CURDIR)/scripts/ensure-playwright-browser.sh && bunx playwright test $(PLAYWRIGHT_WORKERS_FLAG)
-## React UI code coverage from the Playwright e2e suite. Builds an
-## istanbul-instrumented bundle (COVERAGE=true), re-embeds it into the
-## ui-test-server (the dist is //go:embed'ed at compile time), runs the
-## Playwright specs — which harvest window.__coverage__ via the coverage
-## fixture — and writes an nyc report to core/http/react-ui/coverage/.
-## Removes the instrumented dist afterwards so normal builds aren't served
-## instrumented assets.
+## React UI code coverage from the Playwright e2e suite. Builds a
+## NON-instrumented bundle with source maps (COVERAGE_V8=true), re-embeds it
+## into the ui-test-server (the dist is //go:embed'ed at compile time), runs the
+## Playwright specs which collect native Chromium V8 coverage (PW_V8_COVERAGE=1)
+## — far cheaper than istanbul's build-time counters (~40% faster end-to-end) —
+## convert it to istanbul via v8-to-istanbul in the coverage fixture, and write
+## an nyc report to core/http/react-ui/coverage/. Removes the dist afterwards so
+## normal builds aren't served source-mapped assets. (The legacy istanbul path
+## still exists: `bun run build:coverage` + unset PW_V8_COVERAGE.)
test-ui-coverage: build-mock-backend protogen-go
trap 'rm -rf "$(CURDIR)/core/http/react-ui/dist"' EXIT; \
- ( cd core/http/react-ui && bun install && bun run build:coverage ) && \
+ ( cd core/http/react-ui && bun install && bun run build:coverage-v8 ) && \
$(GOCMD) build -o tests/e2e-ui/ui-test-server ./tests/e2e-ui && \
( cd core/http/react-ui && rm -rf .nyc_output coverage && \
sh $(CURDIR)/scripts/ensure-playwright-browser.sh && \
- bunx playwright test && bun run coverage:report )
+ PW_V8_COVERAGE=1 bunx playwright test $(PLAYWRIGHT_WORKERS_FLAG) && bun run coverage:report )
## UI coverage baseline (committed) and the strict gate that compares against
## it — the React mirror of test-coverage-baseline / test-coverage-check.
diff --git a/core/http/react-ui/bun.lock b/core/http/react-ui/bun.lock
index d788ad4c5..dd2875fb8 100644
--- a/core/http/react-ui/bun.lock
+++ b/core/http/react-ui/bun.lock
@@ -32,6 +32,7 @@
"yaml": "^2.8.3",
},
"devDependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
"@eslint/js": "^9.27.0",
"@playwright/test": "1.58.2",
"@vitejs/plugin-react": "^6.0.2",
@@ -41,6 +42,7 @@
"globals": "^16.1.0",
"i18next-parser": "^9.4.0",
"nyc": "^18.0.0",
+ "v8-to-istanbul": "^9.3.0",
"vite": "^8.0.14",
"vite-plugin-istanbul": "^9.0.0",
},
@@ -81,6 +83,8 @@
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
+ "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
+
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A=="],
"@codemirror/commands": ["@codemirror/commands@6.10.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="],
@@ -267,6 +271,8 @@
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
+ "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="],
+
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/minimatch": ["@types/minimatch@3.0.5", "", {}, "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ=="],
@@ -983,6 +989,8 @@
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
+ "v8-to-istanbul": ["v8-to-istanbul@9.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^2.0.0" } }, "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA=="],
+
"value-or-function": ["value-or-function@4.0.0", "", {}, "sha512-aeVK81SIuT6aMJfNo9Vte8Dw0/FZINGBV8BfCraGtqVxIeLAEhJyoWs8SmvRVmXfGss2PmmOwZCuBPbZR+IYWg=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
@@ -1121,6 +1129,8 @@
"test-exclude/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
+ "v8-to-istanbul/convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
+
"vinyl-sourcemap/convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"vite-plugin-istanbul/espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
diff --git a/core/http/react-ui/coverage-baseline.txt b/core/http/react-ui/coverage-baseline.txt
index 6cf2ff28c..4f8b89534 100644
--- a/core/http/react-ui/coverage-baseline.txt
+++ b/core/http/react-ui/coverage-baseline.txt
@@ -1 +1 @@
-30.66
\ No newline at end of file
+38.29
\ No newline at end of file
diff --git a/core/http/react-ui/e2e/coverage-fixtures.js b/core/http/react-ui/e2e/coverage-fixtures.js
index b927a6f97..1f03cace9 100644
--- a/core/http/react-ui/e2e/coverage-fixtures.js
+++ b/core/http/react-ui/e2e/coverage-fixtures.js
@@ -15,9 +15,41 @@ import { randomUUID } from 'node:crypto'
import path from 'node:path'
const COVERAGE_DIR = path.resolve(process.cwd(), '.nyc_output')
+const V8_COVERAGE = process.env.PW_V8_COVERAGE === '1'
+
+const withCoverage = base.extend({
+ // Worker-scoped V8 coverage accumulator: collects every test's native
+ // Chromium coverage and converts it to istanbul ONCE at worker teardown
+ // (conversion is expensive; see e2e/v8-coverage.js). null when V8 mode is off.
+ _v8acc: [
+ async ({}, use) => {
+ if (!V8_COVERAGE) {
+ await use(null)
+ return
+ }
+ const { createAccumulator } = await import('./v8-coverage.js')
+ const acc = createAccumulator()
+ await use(acc)
+ await acc.flush()
+ },
+ { scope: 'worker' },
+ ],
+
+ page: async ({ page, _v8acc }, use) => {
+ // V8 coverage path: collect native Chromium coverage (cheap), hand it to the
+ // worker accumulator on teardown. Avoids running an instrumented bundle.
+ if (V8_COVERAGE) {
+ const { startV8 } = await import('./v8-coverage.js')
+ await startV8(page)
+ await use(page)
+ try {
+ _v8acc.add(await page.coverage.stopJSCoverage())
+ } catch {
+ // page already closed — nothing to collect
+ }
+ return
+ }
-export const test = base.extend({
- page: async ({ page }, use) => {
await use(page)
let coverage
@@ -37,4 +69,5 @@ export const test = base.extend({
},
})
+export const test = withCoverage
export { expect }
diff --git a/core/http/react-ui/e2e/v8-coverage.js b/core/http/react-ui/e2e/v8-coverage.js
new file mode 100644
index 000000000..ed14d481a
--- /dev/null
+++ b/core/http/react-ui/e2e/v8-coverage.js
@@ -0,0 +1,88 @@
+// V8 -> istanbul coverage harvest for the Playwright suite.
+//
+// When PW_V8_COVERAGE=1 the suite runs against a NON-instrumented build (built
+// with COVERAGE_V8=true, which only adds source maps). Chromium collects native
+// V8 coverage with near-zero runtime overhead; we convert it back to per-source
+// istanbul data via v8-to-istanbul (using the on-disk source maps), filter to
+// src/**, and write the same .nyc_output/*.json the istanbul path produced — so
+// `nyc report` and the strict baseline gate are unchanged.
+//
+// Conversion (v8-to-istanbul load() parses the large bundle source map) is the
+// expensive part, so we do NOT convert per test. Instead each worker collects
+// raw V8 coverage from every test, merges it with @bcoe/v8-coverage (which sums
+// counts and reconciles overlapping ranges correctly — applyCoverage can't be
+// called repeatedly, it pushes/overwrites), and converts ONCE at worker
+// teardown. That cuts conversions from ~152 (per test) to ~1 per worker.
+import v8toIstanbul from 'v8-to-istanbul'
+import libCoverage from 'istanbul-lib-coverage'
+import { mergeProcessCovs } from '@bcoe/v8-coverage'
+import { mkdirSync, writeFileSync, existsSync } from 'node:fs'
+import { randomUUID } from 'node:crypto'
+import path from 'node:path'
+
+const COVERAGE_DIR = path.resolve(process.cwd(), '.nyc_output')
+const DIST_ASSETS = path.resolve(process.cwd(), 'dist', 'assets')
+// Absolute app source dir. Match on this (not a bare "/src/" substring) — the
+// repo itself lives under .../go/src/..., so a substring check would collide.
+const SRC_DIR = path.resolve(process.cwd(), 'src') + path.sep
+// Only our own bundle chunks under /assets/*.js carry app source maps.
+const APP_CHUNK = /\/assets\/([^/?]+\.js)(\?|$)/
+
+export async function startV8(page) {
+ // resetOnNavigation:false so hard navigations (goto) within a test accumulate.
+ await page.coverage.startJSCoverage({ resetOnNavigation: false })
+}
+
+// One accumulator per worker (created by the worker-scoped fixture).
+export function createAccumulator() {
+ const processCovs = []
+
+ return {
+ // Called on each test teardown with that test's V8 coverage entries.
+ add(entries) {
+ const result = entries
+ .filter((e) => APP_CHUNK.test(e.url))
+ // Keep only structural fields (drop the ~1MB `source` per entry — it's
+ // re-read from disk at convert time — to bound per-worker memory).
+ .map((e) => ({ scriptId: e.scriptId || e.url, url: e.url, functions: e.functions }))
+ if (result.length) processCovs.push({ result })
+ },
+
+ // Called once at worker teardown: merge all tests' coverage, convert, write.
+ async flush() {
+ if (processCovs.length === 0) return
+ const merged = mergeProcessCovs(processCovs)
+ const map = libCoverage.createCoverageMap({})
+
+ for (const script of merged.result) {
+ const m = APP_CHUNK.exec(script.url)
+ if (!m) continue
+ const diskPath = path.join(DIST_ASSETS, m[1])
+ if (!existsSync(diskPath)) continue
+
+ // v8-to-istanbul auto-loads source + sibling .map from disk; the served
+ // bytes match dist, so the V8 ranges line up.
+ const converter = v8toIstanbul(diskPath, 0)
+ try {
+ await converter.load()
+ converter.applyCoverage(script.functions)
+ const data = converter.toIstanbul()
+ for (const [key, fileCov] of Object.entries(data)) {
+ // v8-to-istanbul keys are already absolute; keep only app sources.
+ if (!key.startsWith(SRC_DIR) || key.includes(`${path.sep}node_modules${path.sep}`)) continue
+ map.merge({ [key]: fileCov })
+ }
+ } catch {
+ // skip a chunk we couldn't convert
+ } finally {
+ converter.destroy()
+ }
+ }
+
+ const json = map.toJSON()
+ if (Object.keys(json).length === 0) return
+ mkdirSync(COVERAGE_DIR, { recursive: true })
+ writeFileSync(path.join(COVERAGE_DIR, `v8-${randomUUID()}.json`), JSON.stringify(json))
+ },
+ }
+}
diff --git a/core/http/react-ui/package.json b/core/http/react-ui/package.json
index 6af515c4a..b0806e498 100644
--- a/core/http/react-ui/package.json
+++ b/core/http/react-ui/package.json
@@ -12,6 +12,7 @@
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"build:coverage": "COVERAGE=true vite build",
+ "build:coverage-v8": "COVERAGE_V8=true vite build",
"coverage:report": "nyc report"
},
"dependencies": {
@@ -42,6 +43,7 @@
"yaml": "^2.8.3"
},
"devDependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
"@eslint/js": "^9.27.0",
"@playwright/test": "1.58.2",
"@vitejs/plugin-react": "^6.0.2",
@@ -51,6 +53,7 @@
"globals": "^16.1.0",
"i18next-parser": "^9.4.0",
"nyc": "^18.0.0",
+ "v8-to-istanbul": "^9.3.0",
"vite": "^8.0.14",
"vite-plugin-istanbul": "^9.0.0"
}
diff --git a/core/http/react-ui/src/App.jsx b/core/http/react-ui/src/App.jsx
index 73a59f81e..d9f170a39 100644
--- a/core/http/react-ui/src/App.jsx
+++ b/core/http/react-ui/src/App.jsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useRef } from 'react'
+import { useState, useEffect, useRef, Suspense } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import Sidebar from './components/Sidebar'
@@ -122,7 +122,14 @@ export default function App() {
-
+ {/* Per-route Suspense catches React.lazy chunk loads (router.jsx)
+ here, inside the App layout. Without it, suspension would bubble
+ up to main.jsx's outer boundary and unmount the sidebar/header
+ on every navigation. fallback={null} keeps the shell stable; the
+ page-content area briefly blanks while the chunk arrives. */}
+
+
+
{!isChatRoute && (
diff --git a/core/http/react-ui/src/components/CanvasPanel.jsx b/core/http/react-ui/src/components/CanvasPanel.jsx
index 9d57cff06..4ac4c3064 100644
--- a/core/http/react-ui/src/components/CanvasPanel.jsx
+++ b/core/http/react-ui/src/components/CanvasPanel.jsx
@@ -4,7 +4,7 @@ import { getArtifactIcon } from '../utils/artifacts'
import { safeHref } from '../utils/url'
import { copyToClipboard } from '../utils/clipboard'
import DOMPurify from 'dompurify'
-import hljs from 'highlight.js'
+import hljs from '../utils/hljs'
export default function CanvasPanel({ artifacts, selectedId, onSelect, onClose }) {
const [showPreview, setShowPreview] = useState(true)
diff --git a/core/http/react-ui/src/components/Sidebar.jsx b/core/http/react-ui/src/components/Sidebar.jsx
index 148a33bfb..c75ed377d 100644
--- a/core/http/react-ui/src/components/Sidebar.jsx
+++ b/core/http/react-ui/src/components/Sidebar.jsx
@@ -6,6 +6,7 @@ import LanguageSwitcher from './LanguageSwitcher'
import { useAuth } from '../context/AuthContext'
import { useBranding } from '../contexts/BrandingContext'
import { apiUrl } from '../utils/basePath'
+import { preloadRoute } from '../router'
const COLLAPSED_KEY = 'localai_sidebar_collapsed'
const SECTIONS_KEY = 'localai_sidebar_sections'
@@ -85,6 +86,10 @@ const sections = [
function NavItem({ item, onClose, collapsed }) {
const { t } = useTranslation('nav')
const label = t(item.labelKey)
+ // Warm the route's lazy chunk before the user clicks. Touch fires ~150ms
+ // before the synthetic click on mobile; mouseenter/focus cover desktop and
+ // keyboard. The underlying import() is memoised so multiple triggers are free.
+ const preload = () => preloadRoute(item.path)
return (
@@ -296,6 +304,9 @@ export default function Sidebar({ isOpen, onClose }) {