-
Notifications
You must be signed in to change notification settings - Fork 2
⚡ Bolt: [performance improvement] optimize chat history rendering #95
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <button | ||
| onClick={() => onSelect(session.id)} | ||
| className={cn( | ||
| "flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left transition-all duration-200 group", | ||
| isActive | ||
| ? "text-xibe-text font-semibold bg-xibe-surface-raised" | ||
| : "text-xibe-text-secondary hover:bg-xibe-surface-raised/50 hover:text-xibe-text" | ||
| )} | ||
| > | ||
| <MessageSquare className={cn("h-3.5 w-3.5 shrink-0", isActive ? "text-xibe-text" : "text-xibe-text-dim/40 group-hover:text-xibe-text-dim/70")} /> | ||
| <span className="flex-1 truncate text-[12px] font-medium leading-tight">{session.title}</span> | ||
|
|
||
| {/* ⚡ Bolt: Used CSS group-hover instead of React state to toggle delete button visibility to prevent O(N) re-renders of the entire list on hover */} | ||
| <span | ||
| onClick={(e) => onDelete(e, session.id)} | ||
| className="shrink-0 rounded p-1 text-xibe-text-dim/50 hover:text-xibe-error hover:bg-xibe-error/10 transition-colors animate-fade-in hidden group-hover:block" | ||
| title="Delete chat" | ||
| > | ||
| <Trash2 className="h-3.5 w-3.5" /> | ||
| </span> | ||
|
Comment on lines
+59
to
+65
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: cat -n packages/desktop/src/renderer/components/ChatHistory.tsx | head -80Repository: iotserver24/Xibecode Length of output: 3460 Delete action is not keyboard-accessible and is nested in an interactive control. At line 59, the delete affordance is a clickable Suggested structure change- <button
+ <div
onClick={() => onSelect(session.id)}
className={cn(
"flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left transition-all duration-200 group",
isActive
? "text-xibe-text font-semibold bg-xibe-surface-raised"
: "text-xibe-text-secondary hover:bg-xibe-surface-raised/50 hover:text-xibe-text"
)}
+ role="button"
+ tabIndex={0}
+ onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onSelect(session.id)}
- >
+ >
<MessageSquare className={cn("h-3.5 w-3.5 shrink-0", isActive ? "text-xibe-text" : "text-xibe-text-dim/40 group-hover:text-xibe-text-dim/70")} />
<span className="flex-1 truncate text-[12px] font-medium leading-tight">{session.title}</span>
- <span
+ <button
+ type="button"
onClick={(e) => onDelete(e, session.id)}
className="shrink-0 rounded p-1 text-xibe-text-dim/50 hover:text-xibe-error hover:bg-xibe-error/10 transition-colors animate-fade-in hidden group-hover:block"
title="Delete chat"
+ aria-label={`Delete chat ${session.title}`}
>
<Trash2 className="h-3.5 w-3.5" />
- </span>
+ </button>
<span className="shrink-0 text-[10px] text-xibe-text-dim/40 tabular-nums block group-hover:hidden">{relativeTime(session.updated)}</span>
- </button>
+ </div>🤖 Prompt for AI Agents |
||
|
|
||
| {/* ⚡ Bolt: Hide timestamp on hover using group-hover to make room for delete button without React state overhead */} | ||
| <span className="shrink-0 text-[10px] text-xibe-text-dim/40 tabular-nums block group-hover:hidden">{relativeTime(session.updated)}</span> | ||
| </button> | ||
|
Comment on lines
+46
to
+69
|
||
| ); | ||
| }); | ||
|
|
||
| const ChatHistory = memo(function ChatHistory({ activeSessionId, onSelectSession, onNewChat }: ChatHistoryProps) { | ||
| const [sessions, setSessions] = useState<SessionItem[]>([]); | ||
|
|
||
|
|
@@ -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); | ||
|
Comment on lines
+89
to
+96
|
||
|
|
||
| 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]); | ||
|
|
||
|
Comment on lines
+90
to
123
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Time-bucket grouping can go stale after date rollover. At Line 90, 🤖 Prompt for AI Agents |
||
| return ( | ||
| <div className="flex h-full flex-col"> | ||
|
|
@@ -89,31 +145,13 @@ const ChatHistory = memo(function ChatHistory({ activeSessionId, onSelectSession | |
| <div className="mb-2 px-1 text-[10px] font-bold uppercase tracking-widest text-xibe-text-dim/50">{group.label}</div> | ||
| <div className="space-y-0.5"> | ||
| {group.items.map((s) => ( | ||
| <button | ||
| <ChatHistoryItem | ||
| key={s.id} | ||
| onClick={() => onSelectSession(s.id)} | ||
| className={cn( | ||
| "flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left transition-all duration-200 group", | ||
| activeSessionId === s.id | ||
| ? "text-xibe-text font-semibold bg-xibe-surface-raised" | ||
| : "text-xibe-text-secondary hover:bg-xibe-surface-raised/50 hover:text-xibe-text" | ||
| )} | ||
| > | ||
| <MessageSquare className={cn("h-3.5 w-3.5 shrink-0", activeSessionId === s.id ? "text-xibe-text" : "text-xibe-text-dim/40 group-hover:text-xibe-text-dim/70")} /> | ||
| <span className="flex-1 truncate text-[12px] font-medium leading-tight">{s.title}</span> | ||
|
|
||
| {/* ⚡ Bolt: Used CSS group-hover instead of React state to toggle delete button visibility to prevent O(N) re-renders of the entire list on hover */} | ||
| <span | ||
| onClick={(e) => handleDelete(e, s.id)} | ||
| className="shrink-0 rounded p-1 text-xibe-text-dim/50 hover:text-xibe-error hover:bg-xibe-error/10 transition-colors animate-fade-in hidden group-hover:block" | ||
| title="Delete chat" | ||
| > | ||
| <Trash2 className="h-3.5 w-3.5" /> | ||
| </span> | ||
|
|
||
| {/* ⚡ Bolt: Hide timestamp on hover using group-hover to make room for delete button without React state overhead */} | ||
| <span className="shrink-0 text-[10px] text-xibe-text-dim/40 tabular-nums block group-hover:hidden">{relativeTime(s.updated)}</span> | ||
| </button> | ||
| session={s} | ||
| isActive={activeSessionId === s.id} | ||
| onSelect={onSelectSession} | ||
| onDelete={handleDelete} | ||
| /> | ||
| ))} | ||
| </div> | ||
| </div> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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". |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Date inconsistency in learning entry.
The learning entry is dated
2025-02-23, but this PR was created on2026-05-19(~15 months later). If this learning was derived from the current work, the date should be updated to reflect when it was actually learned (likely 2026-05).📅 Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents