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