diff --git a/.jules/bolt.md b/.jules/bolt.md index 427be48..88f0402 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -67,3 +67,6 @@ ## 2025-05-17 - Replace O(N²) array search with O(N) Set lookup in session listing **Learning:** Using `array.some(...)` with string operations (like `replace()`) inside a loop over files creates an unnecessary O(N²) performance bottleneck, particularly when reading directories with many files (like the sessions history directory). **Action:** When filtering files against another list of files, pre-compute a `Set` of base names outside the loop for O(1) lookups, changing the overall time complexity from O(N²) to O(N). +## 2025-02-23 - Memoizing List Items Requires Stable Callback Props +**Learning:** Extracting an inline list item into a `React.memo` component (like `ChatHistoryItem`) to prevent O(N) VDOM re-renders is completely ineffective if the event handler props (like `onDelete`) are not wrapped in `useCallback` in the parent component. Without `useCallback`, the parent creates a new function reference on every render, causing `React.memo`'s shallow comparison to fail and re-render every item anyway. +**Action:** When extracting React components into `React.memo()` to prevent unnecessary re-renders (e.g., list items), ensure all functions passed as props (such as event handlers) are wrapped in `useCallback` in the parent component to maintain referential equality across renders. diff --git a/packages/desktop/src/renderer/components/ChatHistory.tsx b/packages/desktop/src/renderer/components/ChatHistory.tsx index 88865ab..6493e85 100644 --- a/packages/desktop/src/renderer/components/ChatHistory.tsx +++ b/packages/desktop/src/renderer/components/ChatHistory.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, memo } from 'react'; +import { useState, useEffect, useCallback, useMemo, memo } from 'react'; import { Plus, MessageSquare, Trash2 } from 'lucide-react'; import { cn } from '../lib/utils'; @@ -31,6 +31,45 @@ function relativeTime(iso: string): string { return new Date(iso).toLocaleDateString(); } +const ChatHistoryItem = memo(function ChatHistoryItem({ + session, + isActive, + onSelect, + onDelete, +}: { + session: SessionItem; + isActive: boolean; + onSelect: (id: string) => void; + onDelete: (e: React.MouseEvent, id: string) => void; +}) { + return ( + + ); +}); + const ChatHistory = memo(function ChatHistory({ activeSessionId, onSelectSession, onNewChat }: ChatHistoryProps) { const [sessions, setSessions] = useState([]); @@ -41,29 +80,46 @@ const ChatHistory = memo(function ChatHistory({ activeSessionId, onSelectSession useEffect(() => { refresh(); }, [refresh]); - const handleDelete = async (e: React.MouseEvent, id: string) => { + const handleDelete = useCallback(async (e: React.MouseEvent, id: string) => { e.stopPropagation(); await xibe.session.delete(id); refresh(); - }; - - const today = new Date(); - today.setHours(0, 0, 0, 0); - const yesterday = new Date(today); - yesterday.setDate(yesterday.getDate() - 1); - const weekAgo = new Date(today); - weekAgo.setDate(weekAgo.getDate() - 7); - - const groups: { label: string; items: SessionItem[] }[] = []; - const todayItems = sessions.filter((s) => new Date(s.updated) >= today); - const yesterdayItems = sessions.filter((s) => { const d = new Date(s.updated); return d >= yesterday && d < today; }); - const weekItems = sessions.filter((s) => { const d = new Date(s.updated); return d >= weekAgo && d < yesterday; }); - const olderItems = sessions.filter((s) => new Date(s.updated) < weekAgo); - - if (todayItems.length) groups.push({ label: 'Today', items: todayItems }); - if (yesterdayItems.length) groups.push({ label: 'Yesterday', items: yesterdayItems }); - if (weekItems.length) groups.push({ label: 'This Week', items: weekItems }); - if (olderItems.length) groups.push({ label: 'Older', items: olderItems }); + }, [refresh]); + + // ⚡ Bolt: Memoize the grouping of sessions into an O(N) single pass to avoid O(4N) filtering and Date parsing on every render + const groups = useMemo(() => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const weekAgo = new Date(today); + weekAgo.setDate(weekAgo.getDate() - 7); + + const todayItems: SessionItem[] = []; + const yesterdayItems: SessionItem[] = []; + const weekItems: SessionItem[] = []; + const olderItems: SessionItem[] = []; + + for (const s of sessions) { + const d = new Date(s.updated); + if (d >= today) { + todayItems.push(s); + } else if (d >= yesterday) { + yesterdayItems.push(s); + } else if (d >= weekAgo) { + weekItems.push(s); + } else { + olderItems.push(s); + } + } + + const g: { label: string; items: SessionItem[] }[] = []; + if (todayItems.length) g.push({ label: 'Today', items: todayItems }); + if (yesterdayItems.length) g.push({ label: 'Yesterday', items: yesterdayItems }); + if (weekItems.length) g.push({ label: 'This Week', items: weekItems }); + if (olderItems.length) g.push({ label: 'Older', items: olderItems }); + return g; + }, [sessions]); return (
@@ -89,31 +145,13 @@ const ChatHistory = memo(function ChatHistory({ activeSessionId, onSelectSession
{group.label}
{group.items.map((s) => ( - + session={s} + isActive={activeSessionId === s.id} + onSelect={onSelectSession} + onDelete={handleDelete} + /> ))}
diff --git a/plan_review_request.md b/plan_review_request.md new file mode 100644 index 0000000..c2728f6 --- /dev/null +++ b/plan_review_request.md @@ -0,0 +1,6 @@ +I plan to optimize `packages/desktop/src/renderer/components/ChatHistory.tsx` to improve performance for long lists of chat sessions. + +1. **Memoize the grouping of sessions into an O(N) single pass.** Currently, it runs `sessions.filter` four times, resulting in an O(4N) operation that also instantiates `new Date(s.updated)` four times per item. I will replace this with a single pass using `useMemo` that evaluates each session's date once and pushes it to the correct group, preventing expensive recalculations on every render. +2. **Extract `ChatHistoryItem` as a `React.memo` component.** Currently, when `activeSessionId` changes, the entire list of `button` components re-renders because they are defined inline inside the mapping. By extracting the item to a `React.memo` component, only the previously active item and the newly active item will re-render, preventing O(N) cascading VDOM re-renders for unchanged chat history items. + +This optimization addresses "Missing memoization for expensive computations", "Inefficient algorithms (O(n²) that could be O(n))", and "Unnecessary re-renders in React components".