diff --git a/web-ui/src/__tests__/components/sessions/SessionDetailPage.test.tsx b/web-ui/src/__tests__/components/sessions/SessionDetailPage.test.tsx
new file mode 100644
index 00000000..a529059f
--- /dev/null
+++ b/web-ui/src/__tests__/components/sessions/SessionDetailPage.test.tsx
@@ -0,0 +1,306 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { useRouter } from 'next/navigation';
+import useSWR from 'swr';
+import { SessionDetailClient } from '@/app/sessions/[id]/SessionDetailClient';
+import { sessionsApi } from '@/lib/api';
+import type { Session } from '@/types';
+
+// ── Mocks ────────────────────────────────────────────────────────────────
+
+jest.mock('next/navigation', () => ({
+ useRouter: jest.fn(),
+}));
+
+jest.mock('swr');
+
+jest.mock('@/lib/api', () => ({
+ sessionsApi: {
+ getOne: jest.fn(),
+ end: jest.fn(),
+ getMessages: jest.fn(),
+ },
+}));
+
+jest.mock('@/components/sessions/AgentChatPanel', () => ({
+ AgentChatPanel: ({
+ sessionId,
+ readOnly,
+ }: {
+ sessionId: string;
+ readOnly?: boolean;
+ }) => (
+
+ {!readOnly && }
+
+ ),
+}));
+
+jest.mock('@/components/sessions/AgentTerminal', () => ({
+ AgentTerminal: ({ sessionId }: { sessionId: string }) => (
+
+ ),
+}));
+
+jest.mock('@/components/sessions/SplitPane', () => ({
+ SplitPane: ({
+ left,
+ right,
+ storageKey,
+ }: {
+ left: React.ReactNode;
+ right: React.ReactNode;
+ storageKey?: string;
+ }) => (
+
+ ),
+}));
+
+const mockUseRouter = useRouter as jest.MockedFunction;
+const mockUseSWR = useSWR as jest.MockedFunction;
+const mockSessApiEnd = sessionsApi.end as jest.MockedFunction;
+const mockSessApiGetMessages = sessionsApi.getMessages as jest.MockedFunction<
+ typeof sessionsApi.getMessages
+>;
+
+function swrResult(overrides: {
+ data?: unknown;
+ isLoading?: boolean;
+ error?: unknown;
+}): ReturnType {
+ return {
+ data: overrides.data ?? undefined,
+ isLoading: overrides.isLoading ?? false,
+ error: overrides.error ?? null,
+ mutate: jest.fn(),
+ isValidating: false,
+ } as unknown as ReturnType;
+}
+
+const SESSION_ID = 'session-abc123def456';
+const SHORT_ID = SESSION_ID.slice(-8);
+
+function makeSession(overrides: Partial = {}): Session {
+ return {
+ id: SESSION_ID,
+ state: 'active',
+ workspace_path: '/home/user/myproject',
+ model: 'claude-sonnet-4-6',
+ created_at: '2026-04-01T10:00:00Z',
+ ended_at: null,
+ cost_usd: 0.0123,
+ agent_name: null,
+ ...overrides,
+ };
+}
+
+const mockRouterPush = jest.fn();
+
+function setupRouter() {
+ mockUseRouter.mockReturnValue({
+ push: mockRouterPush,
+ replace: jest.fn(),
+ back: jest.fn(),
+ forward: jest.fn(),
+ refresh: jest.fn(),
+ prefetch: jest.fn(),
+ } as ReturnType);
+}
+
+// ── Tests ────────────────────────────────────────────────────────────────
+
+describe('SessionDetailClient', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ setupRouter();
+ });
+
+ // ── Loading state ────────────────────────────────────────────────────
+
+ it('shows loading skeleton while session data is fetching', () => {
+ mockUseSWR.mockReturnValue(swrResult({ isLoading: true }));
+ render();
+ expect(screen.getByTestId('session-detail-skeleton')).toBeInTheDocument();
+ });
+
+ // ── Active session ───────────────────────────────────────────────────
+
+ it('renders header with back link for active session', () => {
+ const session = makeSession();
+ mockUseSWR.mockReturnValue(swrResult({ data: session }));
+ render();
+ expect(screen.getByRole('link', { name: /sessions/i })).toHaveAttribute('href', '/sessions');
+ });
+
+ it('renders session short ID in header', () => {
+ const session = makeSession();
+ mockUseSWR.mockReturnValue(swrResult({ data: session }));
+ render();
+ expect(screen.getByText(new RegExp(SHORT_ID))).toBeInTheDocument();
+ });
+
+ it('renders active state badge for active session', () => {
+ const session = makeSession({ state: 'active' });
+ mockUseSWR.mockReturnValue(swrResult({ data: session }));
+ render();
+ expect(screen.getByText('active')).toBeInTheDocument();
+ });
+
+ it('renders SplitPane with AgentChatPanel and AgentTerminal for active session', () => {
+ const session = makeSession({ state: 'active' });
+ mockUseSWR.mockReturnValue(swrResult({ data: session }));
+ render();
+ expect(screen.getByTestId('split-pane')).toBeInTheDocument();
+ expect(screen.getByTestId('agent-chat-panel')).toBeInTheDocument();
+ expect(screen.getByTestId('agent-terminal')).toBeInTheDocument();
+ });
+
+ it('passes session-specific storageKey to SplitPane', () => {
+ const session = makeSession();
+ mockUseSWR.mockReturnValue(swrResult({ data: session }));
+ render();
+ expect(screen.getByTestId('split-pane')).toHaveAttribute(
+ 'data-storage-key',
+ `session-split-${SESSION_ID}`
+ );
+ });
+
+ it('passes sessionId to AgentChatPanel and AgentTerminal', () => {
+ const session = makeSession();
+ mockUseSWR.mockReturnValue(swrResult({ data: session }));
+ render();
+ expect(screen.getByTestId('agent-chat-panel')).toHaveAttribute('data-session-id', SESSION_ID);
+ expect(screen.getByTestId('agent-terminal')).toHaveAttribute('data-session-id', SESSION_ID);
+ });
+
+ it('renders input bar for active session (not read-only)', () => {
+ const session = makeSession({ state: 'active' });
+ mockUseSWR.mockReturnValue(swrResult({ data: session }));
+ render();
+ expect(screen.getByTestId('agent-chat-panel')).toHaveAttribute('data-read-only', 'false');
+ });
+
+ // ── End Session ──────────────────────────────────────────────────────
+
+ it('renders enabled End Session button for active session', () => {
+ const session = makeSession({ state: 'active' });
+ mockUseSWR.mockReturnValue(swrResult({ data: session }));
+ render();
+ const btn = screen.getByRole('button', { name: /end session/i });
+ expect(btn).toBeEnabled();
+ });
+
+ it('calls sessionsApi.end and redirects to /sessions on End Session click', async () => {
+ mockSessApiEnd.mockResolvedValue(undefined);
+ const session = makeSession({ state: 'active' });
+ mockUseSWR.mockReturnValue(swrResult({ data: session }));
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /end session/i }));
+ await waitFor(() => {
+ expect(mockSessApiEnd).toHaveBeenCalledWith(SESSION_ID);
+ expect(mockRouterPush).toHaveBeenCalledWith('/sessions');
+ });
+ });
+
+ it('shows error message and re-enables button when sessionsApi.end rejects', async () => {
+ mockSessApiEnd.mockRejectedValue(new Error('Network error'));
+ const session = makeSession({ state: 'active' });
+ mockUseSWR.mockReturnValue(swrResult({ data: session }));
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /end session/i }));
+ await waitFor(() => {
+ expect(mockRouterPush).not.toHaveBeenCalled();
+ expect(screen.getByText(/failed to end session/i)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /end session/i })).toBeEnabled();
+ });
+ });
+
+ // ── Ended session ────────────────────────────────────────────────────
+
+ it('renders ended state for ended session', () => {
+ const session = makeSession({ state: 'ended', ended_at: '2026-04-01T11:00:00Z' });
+ mockUseSWR.mockReturnValue(swrResult({ data: session }));
+ mockSessApiGetMessages.mockResolvedValue([]);
+ render();
+ // The badge shows the state and the banner also mentions 'ended'
+ expect(screen.getAllByText(/ended/i).length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('renders "session ended" banner for ended session', () => {
+ const session = makeSession({ state: 'ended', ended_at: '2026-04-01T11:00:00Z' });
+ mockUseSWR.mockReturnValue(swrResult({ data: session }));
+ mockSessApiGetMessages.mockResolvedValue([]);
+ render();
+ expect(screen.getByText(/this session has ended/i)).toBeInTheDocument();
+ });
+
+ it('does not render AgentTerminal for ended session', () => {
+ const session = makeSession({ state: 'ended', ended_at: '2026-04-01T11:00:00Z' });
+ mockUseSWR.mockReturnValue(swrResult({ data: session }));
+ mockSessApiGetMessages.mockResolvedValue([]);
+ render();
+ expect(screen.queryByTestId('agent-terminal')).not.toBeInTheDocument();
+ });
+
+ it('renders AgentChatPanel in read-only mode for ended session', () => {
+ const session = makeSession({ state: 'ended', ended_at: '2026-04-01T11:00:00Z' });
+ mockUseSWR.mockReturnValue(swrResult({ data: session }));
+ mockSessApiGetMessages.mockResolvedValue([]);
+ render();
+ expect(screen.getByTestId('agent-chat-panel')).toHaveAttribute('data-read-only', 'true');
+ });
+
+ it('renders disabled End Session button for ended session', () => {
+ const session = makeSession({ state: 'ended', ended_at: '2026-04-01T11:00:00Z' });
+ mockUseSWR.mockReturnValue(swrResult({ data: session }));
+ mockSessApiGetMessages.mockResolvedValue([]);
+ render();
+ const btn = screen.getByRole('button', { name: /end session/i });
+ expect(btn).toBeDisabled();
+ });
+
+ // ── Error state ──────────────────────────────────────────────────────
+
+ it('renders "Session not found" error when fetch returns 404-like error', () => {
+ mockUseSWR.mockReturnValue(
+ swrResult({ error: { status: 404, detail: 'Session not found' } })
+ );
+ render();
+ expect(screen.getByText(/session not found/i)).toBeInTheDocument();
+ // Multiple back links exist (header + error body) — all point to /sessions
+ const links = screen.getAllByRole('link', { name: /back to sessions/i });
+ expect(links.length).toBeGreaterThanOrEqual(1);
+ links.forEach((link) => expect(link).toHaveAttribute('href', '/sessions'));
+ });
+
+ it('renders generic error state for non-404 errors', () => {
+ mockUseSWR.mockReturnValue(
+ swrResult({ error: { status: 500, detail: 'Internal server error' } })
+ );
+ render();
+ expect(screen.getByText(/failed to load session/i)).toBeInTheDocument();
+ });
+
+ // ── Page title ───────────────────────────────────────────────────────
+
+ it('includes session short ID in the header', () => {
+ const session = makeSession();
+ mockUseSWR.mockReturnValue(swrResult({ data: session }));
+ render();
+ // Short ID appears in the header "Session #" text
+ expect(screen.getByText(new RegExp(SHORT_ID))).toBeInTheDocument();
+ });
+
+ it('generateMetadata returns title with session short ID', async () => {
+ // Import and test generateMetadata directly
+ const { generateMetadata } = await import('@/app/sessions/[id]/page');
+ const meta = await generateMetadata({ params: Promise.resolve({ id: SESSION_ID }) });
+ expect((meta as { title: string }).title).toContain(SHORT_ID);
+ });
+});
diff --git a/web-ui/src/app/sessions/[id]/SessionDetailClient.tsx b/web-ui/src/app/sessions/[id]/SessionDetailClient.tsx
new file mode 100644
index 00000000..bf1173cd
--- /dev/null
+++ b/web-ui/src/app/sessions/[id]/SessionDetailClient.tsx
@@ -0,0 +1,214 @@
+'use client';
+
+import { useEffect, useRef, useState, useCallback } from 'react';
+import { useRouter } from 'next/navigation';
+import Link from 'next/link';
+import useSWR from 'swr';
+import { ArrowLeft01Icon } from '@hugeicons/react';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { AgentChatPanel } from '@/components/sessions/AgentChatPanel';
+import { AgentTerminal } from '@/components/sessions/AgentTerminal';
+import { SplitPane } from '@/components/sessions/SplitPane';
+import { sessionsApi } from '@/lib/api';
+import type { ChatMessage, Session } from '@/types';
+
+// ── Helpers ──────────────────────────────────────────────────────────────
+
+function shortId(id: string): string {
+ return id.slice(-8);
+}
+
+function stateBadgeVariant(state: Session['state']): 'default' | 'secondary' | 'outline' {
+ if (state === 'active') return 'default';
+ if (state === 'ended') return 'secondary';
+ return 'outline';
+}
+
+// ── Loading skeleton ──────────────────────────────────────────────────────
+
+function SessionDetailSkeleton() {
+ return (
+
+ {/* Header skeleton */}
+
+ {/* Body skeleton */}
+
+
+ );
+}
+
+// ── Main component ────────────────────────────────────────────────────────
+
+interface SessionDetailClientProps {
+ sessionId: string;
+}
+
+export function SessionDetailClient({ sessionId }: SessionDetailClientProps) {
+ const router = useRouter();
+
+ const { data: session, isLoading, error } = useSWR(
+ sessionId ? `/api/v2/sessions/${sessionId}` : null,
+ () => sessionsApi.getOne(sessionId),
+ { refreshInterval: (data) => (data?.state === 'active' ? 5000 : 0) }
+ );
+
+ const [endingSession, setEndingSession] = useState(false);
+ const [endError, setEndError] = useState(null);
+ const [historyMessages, setHistoryMessages] = useState(undefined);
+ const messagesFetchedRef = useRef(false);
+
+ // Load message history for ended sessions via REST (once per mount)
+ useEffect(() => {
+ if (session?.state === 'ended' && !messagesFetchedRef.current) {
+ messagesFetchedRef.current = true;
+ sessionsApi
+ .getMessages(session.id)
+ .then(setHistoryMessages)
+ .catch(() => setHistoryMessages([]));
+ }
+ }, [session]);
+
+ const handleEndSession = useCallback(async () => {
+ if (!session || session.state !== 'active') return;
+ setEndingSession(true);
+ setEndError(null);
+ try {
+ await sessionsApi.end(session.id);
+ router.push('/sessions');
+ } catch {
+ setEndError('Failed to end session. Please try again.');
+ } finally {
+ setEndingSession(false);
+ }
+ }, [session, router]);
+
+ // ── Loading ──────────────────────────────────────────────────────────
+
+ if (isLoading) {
+ return ;
+ }
+
+ // ── Error ────────────────────────────────────────────────────────────
+
+ if (error || !session) {
+ const isNotFound = error?.status === 404 || error?.detail === 'Session not found';
+ return (
+
+
+
+
+
+
+ {isNotFound ? (
+ <>
+
Session not found
+
+ This session may have been deleted or the ID is incorrect.
+
+
+ >
+ ) : (
+ <>
+
Failed to load session
+
+ {error?.detail ?? 'An unexpected error occurred.'}
+
+ >
+ )}
+
+
+
+ );
+ }
+
+ // ── Loaded ───────────────────────────────────────────────────────────
+
+ const isActive = session.state === 'active';
+ const sid = shortId(session.id);
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Ended session banner */}
+ {!isActive && (
+
+ This session has ended.{' '}
+
+ View history
+
+
+ )}
+
+ {/* Body — SplitPane for active, chat-only for ended */}
+
+ {isActive ? (
+
}
+ right={
}
+ defaultSplit={45}
+ storageKey={`session-split-${session.id}`}
+ className="h-full"
+ />
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/web-ui/src/app/sessions/[id]/page.tsx b/web-ui/src/app/sessions/[id]/page.tsx
index bbf07f87..7aed0045 100644
--- a/web-ui/src/app/sessions/[id]/page.tsx
+++ b/web-ui/src/app/sessions/[id]/page.tsx
@@ -1,34 +1,17 @@
-'use client';
+import type { Metadata } from 'next';
+import { SessionDetailClient } from './SessionDetailClient';
-import { useParams } from 'next/navigation';
-import Link from 'next/link';
-import { ArrowLeft01Icon } from '@hugeicons/react';
-import { Button } from '@/components/ui/button';
+interface PageProps {
+ params: Promise<{ id: string }>;
+}
-export default function SessionDetailPage() {
- const params = useParams<{ id: string }>();
- const sessionId = params.id;
+export async function generateMetadata({ params }: PageProps): Promise {
+ const { id } = await params;
+ const shortId = id.slice(-8);
+ return { title: `Session #${shortId} — CodeFRAME` };
+}
- return (
-
-
-
-
-
-
-
- Session {sessionId?.slice(-8)}
-
-
- Session detail view coming soon.
-
-
-
-
- );
+export default async function SessionDetailPage({ params }: PageProps) {
+ const { id } = await params;
+ return ;
}
diff --git a/web-ui/src/components/sessions/AgentChatPanel.tsx b/web-ui/src/components/sessions/AgentChatPanel.tsx
index 342fda88..3b1dd55a 100644
--- a/web-ui/src/components/sessions/AgentChatPanel.tsx
+++ b/web-ui/src/components/sessions/AgentChatPanel.tsx
@@ -19,6 +19,10 @@ import type { ChatMessage, AgentChatStatus } from '@/types';
interface AgentChatPanelProps {
sessionId: string;
className?: string;
+ /** When true, hides the input bar (e.g. for ended sessions). */
+ readOnly?: boolean;
+ /** Pre-loaded messages to display instead of live WebSocket messages. */
+ initialMessages?: ChatMessage[];
}
// ── Status dot ───────────────────────────────────────────────────────────
@@ -192,9 +196,17 @@ function MessageRow({
// ── Main component ────────────────────────────────────────────────────────
-export function AgentChatPanel({ sessionId, className }: AgentChatPanelProps) {
- const { state, sendMessage, interrupt } = useAgentChat(sessionId);
- const { messages, status, costUsd } = state;
+export function AgentChatPanel({
+ sessionId,
+ className,
+ readOnly = false,
+ initialMessages,
+}: AgentChatPanelProps) {
+ const { state, sendMessage, interrupt } = useAgentChat(
+ readOnly ? null : sessionId
+ );
+ const { status, costUsd } = state;
+ const messages = initialMessages ?? state.messages;
const [value, setValue] = useState('');
const [autoScroll, setAutoScroll] = useState(true);
@@ -292,43 +304,45 @@ export function AgentChatPanel({ sessionId, className }: AgentChatPanelProps) {
- {/* Input bar */}
-
-
- {isBusy && (
+ {/* Input bar — hidden in read-only mode */}
+ {!readOnly && (
+
+
+ {isBusy && (
+
+ )}
+
- )}
-
-
+
-
+ )}
);
}
diff --git a/web-ui/src/lib/api.ts b/web-ui/src/lib/api.ts
index eaaaf4e6..b8be6b6c 100644
--- a/web-ui/src/lib/api.ts
+++ b/web-ui/src/lib/api.ts
@@ -47,6 +47,7 @@ import type {
SessionState,
SessionListResponse,
SessionCreateRequest,
+ ChatMessage,
} from '@/types';
// FastAPI validation error format
@@ -653,6 +654,39 @@ export const sessionsApi = {
return response.data;
},
+ /**
+ * Get a single session by ID
+ */
+ getOne: async (id: string): Promise => {
+ const response = await api.get(
+ `/api/v2/sessions/${encodeURIComponent(id)}`
+ );
+ return response.data;
+ },
+
+ /**
+ * Get message history for a session (REST, for ended sessions)
+ */
+ getMessages: async (
+ id: string,
+ params?: { limit?: number; offset?: number }
+ ): Promise => {
+ const response = await api.get<
+ Array<{
+ id: string;
+ role: string;
+ content: string;
+ created_at: string;
+ }>
+ >(`/api/v2/sessions/${encodeURIComponent(id)}/messages`, { params });
+ return response.data.map((m) => ({
+ id: m.id,
+ role: m.role as ChatMessage['role'],
+ content: m.content,
+ createdAt: m.created_at,
+ }));
+ },
+
/**
* End (delete) a session
*/