From b3ef5c7e8dbd4327354bd61a83889e2cf49b673f Mon Sep 17 00:00:00 2001 From: HUQIANTAO Date: Fri, 5 Jun 2026 00:14:30 -0700 Subject: [PATCH 1/6] perf(desktop): add Vite code splitting and terser minification for smaller bundles --- desktop/frontend/vite.config.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/desktop/frontend/vite.config.ts b/desktop/frontend/vite.config.ts index cce367168..0f49e1837 100644 --- a/desktop/frontend/vite.config.ts +++ b/desktop/frontend/vite.config.ts @@ -21,6 +21,37 @@ export default defineConfig({ outDir: "dist", emptyOutDir: true, target: "es2021", + // Use terser for smaller output (esbuild is faster to build but produces + // larger bundles). Disabled for dev builds via the default. + minify: "terser", + terserOptions: { + compress: { + drop_console: true, // strip console.log in production + passes: 2, // two compression passes for better tree-shaking + }, + }, + rollupOptions: { + output: { + // Manual chunk splitting: keep the heavy markdown/math/code pipeline + // in a separate chunk so it can be cached independently from the + // app shell. The vendor chunk splits react+react-dom (stable, rarely + // changes) from the markdown stack (changes more often). + manualChunks: { + "vendor-react": ["react", "react-dom"], + "vendor-markdown": [ + "react-markdown", + "remark-gfm", + "remark-math", + "rehype-katex", + "katex", + ], + "vendor-highlight": ["highlight.js"], + }, + }, + }, + // Raise the warning limit — the markdown vendor chunk is legitimately large + // (katex alone is ~300KB). The manual split ensures it's cached separately. + chunkSizeWarningLimit: 600, }, server: { // Bind IPv4 — unset host listens on ::1, and the Wails dev proxy's [::1] From 8a2293271333a6cda06e74cc89f80c385649cc9a Mon Sep 17 00:00:00 2001 From: HUQIANTAO Date: Fri, 5 Jun 2026 00:14:31 -0700 Subject: [PATCH 2/6] perf(desktop): 200-entry LRU cache for highlight.js output --- desktop/frontend/src/lib/highlight.ts | 66 ++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/desktop/frontend/src/lib/highlight.ts b/desktop/frontend/src/lib/highlight.ts index 0fd5d12f1..0cb5e3b67 100644 --- a/desktop/frontend/src/lib/highlight.ts +++ b/desktop/frontend/src/lib/highlight.ts @@ -43,15 +43,77 @@ export function resolveLang(lang?: string): string { return hljs.getLanguage(resolved) ? resolved : ""; } +// LRU cache for highlighted output. The same code block can re-render many +// times (Re-renders due to streaming updates that don't change this block, +// React's StrictMode double-invoke in dev, hover-reveals of the toolbar's +// child elements). highlight.highlight() is a real lexer walk that shows +// up in the profile for large blocks; a 200-entry LRU keyed on the +// resolved language plus a fast hash of the code keeps the steady-state +// cost at a Map.get(). The Map is a plain LRU, not a WeakMap, so a large +// (non-streaming) transcript will eventually evict; the size of 200 is +// chosen to cover the visible viewport plus a small overshoot — most +// transcripts re-render the same ~30 visible blocks. +const HL_CACHE_MAX = 200; + +// djb2 hash for the cache key. We only use the hash inside the key +// (Map), not as a security primitive; collisions are fine +// because we ALSO store the original code alongside the entry and verify +// before serving the cached value. The hash is what makes the key +// constant-time to compare for Map.get(); comparing the full source +// string would be O(n) on every render and dwarf the savings. +function hashCode(s: string): number { + let h = 5381; + for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; + return h; +} + +interface CacheEntry { + code: string; + html: string; +} +const hlCache = new Map(); + +function cacheGet(code: string, lang: string): string | null { + const key = hashCode(lang + "\0" + code); + const e = hlCache.get(key); + if (!e) return null; + // Defend against the (rare) hash collision: the stored code must match + // the queried code exactly. We move the entry to the end of the Map to + // mark it most-recently-used. + if (e.code !== code) return null; + hlCache.delete(key); + hlCache.set(key, e); + return e.html; +} + +function cachePut(code: string, lang: string, html: string): void { + const key = hashCode(lang + "\0" + code); + if (hlCache.has(key)) hlCache.delete(key); // refresh + hlCache.set(key, { code, html }); + while (hlCache.size > HL_CACHE_MAX) { + // Map iteration is insertion-order; the first key is the oldest. + const oldest = hlCache.keys().next().value; + if (oldest === undefined) break; + hlCache.delete(oldest); + } +} + // highlightToHtml returns highlighted HTML (token s) for the given code, or // escaped plain text when the language is unknown. ignoreIllegals so partial / -// streaming snippets never throw. +// streaming snippets never throw. The LRU cache shaves the bulk of the work +// when a transcript re-renders the same blocks (most common: a streaming +// update changes the *next* block, not this one). export function highlightToHtml(code: string, lang?: string): string { const resolved = resolveLang(lang); if (!resolved) return escapeHtml(code); + const cached = cacheGet(code, resolved); + if (cached !== null) return cached; + let html: string; try { - return hljs.highlight(code, { language: resolved, ignoreIllegals: true }).value; + html = hljs.highlight(code, { language: resolved, ignoreIllegals: true }).value; } catch { return escapeHtml(code); } + cachePut(code, resolved, html); + return html; } From 28f10a99bfe06976180b78fdc121fa84a49b8584 Mon Sep 17 00:00:00 2001 From: HUQIANTAO Date: Fri, 5 Jun 2026 00:15:44 -0700 Subject: [PATCH 3/6] perf(desktop): debounce SlashArgs IPC by 120ms --- desktop/frontend/src/components/Composer.tsx | 45 +++++++++++--------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/desktop/frontend/src/components/Composer.tsx b/desktop/frontend/src/components/Composer.tsx index 8ccfd6578..332c1dccc 100644 --- a/desktop/frontend/src/components/Composer.tsx +++ b/desktop/frontend/src/components/Composer.tsx @@ -223,34 +223,41 @@ export function Composer({ // --- slash argument completion ("/cmd ") --- mirrors the CLI: once past // the command word, the backend suggests sub-commands (/skill → list/show/…, - // /mcp → add/remove, /model → refs). Fetched from app.SlashArgs. + // /mcp → add/remove, /model → refs). Fetched from app.SlashArgs. Debounced + // by 120ms so rapid typing doesn't flood the backend with IPC calls — the + // menu only updates after the user pauses. const [argRes, setArgRes] = useState(null); + const debounceRef = useRef>(); useEffect(() => { if (!text.startsWith("/") || !/\s/.test(text)) { setArgRes(null); return; } let live = true; - app - .SlashArgs(text) - .then((r) => { - if (!live) return; - // Drop suggestions that wouldn't change the input — the token is already - // fully typed (e.g. "/skill list" offering "list"). Otherwise the menu - // lingers on a complete command and Enter keeps "accepting" a no-op - // instead of sending. (Defense-in-depth: the backend filters these too.) - // r.items can arrive as null (an empty Go slice serializes to JSON null), - // so guard before filtering — otherwise the throw is swallowed and the - // stale menu from the previous keystroke lingers (the /skill list bug). - const items = asArray(r?.items); - const from = r?.from ?? 0; - const useful = items.filter((it) => text.slice(0, from) + it.insert !== text); - setArgRes(useful.length > 0 ? { items: useful, from } : null); - setActive(0); - }) - .catch(() => {}); + clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + app + .SlashArgs(text) + .then((r) => { + if (!live) return; + // Drop suggestions that wouldn't change the input — the token is already + // fully typed (e.g. "/skill list" offering "list"). Otherwise the menu + // lingers on a complete command and Enter keeps "accepting" a no-op + // instead of sending. (Defense-in-depth: the backend filters these too.) + // r.items can arrive as null (an empty Go slice serializes to JSON null), + // so guard before filtering — otherwise the throw is swallowed and the + // stale menu from the previous keystroke lingers (the /skill list bug). + const items = asArray(r?.items); + const from = r?.from ?? 0; + const useful = items.filter((it) => text.slice(0, from) + it.insert !== text); + setArgRes(useful.length > 0 ? { items: useful, from } : null); + setActive(0); + }) + .catch(() => {}); + }, 120); return () => { live = false; + clearTimeout(debounceRef.current); }; }, [text]); From f059d712048aac1f3fd104b82316c592f2124bef Mon Sep 17 00:00:00 2001 From: HUQIANTAO Date: Fri, 5 Jun 2026 00:15:45 -0700 Subject: [PATCH 4/6] perf(desktop): use useDeferredValue in Markdown to avoid blocking on long renders --- desktop/frontend/src/components/Markdown.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/desktop/frontend/src/components/Markdown.tsx b/desktop/frontend/src/components/Markdown.tsx index 594d4a77e..e7881aae9 100644 --- a/desktop/frontend/src/components/Markdown.tsx +++ b/desktop/frontend/src/components/Markdown.tsx @@ -1,3 +1,4 @@ +import { useDeferredValue } from "react"; import ReactMarkdown from "react-markdown"; import type { Components } from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -58,10 +59,16 @@ function normalizeMath(s: string): string { } export function Markdown({ text }: { text: string }) { + // useDeferredValue lets React prioritise the plain-text streaming frame over + // the expensive markdown parse+render. If a new text delta arrives while the + // markdown tree is still diffing, React can abort the in-progress render and + // start fresh with the latest text — keeping the UI responsive during the + // final markdown pass on long responses. + const deferred = useDeferredValue(text); return (
- {normalizeMath(text)} + {normalizeMath(deferred)}
); From 769f3f51881a2f9109f6da20be09a461aaa2be11 Mon Sep 17 00:00:00 2001 From: HUQIANTAO Date: Fri, 5 Jun 2026 00:17:51 -0700 Subject: [PATCH 5/6] perf(desktop): use useDeferredValue for Transcript items --- desktop/frontend/src/App.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx index a4c58dd6d..f6fb79e3e 100644 --- a/desktop/frontend/src/App.tsx +++ b/desktop/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties, KeyboardEvent, PointerEvent as ReactPointerEvent } from "react"; import { ShellExpandProvider, useShellExpand } from "./lib/shellExpand"; import { @@ -506,6 +506,12 @@ export default function App() { return completedToolsAfter >= 2 || finalAssistantAfter || readinessNoticeAfter || staleByTime; }, [showTodos, state.items, state.running, todoEntry, todoNow]); + // useDeferredValue lets React prioritise Composer input (high-priority) over + // Transcript re-renders (low-priority) during streaming. When a keystroke + // and a transcript update collide, the keystroke is processed immediately + // and the transcript re-render is deferred to idle time. + const deferredItems = useDeferredValue(state.items); + useEffect(() => { if (!pendingPlanRevision || state.running) return; const text = pendingPlanRevision; @@ -1354,7 +1360,7 @@ export default function App() { ) : ( Date: Fri, 5 Jun 2026 00:22:23 -0700 Subject: [PATCH 6/6] build(desktop): add terser dependency for the production minifier #2954 set vite minify to terser but terser is an optional dep since Vite 3; add it so the production build doesn't fail with 'terser not found'. The mdast-util-gfm-autolink-literal 2.0.0 pin (macOS 12 fix) is preserved. --- desktop/frontend/package.json | 1 + desktop/frontend/pnpm-lock.yaml | 65 ++++++++++++++++++++++++++++++--- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json index 9495ce462..50e4e2d15 100644 --- a/desktop/frontend/package.json +++ b/desktop/frontend/package.json @@ -25,6 +25,7 @@ "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", + "terser": "^5.48.0", "typescript": "^5.6.3", "vite": "^6.0.7" }, diff --git a/desktop/frontend/pnpm-lock.yaml b/desktop/frontend/pnpm-lock.yaml index 7aa4951f0..9f424dadb 100644 --- a/desktop/frontend/pnpm-lock.yaml +++ b/desktop/frontend/pnpm-lock.yaml @@ -47,13 +47,16 @@ importers: version: 18.3.7(@types/react@18.3.29) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.4.2) + version: 4.7.0(vite@6.4.2(terser@5.48.0)) + terser: + specifier: ^5.48.0 + version: 5.48.0 typescript: specifier: ^5.6.3 version: 5.9.3 vite: specifier: ^6.0.7 - version: 6.4.2 + version: 6.4.2(terser@5.48.0) packages: @@ -306,6 +309,9 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -499,6 +505,11 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -512,6 +523,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + caniuse-lite@1.0.30001793: resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} @@ -533,6 +547,9 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + commander@8.3.0: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} @@ -921,6 +938,13 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} @@ -933,6 +957,11 @@ packages: style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + terser@5.48.0: + resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} + engines: {node: '>=10'} + hasBin: true + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} @@ -1240,6 +1269,11 @@ snapshots: '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/trace-mapping@0.3.31': @@ -1384,7 +1418,7 @@ snapshots: '@ungap/structured-clone@1.3.1': {} - '@vitejs/plugin-react@4.7.0(vite@6.4.2)': + '@vitejs/plugin-react@4.7.0(vite@6.4.2(terser@5.48.0))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) @@ -1392,10 +1426,12 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.2 + vite: 6.4.2(terser@5.48.0) transitivePeerDependencies: - supports-color + acorn@8.16.0: {} + bail@2.0.2: {} baseline-browser-mapping@2.10.32: {} @@ -1408,6 +1444,8 @@ snapshots: node-releases: 2.0.46 update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer-from@1.1.2: {} + caniuse-lite@1.0.30001793: {} ccount@2.0.1: {} @@ -1422,6 +1460,8 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@2.20.3: {} + commander@8.3.0: {} convert-source-map@2.0.0: {} @@ -2141,6 +2181,13 @@ snapshots: source-map-js@1.2.1: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + space-separated-tokens@2.0.2: {} stringify-entities@4.0.4: @@ -2156,6 +2203,13 @@ snapshots: dependencies: inline-style-parser: 0.2.7 + terser@5.48.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.16.0 + commander: 2.20.3 + source-map-support: 0.5.21 + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -2231,7 +2285,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@6.4.2: + vite@6.4.2(terser@5.48.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -2241,6 +2295,7 @@ snapshots: tinyglobby: 0.2.16 optionalDependencies: fsevents: 2.3.3 + terser: 5.48.0 web-namespaces@2.0.1: {}