Skip to content

Commit a4bda97

Browse files
authored
feat(web-ui): /sessions/[id] detail page — SplitPane + AgentChatPanel + AgentTerminal (#509)
## Summary - New `/sessions/[id]` detail page replacing the stub (server wrapper + `SessionDetailClient`) - Header: back link, session short ID, state badge, cost, [End Session] button with error feedback - Active sessions: `SplitPane` (45/55) with `AgentChatPanel` left + `AgentTerminal` right, per-session `storageKey` - Ended sessions: read-only `AgentChatPanel` with REST message history, ended banner, no terminal - Loading skeleton and error states (session not found / generic) - `AgentChatPanel`: added `readOnly` + `initialMessages` props for ended session view - `sessionsApi`: added `getOne()` and `getMessages()` endpoints - `generateMetadata` for server-side page title (Next.js App Router pattern) ## Validation - Review feedback: All 5 items addressed (2 rounds) - Demo: All 9 acceptance criteria verified with Playwright + mocked API responses - Tests: 20 passing (TDD — tests written before implementation) - CI: Code Quality ✅, Backend Unit Tests ✅ - Linting: Clean (0 new errors/warnings) Closes #509
1 parent b2dff53 commit a4bda97

5 files changed

Lines changed: 617 additions & 66 deletions

File tree

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
2+
import { useRouter } from 'next/navigation';
3+
import useSWR from 'swr';
4+
import { SessionDetailClient } from '@/app/sessions/[id]/SessionDetailClient';
5+
import { sessionsApi } from '@/lib/api';
6+
import type { Session } from '@/types';
7+
8+
// ── Mocks ────────────────────────────────────────────────────────────────
9+
10+
jest.mock('next/navigation', () => ({
11+
useRouter: jest.fn(),
12+
}));
13+
14+
jest.mock('swr');
15+
16+
jest.mock('@/lib/api', () => ({
17+
sessionsApi: {
18+
getOne: jest.fn(),
19+
end: jest.fn(),
20+
getMessages: jest.fn(),
21+
},
22+
}));
23+
24+
jest.mock('@/components/sessions/AgentChatPanel', () => ({
25+
AgentChatPanel: ({
26+
sessionId,
27+
readOnly,
28+
}: {
29+
sessionId: string;
30+
readOnly?: boolean;
31+
}) => (
32+
<div
33+
data-testid="agent-chat-panel"
34+
data-session-id={sessionId}
35+
data-read-only={readOnly ? 'true' : 'false'}
36+
>
37+
{!readOnly && <textarea aria-label="Message input" />}
38+
</div>
39+
),
40+
}));
41+
42+
jest.mock('@/components/sessions/AgentTerminal', () => ({
43+
AgentTerminal: ({ sessionId }: { sessionId: string }) => (
44+
<div data-testid="agent-terminal" data-session-id={sessionId} />
45+
),
46+
}));
47+
48+
jest.mock('@/components/sessions/SplitPane', () => ({
49+
SplitPane: ({
50+
left,
51+
right,
52+
storageKey,
53+
}: {
54+
left: React.ReactNode;
55+
right: React.ReactNode;
56+
storageKey?: string;
57+
}) => (
58+
<div data-testid="split-pane" data-storage-key={storageKey}>
59+
<div data-testid="split-pane-left">{left}</div>
60+
<div data-testid="split-pane-right">{right}</div>
61+
</div>
62+
),
63+
}));
64+
65+
const mockUseRouter = useRouter as jest.MockedFunction<typeof useRouter>;
66+
const mockUseSWR = useSWR as jest.MockedFunction<typeof useSWR>;
67+
const mockSessApiEnd = sessionsApi.end as jest.MockedFunction<typeof sessionsApi.end>;
68+
const mockSessApiGetMessages = sessionsApi.getMessages as jest.MockedFunction<
69+
typeof sessionsApi.getMessages
70+
>;
71+
72+
function swrResult(overrides: {
73+
data?: unknown;
74+
isLoading?: boolean;
75+
error?: unknown;
76+
}): ReturnType<typeof useSWR> {
77+
return {
78+
data: overrides.data ?? undefined,
79+
isLoading: overrides.isLoading ?? false,
80+
error: overrides.error ?? null,
81+
mutate: jest.fn(),
82+
isValidating: false,
83+
} as unknown as ReturnType<typeof useSWR>;
84+
}
85+
86+
const SESSION_ID = 'session-abc123def456';
87+
const SHORT_ID = SESSION_ID.slice(-8);
88+
89+
function makeSession(overrides: Partial<Session> = {}): Session {
90+
return {
91+
id: SESSION_ID,
92+
state: 'active',
93+
workspace_path: '/home/user/myproject',
94+
model: 'claude-sonnet-4-6',
95+
created_at: '2026-04-01T10:00:00Z',
96+
ended_at: null,
97+
cost_usd: 0.0123,
98+
agent_name: null,
99+
...overrides,
100+
};
101+
}
102+
103+
const mockRouterPush = jest.fn();
104+
105+
function setupRouter() {
106+
mockUseRouter.mockReturnValue({
107+
push: mockRouterPush,
108+
replace: jest.fn(),
109+
back: jest.fn(),
110+
forward: jest.fn(),
111+
refresh: jest.fn(),
112+
prefetch: jest.fn(),
113+
} as ReturnType<typeof useRouter>);
114+
}
115+
116+
// ── Tests ────────────────────────────────────────────────────────────────
117+
118+
describe('SessionDetailClient', () => {
119+
beforeEach(() => {
120+
jest.clearAllMocks();
121+
setupRouter();
122+
});
123+
124+
// ── Loading state ────────────────────────────────────────────────────
125+
126+
it('shows loading skeleton while session data is fetching', () => {
127+
mockUseSWR.mockReturnValue(swrResult({ isLoading: true }));
128+
render(<SessionDetailClient sessionId={SESSION_ID} />);
129+
expect(screen.getByTestId('session-detail-skeleton')).toBeInTheDocument();
130+
});
131+
132+
// ── Active session ───────────────────────────────────────────────────
133+
134+
it('renders header with back link for active session', () => {
135+
const session = makeSession();
136+
mockUseSWR.mockReturnValue(swrResult({ data: session }));
137+
render(<SessionDetailClient sessionId={SESSION_ID} />);
138+
expect(screen.getByRole('link', { name: /sessions/i })).toHaveAttribute('href', '/sessions');
139+
});
140+
141+
it('renders session short ID in header', () => {
142+
const session = makeSession();
143+
mockUseSWR.mockReturnValue(swrResult({ data: session }));
144+
render(<SessionDetailClient sessionId={SESSION_ID} />);
145+
expect(screen.getByText(new RegExp(SHORT_ID))).toBeInTheDocument();
146+
});
147+
148+
it('renders active state badge for active session', () => {
149+
const session = makeSession({ state: 'active' });
150+
mockUseSWR.mockReturnValue(swrResult({ data: session }));
151+
render(<SessionDetailClient sessionId={SESSION_ID} />);
152+
expect(screen.getByText('active')).toBeInTheDocument();
153+
});
154+
155+
it('renders SplitPane with AgentChatPanel and AgentTerminal for active session', () => {
156+
const session = makeSession({ state: 'active' });
157+
mockUseSWR.mockReturnValue(swrResult({ data: session }));
158+
render(<SessionDetailClient sessionId={SESSION_ID} />);
159+
expect(screen.getByTestId('split-pane')).toBeInTheDocument();
160+
expect(screen.getByTestId('agent-chat-panel')).toBeInTheDocument();
161+
expect(screen.getByTestId('agent-terminal')).toBeInTheDocument();
162+
});
163+
164+
it('passes session-specific storageKey to SplitPane', () => {
165+
const session = makeSession();
166+
mockUseSWR.mockReturnValue(swrResult({ data: session }));
167+
render(<SessionDetailClient sessionId={SESSION_ID} />);
168+
expect(screen.getByTestId('split-pane')).toHaveAttribute(
169+
'data-storage-key',
170+
`session-split-${SESSION_ID}`
171+
);
172+
});
173+
174+
it('passes sessionId to AgentChatPanel and AgentTerminal', () => {
175+
const session = makeSession();
176+
mockUseSWR.mockReturnValue(swrResult({ data: session }));
177+
render(<SessionDetailClient sessionId={SESSION_ID} />);
178+
expect(screen.getByTestId('agent-chat-panel')).toHaveAttribute('data-session-id', SESSION_ID);
179+
expect(screen.getByTestId('agent-terminal')).toHaveAttribute('data-session-id', SESSION_ID);
180+
});
181+
182+
it('renders input bar for active session (not read-only)', () => {
183+
const session = makeSession({ state: 'active' });
184+
mockUseSWR.mockReturnValue(swrResult({ data: session }));
185+
render(<SessionDetailClient sessionId={SESSION_ID} />);
186+
expect(screen.getByTestId('agent-chat-panel')).toHaveAttribute('data-read-only', 'false');
187+
});
188+
189+
// ── End Session ──────────────────────────────────────────────────────
190+
191+
it('renders enabled End Session button for active session', () => {
192+
const session = makeSession({ state: 'active' });
193+
mockUseSWR.mockReturnValue(swrResult({ data: session }));
194+
render(<SessionDetailClient sessionId={SESSION_ID} />);
195+
const btn = screen.getByRole('button', { name: /end session/i });
196+
expect(btn).toBeEnabled();
197+
});
198+
199+
it('calls sessionsApi.end and redirects to /sessions on End Session click', async () => {
200+
mockSessApiEnd.mockResolvedValue(undefined);
201+
const session = makeSession({ state: 'active' });
202+
mockUseSWR.mockReturnValue(swrResult({ data: session }));
203+
render(<SessionDetailClient sessionId={SESSION_ID} />);
204+
fireEvent.click(screen.getByRole('button', { name: /end session/i }));
205+
await waitFor(() => {
206+
expect(mockSessApiEnd).toHaveBeenCalledWith(SESSION_ID);
207+
expect(mockRouterPush).toHaveBeenCalledWith('/sessions');
208+
});
209+
});
210+
211+
it('shows error message and re-enables button when sessionsApi.end rejects', async () => {
212+
mockSessApiEnd.mockRejectedValue(new Error('Network error'));
213+
const session = makeSession({ state: 'active' });
214+
mockUseSWR.mockReturnValue(swrResult({ data: session }));
215+
render(<SessionDetailClient sessionId={SESSION_ID} />);
216+
fireEvent.click(screen.getByRole('button', { name: /end session/i }));
217+
await waitFor(() => {
218+
expect(mockRouterPush).not.toHaveBeenCalled();
219+
expect(screen.getByText(/failed to end session/i)).toBeInTheDocument();
220+
expect(screen.getByRole('button', { name: /end session/i })).toBeEnabled();
221+
});
222+
});
223+
224+
// ── Ended session ────────────────────────────────────────────────────
225+
226+
it('renders ended state for ended session', () => {
227+
const session = makeSession({ state: 'ended', ended_at: '2026-04-01T11:00:00Z' });
228+
mockUseSWR.mockReturnValue(swrResult({ data: session }));
229+
mockSessApiGetMessages.mockResolvedValue([]);
230+
render(<SessionDetailClient sessionId={SESSION_ID} />);
231+
// The badge shows the state and the banner also mentions 'ended'
232+
expect(screen.getAllByText(/ended/i).length).toBeGreaterThanOrEqual(1);
233+
});
234+
235+
it('renders "session ended" banner for ended session', () => {
236+
const session = makeSession({ state: 'ended', ended_at: '2026-04-01T11:00:00Z' });
237+
mockUseSWR.mockReturnValue(swrResult({ data: session }));
238+
mockSessApiGetMessages.mockResolvedValue([]);
239+
render(<SessionDetailClient sessionId={SESSION_ID} />);
240+
expect(screen.getByText(/this session has ended/i)).toBeInTheDocument();
241+
});
242+
243+
it('does not render AgentTerminal for ended session', () => {
244+
const session = makeSession({ state: 'ended', ended_at: '2026-04-01T11:00:00Z' });
245+
mockUseSWR.mockReturnValue(swrResult({ data: session }));
246+
mockSessApiGetMessages.mockResolvedValue([]);
247+
render(<SessionDetailClient sessionId={SESSION_ID} />);
248+
expect(screen.queryByTestId('agent-terminal')).not.toBeInTheDocument();
249+
});
250+
251+
it('renders AgentChatPanel in read-only mode for ended session', () => {
252+
const session = makeSession({ state: 'ended', ended_at: '2026-04-01T11:00:00Z' });
253+
mockUseSWR.mockReturnValue(swrResult({ data: session }));
254+
mockSessApiGetMessages.mockResolvedValue([]);
255+
render(<SessionDetailClient sessionId={SESSION_ID} />);
256+
expect(screen.getByTestId('agent-chat-panel')).toHaveAttribute('data-read-only', 'true');
257+
});
258+
259+
it('renders disabled End Session button for ended session', () => {
260+
const session = makeSession({ state: 'ended', ended_at: '2026-04-01T11:00:00Z' });
261+
mockUseSWR.mockReturnValue(swrResult({ data: session }));
262+
mockSessApiGetMessages.mockResolvedValue([]);
263+
render(<SessionDetailClient sessionId={SESSION_ID} />);
264+
const btn = screen.getByRole('button', { name: /end session/i });
265+
expect(btn).toBeDisabled();
266+
});
267+
268+
// ── Error state ──────────────────────────────────────────────────────
269+
270+
it('renders "Session not found" error when fetch returns 404-like error', () => {
271+
mockUseSWR.mockReturnValue(
272+
swrResult({ error: { status: 404, detail: 'Session not found' } })
273+
);
274+
render(<SessionDetailClient sessionId={SESSION_ID} />);
275+
expect(screen.getByText(/session not found/i)).toBeInTheDocument();
276+
// Multiple back links exist (header + error body) — all point to /sessions
277+
const links = screen.getAllByRole('link', { name: /back to sessions/i });
278+
expect(links.length).toBeGreaterThanOrEqual(1);
279+
links.forEach((link) => expect(link).toHaveAttribute('href', '/sessions'));
280+
});
281+
282+
it('renders generic error state for non-404 errors', () => {
283+
mockUseSWR.mockReturnValue(
284+
swrResult({ error: { status: 500, detail: 'Internal server error' } })
285+
);
286+
render(<SessionDetailClient sessionId={SESSION_ID} />);
287+
expect(screen.getByText(/failed to load session/i)).toBeInTheDocument();
288+
});
289+
290+
// ── Page title ───────────────────────────────────────────────────────
291+
292+
it('includes session short ID in the header', () => {
293+
const session = makeSession();
294+
mockUseSWR.mockReturnValue(swrResult({ data: session }));
295+
render(<SessionDetailClient sessionId={SESSION_ID} />);
296+
// Short ID appears in the header "Session #<shortId>" text
297+
expect(screen.getByText(new RegExp(SHORT_ID))).toBeInTheDocument();
298+
});
299+
300+
it('generateMetadata returns title with session short ID', async () => {
301+
// Import and test generateMetadata directly
302+
const { generateMetadata } = await import('@/app/sessions/[id]/page');
303+
const meta = await generateMetadata({ params: Promise.resolve({ id: SESSION_ID }) });
304+
expect((meta as { title: string }).title).toContain(SHORT_ID);
305+
});
306+
});

0 commit comments

Comments
 (0)