feat: session sidebar component with create, select, logout
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user