From c24fd11f03b8aad105f6959d54c6815bf39e8048 Mon Sep 17 00:00:00 2001 From: Ken Date: Tue, 26 May 2026 19:20:37 +0000 Subject: [PATCH] feat: session sidebar component with create, select, logout --- frontend/src/components/SessionSidebar.tsx | 78 +++++++ frontend/src/test/SessionSidebar.test.tsx | 260 +++++++++++++++++++++ 2 files changed, 338 insertions(+) create mode 100644 frontend/src/components/SessionSidebar.tsx create mode 100644 frontend/src/test/SessionSidebar.test.tsx 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() + }) +})