Skip to content

Commit b2dff53

Browse files
authored
feat(web-ui): /sessions list page + sidebar navigation entry (#508)
## Summary - New `/sessions` page listing active and ended sessions, sorted active-first - Session cards: state dot (green/gray), short ID, workspace name, model, relative time, formatted cost, Resume/View/End buttons - New Session modal with workspace path input, model selector, loading state, form reset on reopen - `/sessions/[id]` placeholder page (detail view depends on #501 backend) - Sessions nav entry in AppSidebar with active-session count badge (gray theme, 30s poll) - New types: `Session`, `SessionState`, `SessionListResponse`, `SessionCreateRequest` - `sessionsApi` client: `getAll` (wraps `Session[]` array from backend), `create`, `end` ## Validation - Review feedback: All addressed (2 rounds — internal code review + CodeRabbit/Claude CI reviews) - Demo: All 9 acceptance criteria verified - Tests: 112/112 passing (TDD) - CI: All checks green - Linting: Clean Closes #508
1 parent be2fd2b commit b2dff53

13 files changed

Lines changed: 944 additions & 6 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ CodeFRAME v2 (Phases 1–6 complete) delivers the full Think-Build-Prove-Ship lo
268268
- **SHIP**: GitHub PR workflow, environment validation, task self-diagnosis
269269
- **Engine adapters**: Claude Code, Codex, OpenCode, Kilocode, and built-in ReAct — all via `--engine` flag
270270
- **Server layer** (optional): FastAPI with 16+ v2 routers, API key auth, rate limiting, SSE streaming, WebSocket endpoints (agent chat, interactive terminal), OpenAPI docs
271-
- **Web UI**: Workspace view, PRD discovery, Task board, Blocker resolution, Review/commit, PROOF9 requirements and evidence views, TUI dashboard, agent chat panel with streaming tool-call display, interactive terminal for session workspaces
271+
- **Web UI**: Workspace view, PRD discovery, Task board, Blocker resolution, Review/commit, PROOF9 requirements and evidence views, TUI dashboard, agent chat panel with streaming tool-call display, interactive terminal for session workspaces, Sessions list with active-session badge
272272
- **Test suite**: 4200+ tests, 88% coverage
273273

274274
---

web-ui/__tests__/components/layout/AppSidebar.test.tsx

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,15 @@ jest.mock('@/lib/workspace-storage', () => ({
2525
getSelectedWorkspacePath: jest.fn(),
2626
}));
2727

28-
// Mock SWR (used for blocker badge count)
28+
// Mock SWR (used for blocker + session badge counts)
29+
const mockSWRData: Record<string, unknown> = {};
2930
jest.mock('swr', () => ({
3031
__esModule: true,
31-
default: () => ({ data: undefined, isLoading: false, error: undefined }),
32+
default: (key: string | null) => ({
33+
data: key ? mockSWRData[key] : undefined,
34+
isLoading: false,
35+
error: undefined,
36+
}),
3237
}));
3338

3439
import { getSelectedWorkspacePath } from '@/lib/workspace-storage';
@@ -48,6 +53,8 @@ describe('AppSidebar', () => {
4853
beforeEach(() => {
4954
jest.clearAllMocks();
5055
mockUsePathname.mockReturnValue('/');
56+
// Clear SWR mock data to prevent cross-test cache leakage
57+
Object.keys(mockSWRData).forEach((k) => delete mockSWRData[k]);
5158
});
5259

5360
it('renders nothing when no workspace is selected', () => {
@@ -63,16 +70,18 @@ describe('AppSidebar', () => {
6370
expect(screen.getByText('PRD')).toBeInTheDocument();
6471
});
6572

66-
it('renders all 6 navigation items', () => {
73+
it('renders all 8 navigation items', () => {
6774
mockGetWorkspacePath.mockReturnValue('/home/user/projects/test');
6875
render(<AppSidebar />);
6976

7077
expect(screen.getByText('Workspace')).toBeInTheDocument();
7178
expect(screen.getByText('PRD')).toBeInTheDocument();
7279
expect(screen.getByText('Tasks')).toBeInTheDocument();
7380
expect(screen.getByText('Execution')).toBeInTheDocument();
81+
expect(screen.getByText('Sessions')).toBeInTheDocument();
7482
expect(screen.getByText('Blockers')).toBeInTheDocument();
7583
expect(screen.getByText('Review')).toBeInTheDocument();
84+
expect(screen.getByText('Proof')).toBeInTheDocument();
7685
});
7786

7887
it('renders enabled items as links', () => {
@@ -112,4 +121,38 @@ describe('AppSidebar', () => {
112121
// Active items have 'bg-accent' without 'hover:' prefix; inactive have 'hover:bg-accent/50'
113122
expect(workspaceLink.className).not.toMatch(/(?<!\w)bg-accent(?!\/)/);
114123
});
124+
125+
// ─── Sessions nav entry tests ──────────────────────────────────────
126+
127+
it('renders Sessions nav link pointing to /sessions', () => {
128+
mockGetWorkspacePath.mockReturnValue('/home/user/projects/test');
129+
render(<AppSidebar />);
130+
const sessionsLink = screen.getByRole('link', { name: /sessions/i });
131+
expect(sessionsLink).toHaveAttribute('href', '/sessions');
132+
});
133+
134+
it('shows active session count badge when there are active sessions', () => {
135+
mockGetWorkspacePath.mockReturnValue('/home/user/projects/test');
136+
mockSWRData['/api/v2/sessions?path=%2Fhome%2Fuser%2Fprojects%2Ftest&state=active'] = {
137+
sessions: [
138+
{ id: 's1', state: 'active' },
139+
{ id: 's2', state: 'active' },
140+
],
141+
total: 2,
142+
};
143+
render(<AppSidebar />);
144+
expect(screen.getByText('2')).toBeInTheDocument();
145+
});
146+
147+
it('does not show session badge when count is 0', () => {
148+
mockGetWorkspacePath.mockReturnValue('/home/user/projects/test');
149+
mockSWRData['/api/v2/sessions?path=%2Fhome%2Fuser%2Fprojects%2Ftest&state=active'] = {
150+
sessions: [],
151+
total: 0,
152+
};
153+
render(<AppSidebar />);
154+
// With 0 sessions and no blockers, no badge spans should be present
155+
const sessionsLink = screen.getByRole('link', { name: /sessions/i });
156+
expect(sessionsLink.querySelector('.bg-muted')).toBeNull();
157+
});
115158
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { render, screen, waitFor } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { NewSessionModal } from '@/components/sessions/NewSessionModal';
4+
5+
const defaultProps = {
6+
open: true,
7+
onOpenChange: jest.fn(),
8+
defaultWorkspacePath: '/home/user/projects/my-app',
9+
onSubmit: jest.fn().mockResolvedValue(undefined),
10+
};
11+
12+
beforeEach(() => {
13+
jest.clearAllMocks();
14+
});
15+
16+
describe('NewSessionModal', () => {
17+
it('renders when open is true', () => {
18+
render(<NewSessionModal {...defaultProps} />);
19+
expect(screen.getByText('New Session')).toBeInTheDocument();
20+
});
21+
22+
it('does not render when open is false', () => {
23+
render(<NewSessionModal {...defaultProps} open={false} />);
24+
expect(screen.queryByText('New Session')).not.toBeInTheDocument();
25+
});
26+
27+
it('pre-fills workspace path from prop', () => {
28+
render(<NewSessionModal {...defaultProps} />);
29+
const input = screen.getByLabelText(/workspace/i);
30+
expect(input).toHaveValue('/home/user/projects/my-app');
31+
});
32+
33+
it('defaults model to claude-sonnet-4-6', () => {
34+
render(<NewSessionModal {...defaultProps} />);
35+
// The select trigger should show the default value
36+
expect(screen.getByText('claude-sonnet-4-6')).toBeInTheDocument();
37+
});
38+
39+
it('calls onSubmit with workspace_path and model on submit', async () => {
40+
const user = userEvent.setup();
41+
render(<NewSessionModal {...defaultProps} />);
42+
await user.click(screen.getByRole('button', { name: /start session/i }));
43+
await waitFor(() => {
44+
expect(defaultProps.onSubmit).toHaveBeenCalledWith({
45+
workspace_path: '/home/user/projects/my-app',
46+
model: 'claude-sonnet-4-6',
47+
});
48+
});
49+
});
50+
51+
it('disables Start Session button while submitting', async () => {
52+
const onSubmit = jest.fn().mockReturnValue(new Promise(() => {})); // never resolves
53+
const user = userEvent.setup();
54+
render(<NewSessionModal {...defaultProps} onSubmit={onSubmit} />);
55+
await user.click(screen.getByRole('button', { name: /start session/i }));
56+
await waitFor(() => {
57+
expect(screen.getByRole('button', { name: /start session/i })).toBeDisabled();
58+
});
59+
});
60+
61+
it('allows editing the workspace path', async () => {
62+
const user = userEvent.setup();
63+
render(<NewSessionModal {...defaultProps} />);
64+
const input = screen.getByLabelText(/workspace/i);
65+
await user.clear(input);
66+
await user.type(input, '/other/path');
67+
await user.click(screen.getByRole('button', { name: /start session/i }));
68+
await waitFor(() => {
69+
expect(defaultProps.onSubmit).toHaveBeenCalledWith({
70+
workspace_path: '/other/path',
71+
model: 'claude-sonnet-4-6',
72+
});
73+
});
74+
});
75+
});
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { render, screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { SessionCard } from '@/components/sessions/SessionCard';
4+
import type { Session } from '@/types';
5+
6+
// ─── Fixtures ───────────────────────────────────────────────────────
7+
8+
function makeSession(overrides: Partial<Session> = {}): Session {
9+
return {
10+
id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeee1234',
11+
state: 'active',
12+
workspace_path: '/home/user/projects/my-app',
13+
model: 'claude-sonnet-4-6',
14+
created_at: new Date(Date.now() - 5 * 60 * 1000).toISOString(), // 5 min ago
15+
ended_at: null,
16+
cost_usd: 0.012,
17+
agent_name: null,
18+
...overrides,
19+
};
20+
}
21+
22+
const defaultHandlers = {
23+
onEnd: jest.fn(),
24+
};
25+
26+
function renderCard(sessionOverrides: Partial<Session> = {}, props: Partial<Parameters<typeof SessionCard>[0]> = {}) {
27+
const session = makeSession(sessionOverrides);
28+
return render(
29+
<SessionCard
30+
session={session}
31+
{...defaultHandlers}
32+
{...props}
33+
/>
34+
);
35+
}
36+
37+
beforeEach(() => {
38+
jest.clearAllMocks();
39+
});
40+
41+
// ─── Tests ──────────────────────────────────────────────────────────
42+
43+
describe('SessionCard', () => {
44+
it('renders the session short ID (last 8 chars)', () => {
45+
renderCard();
46+
expect(screen.getByText('eeee1234')).toBeInTheDocument();
47+
});
48+
49+
it('renders the workspace basename', () => {
50+
renderCard();
51+
expect(screen.getByText('my-app')).toBeInTheDocument();
52+
});
53+
54+
it('renders the model name', () => {
55+
renderCard();
56+
expect(screen.getByText('claude-sonnet-4-6')).toBeInTheDocument();
57+
});
58+
59+
it('renders cost formatted as dollar amount', () => {
60+
renderCard({ cost_usd: 0.012 });
61+
expect(screen.getByText('$0.0120')).toBeInTheDocument();
62+
});
63+
64+
it('shows green state dot for active sessions', () => {
65+
renderCard({ state: 'active' });
66+
const dot = screen.getByTestId('session-state-dot');
67+
expect(dot).toHaveClass('bg-green-500');
68+
});
69+
70+
it('shows gray state dot for ended sessions', () => {
71+
renderCard({ state: 'ended' });
72+
const dot = screen.getByTestId('session-state-dot');
73+
expect(dot).toHaveClass('bg-gray-400');
74+
});
75+
76+
it('shows gray state dot for paused sessions', () => {
77+
renderCard({ state: 'paused' });
78+
const dot = screen.getByTestId('session-state-dot');
79+
expect(dot).toHaveClass('bg-gray-400');
80+
});
81+
82+
it('shows Resume button for active sessions', () => {
83+
renderCard({ state: 'active' });
84+
expect(screen.getByRole('link', { name: /resume/i })).toBeInTheDocument();
85+
expect(screen.queryByRole('link', { name: /^view$/i })).not.toBeInTheDocument();
86+
});
87+
88+
it('shows View button for ended sessions', () => {
89+
renderCard({ state: 'ended' });
90+
expect(screen.getByRole('link', { name: /view/i })).toBeInTheDocument();
91+
expect(screen.queryByRole('link', { name: /resume/i })).not.toBeInTheDocument();
92+
});
93+
94+
it('shows End button for active sessions', () => {
95+
renderCard({ state: 'active' });
96+
expect(screen.getByRole('button', { name: /end/i })).toBeInTheDocument();
97+
});
98+
99+
it('hides End button for ended sessions', () => {
100+
renderCard({ state: 'ended' });
101+
expect(screen.queryByRole('button', { name: /end/i })).not.toBeInTheDocument();
102+
});
103+
104+
it('calls onEnd when End button is clicked and confirmed', async () => {
105+
const user = userEvent.setup();
106+
window.confirm = jest.fn().mockReturnValue(true);
107+
renderCard({ state: 'active' });
108+
await user.click(screen.getByRole('button', { name: /end/i }));
109+
expect(window.confirm).toHaveBeenCalled();
110+
expect(defaultHandlers.onEnd).toHaveBeenCalledWith('aaaaaaaa-bbbb-cccc-dddd-eeeeeeee1234');
111+
});
112+
113+
it('does not call onEnd when confirm is cancelled', async () => {
114+
const user = userEvent.setup();
115+
window.confirm = jest.fn().mockReturnValue(false);
116+
renderCard({ state: 'active' });
117+
await user.click(screen.getByRole('button', { name: /end/i }));
118+
expect(window.confirm).toHaveBeenCalled();
119+
expect(defaultHandlers.onEnd).not.toHaveBeenCalled();
120+
});
121+
122+
it('renders relative time for created_at', () => {
123+
renderCard();
124+
// date-fns formatDistanceToNow will produce something like "5 minutes ago"
125+
expect(screen.getByText(/minutes? ago/i)).toBeInTheDocument();
126+
});
127+
128+
it('links Resume button to /sessions/[id]', () => {
129+
renderCard({ state: 'active' });
130+
const link = screen.getByRole('link', { name: /resume/i });
131+
expect(link).toHaveAttribute('href', '/sessions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeee1234');
132+
});
133+
134+
it('links View button to /sessions/[id]', () => {
135+
renderCard({ state: 'ended' });
136+
const link = screen.getByRole('link', { name: /view/i });
137+
expect(link).toHaveAttribute('href', '/sessions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeee1234');
138+
});
139+
});

0 commit comments

Comments
 (0)