feat: session sidebar component with create, select, logout

This commit is contained in:
Ken
2026-05-26 19:20:37 +00:00
parent 77fe2bafd4
commit c24fd11f03
2 changed files with 338 additions and 0 deletions
@@ -0,0 +1,78 @@
import { IconPlus, IconLogout } from '@tabler/icons-react'
import type { Session } from '../lib/types'
interface SessionSidebarProps {
sessions: Session[]
activeSessionId: string | null
onSelect: (id: string) => void
onCreate: () => void
onLogout: () => void
}
export function SessionSidebar({
sessions,
activeSessionId,
onSelect,
onCreate,
onLogout,
}: SessionSidebarProps) {
return (
<div className='sidebar'>
<div className='sidebar-header'>
<h3>Sessions</h3>
<div style={{ display: 'flex' }}>
<button
title='New session'
onClick={onCreate}
style={{
border: 'none',
background: 'transparent',
cursor: 'pointer',
padding: '4px',
}}
>
<IconPlus size={18} color='#7aa2f7' />
</button>
<button
title='Log out'
onClick={onLogout}
style={{
border: 'none',
background: 'transparent',
cursor: 'pointer',
padding: '4px',
}}
>
<IconLogout size={18} color='#8888aa' />
</button>
</div>
</div>
<div className='session-list'>
{sessions.length === 0 ? (
<div
style={{
color: '#6a6a8a',
fontSize: '0.85rem',
padding: '1rem',
}}
>
No sessions yet. Click + to start researching.
</div>
) : (
sessions.map(s => (
<div
key={s.session_id}
className={`session-item ${s.session_id === activeSessionId ? 'active' : ''}`}
onClick={() => onSelect(s.session_id)}
>
{s.session_id.slice(0, 12)}...
<div className='session-meta'>
{s.total_messages ?? 0} msgs · {s.status}
</div>
</div>
))
)}
</div>
</div>
)
}
+260
View File
@@ -0,0 +1,260 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { SessionSidebar } from '../components/SessionSidebar'
import type { Session } from '../lib/types'
const makeSession = (overrides: Partial<Session> = {}): Session => ({
session_id: 'abcdefghijklmnop',
status: 'active',
bundle: 'default',
created_at: '2024-01-01T00:00:00Z',
last_activity: '2024-01-01T00:00:00Z',
total_messages: 5,
tool_invocations: 2,
stale: false,
...overrides,
})
describe('SessionSidebar', () => {
it('renders a sidebar div with sidebar-header containing h3 Sessions', () => {
const { container } = render(
<SessionSidebar
sessions={[]}
activeSessionId={null}
onSelect={vi.fn()}
onCreate={vi.fn()}
onLogout={vi.fn()}
/>
)
expect(container.querySelector('.sidebar')).toBeTruthy()
expect(container.querySelector('.sidebar-header')).toBeTruthy()
expect(screen.getByRole('heading', { name: 'Sessions' })).toBeTruthy()
})
it('renders a create button with title "New session"', () => {
render(
<SessionSidebar
sessions={[]}
activeSessionId={null}
onSelect={vi.fn()}
onCreate={vi.fn()}
onLogout={vi.fn()}
/>
)
const createBtn = screen.getByTitle('New session')
expect(createBtn).toBeTruthy()
})
it('renders a logout button with title "Log out"', () => {
render(
<SessionSidebar
sessions={[]}
activeSessionId={null}
onSelect={vi.fn()}
onCreate={vi.fn()}
onLogout={vi.fn()}
/>
)
const logoutBtn = screen.getByTitle('Log out')
expect(logoutBtn).toBeTruthy()
})
it('calls onCreate when create button is clicked', async () => {
const onCreate = vi.fn()
render(
<SessionSidebar
sessions={[]}
activeSessionId={null}
onSelect={vi.fn()}
onCreate={onCreate}
onLogout={vi.fn()}
/>
)
await userEvent.click(screen.getByTitle('New session'))
expect(onCreate).toHaveBeenCalledOnce()
})
it('calls onLogout when logout button is clicked', async () => {
const onLogout = vi.fn()
render(
<SessionSidebar
sessions={[]}
activeSessionId={null}
onSelect={vi.fn()}
onCreate={vi.fn()}
onLogout={onLogout}
/>
)
await userEvent.click(screen.getByTitle('Log out'))
expect(onLogout).toHaveBeenCalledOnce()
})
it('shows empty-state placeholder when sessions array is empty', () => {
render(
<SessionSidebar
sessions={[]}
activeSessionId={null}
onSelect={vi.fn()}
onCreate={vi.fn()}
onLogout={vi.fn()}
/>
)
expect(
screen.getByText('No sessions yet. Click + to start researching.')
).toBeTruthy()
})
it('renders .session-list container', () => {
const { container } = render(
<SessionSidebar
sessions={[]}
activeSessionId={null}
onSelect={vi.fn()}
onCreate={vi.fn()}
onLogout={vi.fn()}
/>
)
expect(container.querySelector('.session-list')).toBeTruthy()
})
it('does not show placeholder when sessions are present', () => {
render(
<SessionSidebar
sessions={[makeSession()]}
activeSessionId={null}
onSelect={vi.fn()}
onCreate={vi.fn()}
onLogout={vi.fn()}
/>
)
expect(
screen.queryByText('No sessions yet. Click + to start researching.')
).toBeNull()
})
it('renders session items with truncated session_id', () => {
const session = makeSession({ session_id: 'abcdefghijklmnopqrst' })
render(
<SessionSidebar
sessions={[session]}
activeSessionId={null}
onSelect={vi.fn()}
onCreate={vi.fn()}
onLogout={vi.fn()}
/>
)
// First 12 chars + '...'
expect(screen.getByText('abcdefghijkl...')).toBeTruthy()
})
it('renders session-meta with message count and status', () => {
const session = makeSession({ total_messages: 7, status: 'idle' })
render(
<SessionSidebar
sessions={[session]}
activeSessionId={null}
onSelect={vi.fn()}
onCreate={vi.fn()}
onLogout={vi.fn()}
/>
)
expect(screen.getByText('7 msgs · idle')).toBeTruthy()
})
it('shows 0 msgs when total_messages is null/undefined', () => {
// Session with total_messages as 0 via override
const session = makeSession({ total_messages: 0 })
render(
<SessionSidebar
sessions={[session]}
activeSessionId={null}
onSelect={vi.fn()}
onCreate={vi.fn()}
onLogout={vi.fn()}
/>
)
expect(screen.getByText('0 msgs · active')).toBeTruthy()
})
it('adds "active" class to session item when it matches activeSessionId', () => {
const session = makeSession({ session_id: 'sess-abc-123' })
const { container } = render(
<SessionSidebar
sessions={[session]}
activeSessionId='sess-abc-123'
onSelect={vi.fn()}
onCreate={vi.fn()}
onLogout={vi.fn()}
/>
)
const item = container.querySelector('.session-item')
expect(item?.classList.contains('active')).toBe(true)
})
it('does not add "active" class when session is not the active one', () => {
const session = makeSession({ session_id: 'sess-abc-123' })
const { container } = render(
<SessionSidebar
sessions={[session]}
activeSessionId='other-session'
onSelect={vi.fn()}
onCreate={vi.fn()}
onLogout={vi.fn()}
/>
)
const item = container.querySelector('.session-item')
expect(item?.classList.contains('active')).toBe(false)
})
it('calls onSelect with session_id when session item is clicked', async () => {
const onSelect = vi.fn()
const session = makeSession({ session_id: 'sess-xyz-789' })
render(
<SessionSidebar
sessions={[session]}
activeSessionId={null}
onSelect={onSelect}
onCreate={vi.fn()}
onLogout={vi.fn()}
/>
)
await userEvent.click(screen.getByText('sess-xyz-789...'))
expect(onSelect).toHaveBeenCalledWith('sess-xyz-789')
})
it('renders multiple sessions', () => {
// 'session-one-abc' -> first 12 chars -> 'session-one-'
// 'session-two-xyz' -> first 12 chars -> 'session-two-'
const sessions = [
makeSession({ session_id: 'session-one-abc' }),
makeSession({ session_id: 'session-two-xyz' }),
]
render(
<SessionSidebar
sessions={sessions}
activeSessionId={null}
onSelect={vi.fn()}
onCreate={vi.fn()}
onLogout={vi.fn()}
/>
)
expect(screen.getByText('session-one-...')).toBeTruthy()
expect(screen.getByText('session-two-...')).toBeTruthy()
})
})