diff --git a/frontend/src/components/SessionSidebar.tsx b/frontend/src/components/SessionSidebar.tsx
new file mode 100644
index 0000000..9813a23
--- /dev/null
+++ b/frontend/src/components/SessionSidebar.tsx
@@ -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 (
+
+
+
Sessions
+
+
+
+
+
+
+ {sessions.length === 0 ? (
+
+ No sessions yet. Click + to start researching.
+
+ ) : (
+ sessions.map(s => (
+
onSelect(s.session_id)}
+ >
+ {s.session_id.slice(0, 12)}...
+
+ {s.total_messages ?? 0} msgs · {s.status}
+
+
+ ))
+ )}
+
+
+ )
+}
diff --git a/frontend/src/test/SessionSidebar.test.tsx b/frontend/src/test/SessionSidebar.test.tsx
new file mode 100644
index 0000000..0685334
--- /dev/null
+++ b/frontend/src/test/SessionSidebar.test.tsx
@@ -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_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(
+
+ )
+
+ 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(
+
+ )
+
+ const createBtn = screen.getByTitle('New session')
+ expect(createBtn).toBeTruthy()
+ })
+
+ it('renders a logout button with title "Log out"', () => {
+ render(
+
+ )
+
+ const logoutBtn = screen.getByTitle('Log out')
+ expect(logoutBtn).toBeTruthy()
+ })
+
+ it('calls onCreate when create button is clicked', async () => {
+ const onCreate = vi.fn()
+ render(
+
+ )
+
+ await userEvent.click(screen.getByTitle('New session'))
+ expect(onCreate).toHaveBeenCalledOnce()
+ })
+
+ it('calls onLogout when logout button is clicked', async () => {
+ const onLogout = vi.fn()
+ render(
+
+ )
+
+ await userEvent.click(screen.getByTitle('Log out'))
+ expect(onLogout).toHaveBeenCalledOnce()
+ })
+
+ it('shows empty-state placeholder when sessions array is empty', () => {
+ render(
+
+ )
+
+ expect(
+ screen.getByText('No sessions yet. Click + to start researching.')
+ ).toBeTruthy()
+ })
+
+ it('renders .session-list container', () => {
+ const { container } = render(
+
+ )
+
+ expect(container.querySelector('.session-list')).toBeTruthy()
+ })
+
+ it('does not show placeholder when sessions are present', () => {
+ render(
+
+ )
+
+ 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(
+
+ )
+
+ // 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(
+
+ )
+
+ 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(
+
+ )
+
+ 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(
+
+ )
+
+ 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(
+
+ )
+
+ 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(
+
+ )
+
+ 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(
+
+ )
+
+ expect(screen.getByText('session-one-...')).toBeTruthy()
+ expect(screen.getByText('session-two-...')).toBeTruthy()
+ })
+})