diff --git a/.jules/bolt.md b/.jules/bolt.md index 3520a4c..9228b15 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -57,3 +57,7 @@ ## 2024-05-09 - Avoid O(N) array allocation in loops **Learning:** Using `[...updated].reverse().findIndex(...)` in frequent event callbacks (like stream_text in React) forces O(N) memory allocation and O(N) forward searching each tick, causing major garbage collection pauses. `findLastIndex` is not supported on ES2022 output targets. **Action:** Replace functional backwards searches that depend on clones with traditional reverse `for` loops to find items instantly in O(1) time without allocations. + +## 2026-05-19 - Top-Level Interval Anti-Pattern (SpinnerVerb) +**Learning:** Storing a slow interval state like `spinnerVerb` (every 2.4s) at the top-level `App` component still forces a full application tree O(N) cascade re-render every tick. Even if it is not as fast as a 250ms timer, it causes noticeable micro-stuttering during UI interactions and when large lists are rendered because it triggers diffs across the entire application including Heavy `ChatPanel` and `TabbedRightPanel`. +**Action:** Always extract even slow interval-driven decorative states into isolated leaf components (like `SpinnerVerbDisplay`) wrapped in `React.memo()`. Removing the prop-drilling entirely isolates the re-renders specifically to the UI span rendering the changing string. diff --git a/packages/desktop/src/renderer/App.tsx b/packages/desktop/src/renderer/App.tsx index 8cb23cf..56c0a3e 100644 --- a/packages/desktop/src/renderer/App.tsx +++ b/packages/desktop/src/renderer/App.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect, useRef } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { PanelLeft, PanelRight, Settings, Command } from 'lucide-react'; import type { HostedAgentEvent, ModeState } from '../preload/index'; import ChatPanel from './components/ChatPanel'; @@ -27,13 +27,6 @@ function uid(): string { return Date.now().toString(36) + Math.random().toString(36).slice(2); } -const SPINNER_VERBS = [ - 'Thinking', 'Analyzing', 'Processing', 'Writing code', 'Reading files', - 'Running commands', 'Searching', 'Debugging', 'Building', 'Testing', - 'Reviewing', 'Planning', 'Implementing', 'Refactoring', 'Optimizing', - 'Generating', 'Compiling', 'Deploying', 'Cooking', 'Locked in', -]; - export default function App() { const [messages, setMessages] = useState([]); const [modeState, setModeState] = useState({ @@ -52,12 +45,10 @@ export default function App() { const [needsSetup, setNeedsSetup] = useState(false); const [showSetup, setShowSetup] = useState(false); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); - const [spinnerVerb, setSpinnerVerb] = useState(''); const [leftPanelOpen, setLeftPanelOpen] = useState(true); const [rightPanelOpen, setRightPanelOpen] = useState(false); const [costMode, setCostMode] = useState('normal'); const [activeSessionId, setActiveSessionId] = useState(); - const spinnerIndexRef = useRef(0); useEffect(() => { (async () => { @@ -83,17 +74,6 @@ export default function App() { })(); }, []); - useEffect(() => { - if (!isRunning) return; - spinnerIndexRef.current = Math.floor(Math.random() * SPINNER_VERBS.length); - setSpinnerVerb(SPINNER_VERBS[spinnerIndexRef.current]); - const id = setInterval(() => { - spinnerIndexRef.current = (spinnerIndexRef.current + 1) % SPINNER_VERBS.length; - setSpinnerVerb(SPINNER_VERBS[spinnerIndexRef.current]); - }, 2400); - return () => clearInterval(id); - }, [isRunning]); - const handleAgentEvents = useCallback((batch: HostedAgentEvent[]) => { setMessages((prev) => { const updated = [...prev]; @@ -419,7 +399,6 @@ export default function App() { onSendMessage={handleSendMessage} onCommand={handleCommand} isRunning={isRunning} - spinnerVerb={spinnerVerb} activeModel={activeModel} activeProvider={activeProvider} wireFormat={wireFormat} @@ -451,7 +430,6 @@ export default function App() { mode={modeState.current} workingDir={workingDir} isRunning={isRunning} - spinnerVerb={spinnerVerb} activeModel={activeModel} activeProvider={activeProvider} wireFormat={wireFormat} diff --git a/packages/desktop/src/renderer/components/ChatPanel.tsx b/packages/desktop/src/renderer/components/ChatPanel.tsx index a4181c6..cc5c0f8 100644 --- a/packages/desktop/src/renderer/components/ChatPanel.tsx +++ b/packages/desktop/src/renderer/components/ChatPanel.tsx @@ -6,6 +6,7 @@ import MessageBubble from './MessageBubble'; import ToolCallCard from './ToolCallCard'; import { cn } from '../lib/utils'; import { useRunElapsed } from '../hooks/useRunElapsed'; +import SpinnerVerbDisplay from './SpinnerVerbDisplay'; const ChatPanelTimer = memo(function ChatPanelTimer({ isRunning }: { isRunning: boolean }) { const runElapsed = useRunElapsed(isRunning); @@ -41,7 +42,6 @@ interface ChatPanelProps { onSendMessage: (content: string) => void; onCommand: (cmd: string, arg?: string) => void; isRunning: boolean; - spinnerVerb: string; activeModel: string; activeProvider: string; wireFormat: string; @@ -54,7 +54,7 @@ interface ChatPanelProps { } export default function ChatPanel({ - messages, onSendMessage, onCommand, isRunning, spinnerVerb, + messages, onSendMessage, onCommand, isRunning, activeModel, activeProvider, wireFormat, appVersion, needsSetup, onOpenSetup, onOpenCommandPalette, modeState, onModeSwitch, }: ChatPanelProps) { @@ -177,7 +177,7 @@ export default function ChatPanel({ - {spinnerVerb} + )} diff --git a/packages/desktop/src/renderer/components/SpinnerVerbDisplay.tsx b/packages/desktop/src/renderer/components/SpinnerVerbDisplay.tsx new file mode 100644 index 0000000..a092556 --- /dev/null +++ b/packages/desktop/src/renderer/components/SpinnerVerbDisplay.tsx @@ -0,0 +1,36 @@ +import { useState, useEffect, useRef, memo } from 'react'; +import { cn } from '../lib/utils'; + +const SPINNER_VERBS = [ + 'Thinking', 'Analyzing', 'Processing', 'Writing code', 'Reading files', + 'Running commands', 'Searching', 'Debugging', 'Building', 'Testing', + 'Reviewing', 'Planning', 'Implementing', 'Refactoring', 'Optimizing', + 'Generating', 'Compiling', 'Deploying', 'Cooking', 'Locked in', +]; + +interface SpinnerVerbDisplayProps { + isRunning: boolean; + className?: string; +} + +const SpinnerVerbDisplay = memo(function SpinnerVerbDisplay({ isRunning, className }: SpinnerVerbDisplayProps) { + const [spinnerVerb, setSpinnerVerb] = useState(''); + const spinnerIndexRef = useRef(0); + + useEffect(() => { + if (!isRunning) return; + spinnerIndexRef.current = Math.floor(Math.random() * SPINNER_VERBS.length); + setSpinnerVerb(SPINNER_VERBS[spinnerIndexRef.current]); + const id = setInterval(() => { + spinnerIndexRef.current = (spinnerIndexRef.current + 1) % SPINNER_VERBS.length; + setSpinnerVerb(SPINNER_VERBS[spinnerIndexRef.current]); + }, 2400); + return () => clearInterval(id); + }, [isRunning]); + + if (!isRunning) return null; + + return {spinnerVerb}; +}); + +export default SpinnerVerbDisplay; diff --git a/packages/desktop/src/renderer/components/StatusBar.tsx b/packages/desktop/src/renderer/components/StatusBar.tsx index aab1932..e3c99b1 100644 --- a/packages/desktop/src/renderer/components/StatusBar.tsx +++ b/packages/desktop/src/renderer/components/StatusBar.tsx @@ -1,5 +1,6 @@ import { memo } from 'react'; import { useRunElapsed } from '../hooks/useRunElapsed'; +import SpinnerVerbDisplay from './SpinnerVerbDisplay'; const StatusBarTimer = memo(function StatusBarTimer({ isRunning }: { isRunning: boolean }) { const runElapsed = useRunElapsed(isRunning); @@ -16,7 +17,6 @@ interface Props { mode: string; workingDir: string; isRunning: boolean; - spinnerVerb: string; activeModel: string; activeProvider: string; wireFormat: string; @@ -33,7 +33,7 @@ const MODE_COLORS: Record = { engineer: 'bg-green-400', data: 'bg-cyan-400', researcher: 'bg-pink-300', }; -export default function StatusBar({ mode, workingDir, isRunning, spinnerVerb, activeModel, activeProvider, wireFormat, costMode, onToggleSidebar, onTogglePreview }: Props) { +export default function StatusBar({ mode, workingDir, isRunning, activeModel, activeProvider, wireFormat, costMode, onToggleSidebar, onTogglePreview }: Props) { const dot = MODE_COLORS[mode] ?? 'bg-zinc-400'; return ( @@ -55,7 +55,7 @@ export default function StatusBar({ mode, workingDir, isRunning, spinnerVerb, ac {isRunning && ( <> | - {spinnerVerb} + )}