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
182 changes: 182 additions & 0 deletions apps/desktop/src/app/chat/sidebar/sessions-section.test.tsx
Original file line number Diff line number Diff line change
@@ -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: {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

VirtualSessionListProps is not exported by virtual-session-list.tsx (it is a module-private interface at line 30), so this import fails TypeScript checking. Please use a local minimal mock-prop shape containing rows, or deliberately export the interface.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the detailed review @teknium1!

I've addressed both feedback points in the latest commit:

  1. Exported VirtualSessionListProps interface in virtual-session-list.tsx so the import passes TypeScript checks cleanly.
  2. Updated sessions-section.test.tsx to explicitly test changing the sessions array identity in addition to dateGrouped.

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 <div data-testid="virtual-session-list">Virtual List ({props.rows.length} rows)</div>
}
}))

vi.mock('./session-row', () => ({
SidebarSessionRow: ({ session }: { session: SessionInfo }) => (
<div data-testid={`session-row-${session.id}`}>{session.id}</div>
)
}))

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(
<SidebarSessionsSection
activeSessionId={null}
emptyState={<div>Empty</div>}
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(
<SidebarSessionsSection
activeSessionId={null}
emptyState={<div>Empty</div>}
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(
<SidebarSessionsSection
activeSessionId={null}
dateGrouped={false}
emptyState={<div>Empty</div>}
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(
<SidebarSessionsSection
activeSessionId={null}
dateGrouped={true}
emptyState={<div>Empty</div>}
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(
<SidebarSessionsSection
activeSessionId={null}
dateGrouped={true}
emptyState={<div>Empty</div>}
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)
})
})
97 changes: 61 additions & 36 deletions apps/desktop/src/app/chat/sidebar/sessions-section.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 ? (
<SortableSidebarSessionRow key={session.id} {...rowProps} />
) : (
<SidebarSessionRow key={session.id} {...rowProps} />
)
}
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 ? (
<SortableSidebarSessionRow key={session.id} {...rowProps} />
) : (
<SidebarSessionRow key={session.id} {...rowProps} />
)
},
[
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' ? (
<SidebarDateDivider key={row.key} label={sessionBucketLabel(row.bucket, dividerLabels)} />
) : (
renderRow(row.entry.session, draggable, row.entry.branchStem)
)
const renderListRow = useCallback(
(row: SidebarListRow, draggable: boolean) =>
row.kind === 'divider' ? (
<SidebarDateDivider key={row.key} label={sessionBucketLabel(row.bucket, dividerLabels)} />
) : (
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 &&
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ interface SessionRowCommonProps {
showProfile?: boolean
}

interface VirtualSessionListProps {
export interface VirtualSessionListProps {
activeSessionId: null | string
className?: string
rows: SidebarListRow[]
Expand Down