Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Date inconsistency in learning entry.

The learning entry is dated 2025-02-23, but this PR was created on 2026-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
-## 2025-02-23 - Memoizing List Items Requires Stable Callback Props
+## 2026-05-19 - Memoizing List Items Requires Stable Callback Props
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
## 2025-02-23 - Memoizing List Items Requires Stable Callback Props
## 2026-05-19 - Memoizing List Items Requires Stable Callback Props
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.jules/bolt.md at line 70, Update the dated heading "## 2025-02-23 -
Memoizing List Items Requires Stable Callback Props" so the date matches when
this learning was derived (e.g., change 2025-02-23 to 2026-05-19 or 2026-05),
ensuring the entry's heading reflects the correct timestamp for the current PR;
locate and edit that exact header line in .jules/bolt.md to replace the old date
with the correct 2026 date.

**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.
128 changes: 83 additions & 45 deletions packages/desktop/src/renderer/components/ChatHistory.tsx
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';

Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

cat -n packages/desktop/src/renderer/components/ChatHistory.tsx | head -80

Repository: 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 <span> nested inside the row <button>. This creates two accessibility issues: (1) invalid HTML semantics (interactive elements should not nest), and (2) keyboard users cannot access delete since the span lacks keyboard event handlers. Please restructure the row as a <div role="button"> with keyboard support, and render delete as a proper <button> element.

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/desktop/src/renderer/components/ChatHistory.tsx` around lines 59 -
65, The delete control is an interactive <span> nested inside a row <button>,
causing invalid semantics and no keyboard access; update the row element used in
ChatHistory (the parent that currently renders the session row as a <button>) to
be a non-nested interactive container (e.g., <div role="button">) that
implements keyboard handlers (onKeyDown handling Enter/Space to invoke the row
activation function), and replace the delete <span> (the element that calls
onDelete with session.id and renders <Trash2 />) with a real <button> element
that has its own onClick, onKeyDown support, an accessible aria-label/title, and
calls e.stopPropagation() to avoid activating the row when deleting. Ensure
onDelete(session.id) still receives session.id and that the new button is
focusable via keyboard.


{/* ⚡ 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[]>([]);

Expand All @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Time-bucket grouping can go stale after date rollover.

At Line 90, groups only depends on sessions, so “Today/Yesterday/This Week” may remain wrong after midnight until sessions change. Include a day key dependency (or a midnight timer tick) so buckets refresh when the date changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/desktop/src/renderer/components/ChatHistory.tsx` around lines 90 -
123, The time-bucket grouping computed in the useMemo for `groups` only depends
on `sessions`, so labels like "Today/Yesterday/This Week" won't update after
midnight until `sessions` changes; fix it by adding a date-based dependency
(e.g., compute a `dayKey = new Date().setHours(0,0,0,0)` or a `currentDay` state
updated at midnight) and include that `dayKey`/`currentDay` in the useMemo
dependency array alongside `sessions` so the grouping recomputes on date
rollover; update the `groups` useMemo and the surrounding logic that defines
`today`, `yesterday`, and `weekAgo` to use the same `dayKey` reference.

return (
<div className="flex h-full flex-col">
Expand All @@ -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>
Expand Down
6 changes: 6 additions & 0 deletions plan_review_request.md
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".