diff --git a/apps/desktop/src/app/chat/sidebar/sessions-section.test.tsx b/apps/desktop/src/app/chat/sidebar/sessions-section.test.tsx new file mode 100644 index 000000000000..47015dd80cfb --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/sessions-section.test.tsx @@ -0,0 +1,182 @@ +import { cleanup, render } from '@testing-library/react' +import type * as React from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { SessionInfo } from '@/hermes' +import { SidebarSessionsSection, VIRTUALIZE_THRESHOLD } from './sessions-section' +import type { VirtualSessionListProps } from './virtual-session-list' + +afterEach(cleanup) + +vi.mock('@/i18n', () => ({ + useI18n: () => ({ + t: { + sidebar: { + dateDivider: { + earlierThisMonth: 'Earlier this month', + lastMonth: 'Last month', + lastWeek: 'Last week', + older: 'Older', + today: 'Today', + yesterday: 'Yesterday' + } + } + } + }) +})) + +const mockVirtualListPropsHistory: VirtualSessionListProps[] = [] + +vi.mock('./virtual-session-list', () => ({ + VirtualSessionList: (props: VirtualSessionListProps) => { + mockVirtualListPropsHistory.push(props) + return
Virtual List ({props.rows.length} rows)
+ } +})) + +vi.mock('./session-row', () => ({ + SidebarSessionRow: ({ session }: { session: SessionInfo }) => ( +
{session.id}
+ ) +})) + +function makeSession(id: string, startedAt = 1000): SessionInfo { + return { + handoff_platform: null, + handoff_state: null, + id, + last_active: startedAt, + profile: 'default', + started_at: startedAt + } as unknown as SessionInfo +} + +function generateSessions(count: number): SessionInfo[] { + return Array.from({ length: count }, (_, i) => makeSession(`session-${i + 1}`, 10000 - i * 100)) +} + +const noop = () => {} + +describe('SidebarSessionsSection memoization & virtualizer stability', () => { + it('memoizes flatRows and passes the exact same rows array reference across parent re-renders', () => { + mockVirtualListPropsHistory.length = 0 + + const sessions = generateSessions(VIRTUALIZE_THRESHOLD + 5) + + const { rerender } = render( + Empty} + label="Sessions" + onArchiveSession={noop} + onDeleteSession={noop} + onResumeSession={noop} + onToggle={noop} + onTogglePin={noop} + open={true} + pinned={false} + sessions={sessions} + workingSessionIdSet={new Set()} + /> + ) + + expect(mockVirtualListPropsHistory.length).toBe(1) + const initialRowsRef = mockVirtualListPropsHistory[0].rows + expect(initialRowsRef.length).toBeGreaterThan(VIRTUALIZE_THRESHOLD) + + // Re-render parent with the exact same sessions array and props + rerender( + Empty} + label="Sessions" + onArchiveSession={noop} + onDeleteSession={noop} + onResumeSession={noop} + onToggle={noop} + onTogglePin={noop} + open={true} + pinned={false} + sessions={sessions} + workingSessionIdSet={new Set()} + /> + ) + + expect(mockVirtualListPropsHistory.length).toBe(2) + const nextRowsRef = mockVirtualListPropsHistory[1].rows + + // Confirm that the flatRows array reference remains strictly identical across renders (useMemo proof) + expect(nextRowsRef).toBe(initialRowsRef) + }) + + it('re-computes flatRows reference when dateGrouped or sessions change', () => { + mockVirtualListPropsHistory.length = 0 + + const initialSessions = generateSessions(VIRTUALIZE_THRESHOLD + 2) + + const { rerender } = render( + Empty} + label="Sessions" + onArchiveSession={noop} + onDeleteSession={noop} + onResumeSession={noop} + onToggle={noop} + onTogglePin={noop} + open={true} + pinned={false} + sessions={initialSessions} + workingSessionIdSet={new Set()} + /> + ) + + const firstRowsRef = mockVirtualListPropsHistory[0].rows + + // Change dateGrouped to true + rerender( + Empty} + label="Sessions" + onArchiveSession={noop} + onDeleteSession={noop} + onResumeSession={noop} + onToggle={noop} + onTogglePin={noop} + open={true} + pinned={false} + sessions={initialSessions} + workingSessionIdSet={new Set()} + /> + ) + + const secondRowsRef = mockVirtualListPropsHistory[1].rows + expect(secondRowsRef).not.toBe(firstRowsRef) + + // Change sessions array identity + const updatedSessions = generateSessions(VIRTUALIZE_THRESHOLD + 4) + rerender( + Empty} + label="Sessions" + onArchiveSession={noop} + onDeleteSession={noop} + onResumeSession={noop} + onToggle={noop} + onTogglePin={noop} + open={true} + pinned={false} + sessions={updatedSessions} + workingSessionIdSet={new Set()} + /> + ) + + const thirdRowsRef = mockVirtualListPropsHistory[2].rows + expect(thirdRowsRef).not.toBe(secondRowsRef) + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx index 35174a8370e6..95cbe9bf0daa 100644 --- a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx +++ b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx @@ -1,6 +1,6 @@ import type { useSensors } from '@dnd-kit/core' import type * as React from 'react' -import { useMemo } from 'react' +import { useCallback, useMemo } from 'react' import { SidebarPanelLabel } from '@/app/shell/sidebar-label' import { DisclosureCaret } from '@/components/ui/disclosure-caret' @@ -225,52 +225,77 @@ export function SidebarSessionsSection({ [sessions, preserveInputOrder] ) - const renderRow = (session: SessionInfo, draggable: boolean, branchStem?: string) => { - const rowProps = { - branchStem, - isPinned: pinned, - isSelected: session.id === activeSessionId, - isWorking: workingSessionIdSet.has(session.id), - onArchive: () => onArchiveSession(session.id), - onBranch: onBranchSession ? () => onBranchSession(session.id, session.profile) : undefined, - onDelete: () => onDeleteSession(session.id), - onPin: () => onTogglePin(sessionPinId(session)), - onResume: () => onResumeSession(session.id), - reorderable: draggable && !branchStem, - session, - showProfile: showProfileTags - } - - return draggable && !branchStem ? ( - - ) : ( - - ) - } + const renderRow = useCallback( + (session: SessionInfo, draggable: boolean, branchStem?: string) => { + const rowProps = { + branchStem, + isPinned: pinned, + isSelected: session.id === activeSessionId, + isWorking: workingSessionIdSet.has(session.id), + onArchive: () => onArchiveSession(session.id), + onBranch: onBranchSession ? () => onBranchSession(session.id, session.profile) : undefined, + onDelete: () => onDeleteSession(session.id), + onPin: () => onTogglePin(sessionPinId(session)), + onResume: () => onResumeSession(session.id), + reorderable: draggable && !branchStem, + session, + showProfile: showProfileTags + } + + return draggable && !branchStem ? ( + + ) : ( + + ) + }, + [ + activeSessionId, + onArchiveSession, + onBranchSession, + onDeleteSession, + onResumeSession, + onTogglePin, + pinned, + showProfileTags, + workingSessionIdSet + ] + ) // A single flat/virtual/lane list row — either a date divider or a session. - const renderListRow = (row: SidebarListRow, draggable: boolean) => - row.kind === 'divider' ? ( - - ) : ( - renderRow(row.entry.session, draggable, row.entry.branchStem) - ) + const renderListRow = useCallback( + (row: SidebarListRow, draggable: boolean) => + row.kind === 'divider' ? ( + + ) : ( + renderRow(row.entry.session, draggable, row.entry.branchStem) + ), + [dividerLabels, renderRow] + ) // Sessions inside repos/worktrees are date-ordered and static. - const renderRows = (items: SessionInfo[]) => - flattenSessionsWithBranches(items).map(({ branchStem, session }) => renderRow(session, false, branchStem)) + const renderRows = useCallback( + (items: SessionInfo[]) => + flattenSessionsWithBranches(items).map(({ branchStem, session }) => renderRow(session, false, branchStem)), + [renderRow] + ) // Same as `renderRows`, but with date dividers folded in — used for // entered-project lanes so a lane spanning multiple days reads // chronologically, matching the flat recents list. - const renderRowsDated = (items: SessionInfo[]) => { - const entries = flattenSessionsWithBranches(items) + const renderRowsDated = useCallback( + (items: SessionInfo[]) => { + const entries = flattenSessionsWithBranches(items) - return (dateGrouped ? groupEntriesByRecency(entries) : toSessionRows(entries)).map(row => renderListRow(row, false)) - } + return (dateGrouped ? groupEntriesByRecency(entries) : toSessionRows(entries)).map(row => renderListRow(row, false)) + }, + [dateGrouped, renderListRow] + ) // Flat recents as list rows: grouped by recency when enabled, plain otherwise. - const flatRows: SidebarListRow[] = dateGrouped ? groupEntriesByRecency(displayEntries) : toSessionRows(displayEntries) + const flatRows: SidebarListRow[] = useMemo( + () => (dateGrouped ? groupEntriesByRecency(displayEntries) : toSessionRows(displayEntries)), + [dateGrouped, displayEntries] + ) const flatVirtualized = !showEmptyState && diff --git a/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx b/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx index d9cfd6c00438..90e10ddc38d0 100644 --- a/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx +++ b/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx @@ -27,7 +27,7 @@ interface SessionRowCommonProps { showProfile?: boolean } -interface VirtualSessionListProps { +export interface VirtualSessionListProps { activeSessionId: null | string className?: string rows: SidebarListRow[]