import { describe, expect, it } from 'vitest' import { toSessionSummary, type Session } from '@hapi/protocol' import type { SessionSummary } from '@/types/api' import { deduplicateSessionsByAgentId, expandSelectedSessionCollapseOverrides, filterActiveSessionsOnly, getSessionTimeRange, getNextSessionVisibleCount, getPullRefreshIndicatorRotation, getPreviousSessionVisibleCount, getPullToRefreshState, getSessionDedupKey, getWorktreeSessionLabel, getVisibleSessionPreview, isSidebarEmptySessionStub, normalizeSearch, prepareSidebarSessions, sessionMatchesQuery, sessionMatchesTimeRange, shouldShowSessionInSidebar } from './SessionList' function makeSession(overrides: Partial & { id: string }): SessionSummary { return { active: false, thinking: false, activeAt: 0, updatedAt: 0, metadata: null, todoProgress: null, pendingRequestsCount: 0, pendingRequestKinds: [], pendingRequests: [], backgroundTaskCount: 0, futureScheduledMessageCount: 0, nextScheduledAt: null, model: null, effort: null, ...overrides } } describe('getWorktreeSessionLabel', () => { it('returns the worktree name for sessions grouped under a shared repository', () => { const session = makeSession({ id: 'worktree-session', metadata: { path: '/work/hapi-worktrees/fix-resume', worktree: { basePath: '/work/hapi', branch: 'fix/resume', name: 'fix-resume', worktreePath: '/work/hapi-worktrees/fix-resume' } } }) expect(getWorktreeSessionLabel(session)).toBe('fix-resume') }) it('does not add a subtitle to ordinary sessions', () => { const session = makeSession({ id: 'ordinary-session', metadata: { path: '/work/hapi' } }) expect(getWorktreeSessionLabel(session)).toBeNull() }) it('falls back to the worktree directory name when metadata name is blank', () => { const session = makeSession({ id: 'windows-worktree-session', metadata: { path: 'C:\\work\\hapi-worktrees\\fix-resume', worktree: { basePath: 'C:\\work\\hapi', branch: 'fix/resume', name: ' ', worktreePath: 'C:\\work\\hapi-worktrees\\fix-resume\\' } } }) expect(getWorktreeSessionLabel(session)).toBe('fix-resume') }) }) describe('deduplicateSessionsByAgentId', () => { it('deduplicates sessions with the same agentSessionId', () => { const sessions = [ makeSession({ id: 'a', metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 100 }), makeSession({ id: 'b', metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 200 }) ] const result = deduplicateSessionsByAgentId(sessions) expect(result).toHaveLength(1) expect(result[0].id).toBe('b') // more recent wins }) it('keeps active session over inactive duplicate', () => { const sessions = [ makeSession({ id: 'a', active: true, metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 100 }), makeSession({ id: 'b', metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 200 }) ] const result = deduplicateSessionsByAgentId(sessions) expect(result).toHaveLength(1) expect(result[0].id).toBe('a') // active wins despite older updatedAt }) it('prefers selected session among inactive duplicates', () => { const sessions = [ makeSession({ id: 'a', metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 100 }), makeSession({ id: 'b', metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 200 }) ] const result = deduplicateSessionsByAgentId(sessions, 'a') expect(result).toHaveLength(1) expect(result[0].id).toBe('a') // selected wins despite older updatedAt }) it('active always wins over selected inactive', () => { const sessions = [ makeSession({ id: 'a', metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 200 }), makeSession({ id: 'b', active: true, metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 100 }) ] const result = deduplicateSessionsByAgentId(sessions, 'a') expect(result).toHaveLength(1) expect(result[0].id).toBe('b') // active wins over selected }) it('passes through sessions without agentSessionId', () => { const sessions = [ makeSession({ id: 'a', metadata: { path: '/p' } }), makeSession({ id: 'b', metadata: { path: '/p', agentSessionId: 'thread-1' } }), makeSession({ id: 'c', metadata: null }) ] const result = deduplicateSessionsByAgentId(sessions) expect(result).toHaveLength(3) }) it('deduplicates cursor sessions by summary agentSessionId', () => { const sessions = [ makeSession({ id: 'a', active: true, metadata: { path: '/p', flavor: 'cursor', agentSessionId: 'acp-thread-1' }, updatedAt: 100 }), makeSession({ id: 'b', metadata: { path: '/p', flavor: 'cursor', agentSessionId: 'acp-thread-1' }, updatedAt: 200 }) ] const result = deduplicateSessionsByAgentId(sessions) expect(result).toHaveLength(1) expect(result[0].id).toBe('a') }) it('deduplicates independently across different agentSessionIds', () => { const sessions = [ makeSession({ id: 'a', metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 100 }), makeSession({ id: 'b', metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 200 }), makeSession({ id: 'c', metadata: { path: '/p', agentSessionId: 'thread-2' }, updatedAt: 100 }), makeSession({ id: 'd', metadata: { path: '/p', agentSessionId: 'thread-2' }, updatedAt: 200 }) ] const result = deduplicateSessionsByAgentId(sessions) expect(result).toHaveLength(2) expect(result.map(s => s.id).sort()).toEqual(['b', 'd']) }) it('does not dedupe across flavors sharing the same flattened agentSessionId', () => { const sessions = [ makeSession({ id: 'codex', metadata: { path: '/p', flavor: 'codex', agentSessionId: 'stale-shared-id' }, updatedAt: 100 }), makeSession({ id: 'cursor', metadata: { path: '/p', flavor: 'cursor', agentSessionId: 'stale-shared-id' }, updatedAt: 200 }) ] expect(getSessionDedupKey(sessions[0])).toBe('codex:stale-shared-id') expect(getSessionDedupKey(sessions[1])).toBe('cursor:stale-shared-id') expect(deduplicateSessionsByAgentId(sessions).map(session => session.id).sort()).toEqual(['codex', 'cursor']) expect(prepareSidebarSessions(sessions).map(session => session.id).sort()).toEqual(['codex', 'cursor']) }) }) describe('isSidebarEmptySessionStub', () => { it('treats inactive sessions without agent id or title as stubs', () => { expect(isSidebarEmptySessionStub(makeSession({ id: 'stub', metadata: { path: '/work/hapi' } }))).toBe(true) }) it('does not treat active sessions as stubs', () => { expect(isSidebarEmptySessionStub(makeSession({ id: 'live', active: true, metadata: { path: '/work/hapi' } }))).toBe(false) }) it('does not treat sessions with agentSessionId as stubs', () => { expect(isSidebarEmptySessionStub(makeSession({ id: 'resume', metadata: { path: '/work/hapi', agentSessionId: 'thread-1' } }))).toBe(false) }) it('does not treat sessions with summary text as stubs', () => { expect(isSidebarEmptySessionStub(makeSession({ id: 'titled', metadata: { path: '/work/hapi', summary: { text: 'Fix sidebar' } } }))).toBe(false) }) }) describe('prepareSidebarSessions', () => { it('hides inactive empty stubs but keeps real sessions', () => { const sessions = [ makeSession({ id: 'stub', metadata: { path: '/work/hapi' } }), makeSession({ id: 'real', metadata: { path: '/work/hapi', agentSessionId: 'thread-1', summary: { text: 'Real chat' } } }) ] const result = prepareSidebarSessions(sessions) expect(result.map(session => session.id)).toEqual(['real']) }) it('keeps an archived Pi session with a native session id and no title', () => { const piSession: Session = { id: 'archived-pi', namespace: 'default', seq: 1, createdAt: 50, active: false, activeAt: 0, updatedAt: 100, metadata: { path: '/work/hapi', host: 'local', flavor: 'pi', piSessionId: 'pi-session-1', lifecycleState: 'archived' }, metadataVersion: 1, agentState: null, agentStateVersion: 0, thinking: false, thinkingAt: 0, model: null, modelReasoningEffort: null, effort: null, serviceTier: null } const summary = toSessionSummary(piSession) expect(summary.metadata?.agentSessionId).toBe('pi-session-1') expect(prepareSidebarSessions([summary]).map(session => session.id)).toEqual(['archived-pi']) }) it('keeps the selected inactive stub visible', () => { const sessions = [ makeSession({ id: 'stub', metadata: { path: '/work/hapi' } }), makeSession({ id: 'real', metadata: { path: '/work/hapi', agentSessionId: 'thread-1' } }) ] const result = prepareSidebarSessions(sessions, 'stub') expect(result.map(session => session.id).sort()).toEqual(['real', 'stub']) }) it('deduplicates before filtering stubs', () => { const sessions = [ makeSession({ id: 'stub', metadata: { path: '/work/hapi' } }), makeSession({ id: 'older', metadata: { path: '/work/hapi', agentSessionId: 'thread-1' }, updatedAt: 100 }), makeSession({ id: 'newer', metadata: { path: '/work/hapi', agentSessionId: 'thread-1' }, updatedAt: 200 }) ] const result = prepareSidebarSessions(sessions) expect(result.map(session => session.id)).toEqual(['newer']) }) }) describe('shouldShowSessionInSidebar', () => { it('always shows active and selected sessions', () => { const stub = makeSession({ id: 'stub', metadata: { path: '/work/hapi' } }) expect(shouldShowSessionInSidebar(stub)).toBe(false) expect(shouldShowSessionInSidebar(stub, 'stub')).toBe(true) expect(shouldShowSessionInSidebar({ ...stub, active: true })).toBe(true) }) }) describe('session list search helpers', () => { it('normalizes whitespace and case before filtering', () => { const session = makeSession({ id: 'session-1', metadata: { path: '/work/hapi', name: 'Fix Bot Review', flavor: 'codex', machineId: 'machine-1' } }) expect(normalizeSearch(' BOT ')).toBe('bot') expect(sessionMatchesQuery(session, normalizeSearch('bot review'), 'desktop')).toBe(true) expect(sessionMatchesQuery(session, normalizeSearch('desktop'), 'desktop')).toBe(true) expect(sessionMatchesQuery(session, normalizeSearch('missing'), 'desktop')).toBe(false) }) it('matches the displayed worktree label and worktree path', () => { const session = makeSession({ id: 'worktree-session', metadata: { path: '/work/hapi', worktree: { basePath: '/work/hapi', branch: 'fix/sidebar-search', name: 'sidebar-search', worktreePath: '/work/hapi-worktrees/fix-sidebar-search' } } }) expect(sessionMatchesQuery(session, normalizeSearch('sidebar-search'), 'desktop')).toBe(true) expect(sessionMatchesQuery(session, normalizeSearch('hapi-worktrees'), 'desktop')).toBe(true) }) }) describe('session list time filter helpers', () => { it('treats the selected end date as inclusive in local time', () => { const range = getSessionTimeRange('2026-07-01', '2026-07-18') expect(range).toEqual({ start: new Date(2026, 6, 1).getTime(), end: new Date(2026, 6, 19).getTime() }) expect(sessionMatchesTimeRange(makeSession({ id: 'inside', updatedAt: new Date(2026, 6, 18, 23, 59).getTime() }), range)).toBe(true) expect(sessionMatchesTimeRange(makeSession({ id: 'outside', updatedAt: new Date(2026, 6, 19).getTime() }), range)).toBe(false) }) it('does not filter until both dates are selected', () => { expect(getSessionTimeRange('', '')).toBeNull() expect(getSessionTimeRange('2026-07-01', '')).toBeNull() }) }) describe('getVisibleSessionPreview', () => { it('keeps selected and pending sessions inside the collapsed preview without promoting them', () => { const sessions = Array.from({ length: 6 }, (_, index) => makeSession({ id: `s-${index + 1}`, pendingRequestsCount: index === 4 ? 1 : 0, metadata: { path: '/work/hapi' }, updatedAt: 100 - index })) const preview = getVisibleSessionPreview(sessions, { selectedSessionId: 's-6', limit: 3 }) expect(preview.map(session => session.id)).toEqual(['s-1', 's-5', 's-6']) }) it('does not exceed the limit just because many sessions are active', () => { const sessions = Array.from({ length: 6 }, (_, index) => makeSession({ id: `s-${index + 1}`, active: true, metadata: { path: '/work/hapi' }, updatedAt: 100 - index })) const preview = getVisibleSessionPreview(sessions, { limit: 4 }) expect(preview.map(session => session.id)).toEqual(['s-1', 's-2', 's-3', 's-4']) }) it('does not move an already-visible selected session to the top', () => { const sessions = Array.from({ length: 6 }, (_, index) => makeSession({ id: `s-${index + 1}`, metadata: { path: '/work/hapi' }, updatedAt: 100 - index })) const preview = getVisibleSessionPreview(sessions, { selectedSessionId: 's-3', limit: 4 }) expect(preview.map(session => session.id)).toEqual(['s-1', 's-2', 's-3', 's-4']) }) it('returns all sessions when expanded', () => { const sessions = Array.from({ length: 4 }, (_, index) => makeSession({ id: `s-${index + 1}`, metadata: { path: '/work/hapi' } })) expect(getVisibleSessionPreview(sessions, { expanded: true, limit: 2 })).toHaveLength(4) }) }) describe('filterActiveSessionsOnly', () => { it('keeps only active sessions when no selection', () => { const sessions = [ makeSession({ id: 'live', active: true, metadata: { path: '/p' } }), makeSession({ id: 'dead', metadata: { path: '/p' } }) ] expect(filterActiveSessionsOnly(sessions).map(s => s.id)).toEqual(['live']) }) it('keeps the selected inactive session visible', () => { const sessions = [ makeSession({ id: 'live', active: true, metadata: { path: '/p' } }), makeSession({ id: 'dead', metadata: { path: '/p' } }), makeSession({ id: 'selected-dead', metadata: { path: '/p' } }) ] expect(filterActiveSessionsOnly(sessions, 'selected-dead').map(s => s.id).sort()) .toEqual(['live', 'selected-dead']) }) it('preserves input order', () => { const sessions = [ makeSession({ id: 'a', active: true, metadata: { path: '/p' } }), makeSession({ id: 'b', metadata: { path: '/p' } }), makeSession({ id: 'c', active: true, metadata: { path: '/p' } }) ] expect(filterActiveSessionsOnly(sessions).map(s => s.id)).toEqual(['a', 'c']) }) }) describe('getNextSessionVisibleCount', () => { it('reveals one batch of step size per call', () => { expect(getNextSessionVisibleCount(8, 8, 20)).toBe(16) expect(getNextSessionVisibleCount(16, 8, 20)).toBe(20) }) it('never exceeds the total session count', () => { expect(getNextSessionVisibleCount(18, 8, 20)).toBe(20) expect(getNextSessionVisibleCount(20, 8, 20)).toBe(20) }) it('always advances by at least one even with a zero step', () => { expect(getNextSessionVisibleCount(5, 0, 20)).toBe(6) }) }) describe('getPreviousSessionVisibleCount', () => { it('collapses one batch of step size per call', () => { expect(getPreviousSessionVisibleCount(20, 8)).toBe(12) expect(getPreviousSessionVisibleCount(12, 8)).toBe(8) }) it('never goes below the preview limit', () => { expect(getPreviousSessionVisibleCount(10, 8)).toBe(8) expect(getPreviousSessionVisibleCount(8, 8)).toBe(8) }) it('uses a minimum batch size of one', () => { expect(getPreviousSessionVisibleCount(5, 0)).toBe(4) }) }) describe('expandSelectedSessionCollapseOverrides', () => { it('expands the collapsed project group, but preserves session preview folding', () => { const overrides = new Map([ ['machine-1::/work/hapi', true], ['sessions::machine-1::/work/hapi', true] ]) const result = expandSelectedSessionCollapseOverrides(overrides, { key: 'machine-1::/work/hapi' }) expect(result.get('machine-1::/work/hapi')).toBe(false) expect(result.get('sessions::machine-1::/work/hapi')).toBe(true) }) it('leaves missing session preview override unset', () => { const overrides = new Map() const result = expandSelectedSessionCollapseOverrides(overrides, { key: 'machine-1::/work/hapi' }) expect(result.has('sessions::machine-1::/work/hapi')).toBe(false) }) }) describe('getPullToRefreshState', () => { it('requires a deliberate pull past the trigger distance', () => { expect(getPullToRefreshState(15)).toBe('idle') expect(getPullToRefreshState(16)).toBe('pulling') expect(getPullToRefreshState(63)).toBe('pulling') expect(getPullToRefreshState(64)).toBe('ready') }) }) describe('getPullRefreshIndicatorRotation', () => { it('turns the pull indicator upward once refresh is ready', () => { expect(getPullRefreshIndicatorRotation('pulling')).toBe(0) expect(getPullRefreshIndicatorRotation('ready')).toBe(180) }) })