diff --git a/dashboard/src/__tests__/SessionDetailPage.test.tsx b/dashboard/src/__tests__/SessionDetailPage.test.tsx index de372198..feda2673 100644 --- a/dashboard/src/__tests__/SessionDetailPage.test.tsx +++ b/dashboard/src/__tests__/SessionDetailPage.test.tsx @@ -52,6 +52,10 @@ vi.mock('../components/session/SessionMetricsPanel', () => ({ vi.mock('../components/session/ApprovalBanner', () => ({ ApprovalBanner: () =>
approval
, })); +vi.mock('../components/session/SessionSummaryCard', () => ({ + SessionSummaryCard: () => null, +})); + function renderPage(): void { render( @@ -106,6 +110,8 @@ describe('SessionDetailPage quick actions', () => { }, metrics: null, metricsLoading: false, + summary: null, + summaryLoading: false, }); }); diff --git a/dashboard/src/__tests__/SessionSummaryCard.test.tsx b/dashboard/src/__tests__/SessionSummaryCard.test.tsx new file mode 100644 index 00000000..86f5ffd0 --- /dev/null +++ b/dashboard/src/__tests__/SessionSummaryCard.test.tsx @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { SessionSummaryCard } from '../components/session/SessionSummaryCard'; +import type { SessionSummary } from '../types'; + +const BASE_SUMMARY: SessionSummary = { + sessionId: 'sess-1', + windowName: 'Test Session', + status: 'idle', + totalMessages: 5, + messages: [ + { role: 'user', contentType: 'text', text: 'Hello' }, + { role: 'user', contentType: 'text', text: 'World' }, + { role: 'assistant', contentType: 'text', text: 'Hi there' }, + { role: 'assistant', contentType: 'text', text: 'How are you?' }, + { role: 'tool', contentType: 'tool_result', text: 'result' }, + ], + createdAt: Date.now() - 65_000, // ~1 minute ago + lastActivity: Date.now() - 10_000, + permissionMode: 'default', +}; + +describe('SessionSummaryCard', () => { + it('renders the loading state', () => { + render(); + expect(screen.getByText('Loading summary\u2026')).toBeDefined(); + }); + + it('renders nothing when summary is null and not loading', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('shows total message count', () => { + const { container } = render(); + expect(container.textContent).toContain('5'); + }); + + it('shows per-role breakdown', () => { + const { container } = render(); + expect(container.textContent).toContain('user'); + expect(container.textContent).toContain('assistant'); + expect(container.textContent).toContain('tool'); + }); + + it('shows the session status label', () => { + render(); + expect(screen.getByText('Idle')).toBeDefined(); + }); + + it('shows the session age', () => { + const { container } = render(); + // createdAt is ~65s ago → formatTimeAgo returns "1m ago" + expect(container.textContent).toContain('1m ago'); + }); + + it('renders the session summary aria label', () => { + render(); + expect(screen.getByRole('region', { name: 'Session summary' })).toBeDefined(); + }); + + it('handles empty messages array gracefully', () => { + const emptySummary: SessionSummary = { + ...BASE_SUMMARY, + totalMessages: 0, + messages: [], + }; + const { container } = render(); + // Should not show role breakdown section + expect(container.textContent).not.toContain('By role'); + expect(container.textContent).toContain('0'); + }); +}); diff --git a/dashboard/src/__tests__/useSessionPolling.test.ts b/dashboard/src/__tests__/useSessionPolling.test.ts index e71f4bff..d9705b4c 100644 --- a/dashboard/src/__tests__/useSessionPolling.test.ts +++ b/dashboard/src/__tests__/useSessionPolling.test.ts @@ -12,6 +12,7 @@ vi.mock('../api/client', () => ({ getSessionHealth: vi.fn(), getSessionPane: vi.fn(), getSessionMetrics: vi.fn(), + getSessionSummary: vi.fn(), subscribeSSE: vi.fn(), })); @@ -23,7 +24,7 @@ vi.mock('../store/useToastStore', () => ({ useToastStore: vi.fn(), })); -import { getSession, getSessionHealth, getSessionPane, getSessionMetrics, subscribeSSE } from '../api/client'; +import { getSession, getSessionHealth, getSessionPane, getSessionMetrics, getSessionSummary, subscribeSSE } from '../api/client'; import { useStore } from '../store/useStore'; import { useToastStore } from '../store/useToastStore'; @@ -31,6 +32,7 @@ const mockedGetSession = vi.mocked(getSession); const mockedGetSessionHealth = vi.mocked(getSessionHealth); const mockedGetSessionPane = vi.mocked(getSessionPane); const mockedGetSessionMetrics = vi.mocked(getSessionMetrics); +const mockedGetSessionSummary = vi.mocked(getSessionSummary); describe('useSessionPolling', () => { let capturedHandler: ((e: MessageEvent) => void) | null = null; @@ -78,6 +80,16 @@ describe('useSessionPolling', () => { autoApprovals: 0, statusChanges: [], }); + mockedGetSessionSummary.mockResolvedValue({ + sessionId: 'session-a', + windowName: 'test', + status: 'idle', + totalMessages: 0, + messages: [], + createdAt: Date.now(), + lastActivity: Date.now(), + permissionMode: 'default', + }); (subscribeSSE as any).mockImplementation( (_sessionId: string, handler: (e: MessageEvent) => void): (() => void) => { diff --git a/dashboard/src/components/session/SessionSummaryCard.tsx b/dashboard/src/components/session/SessionSummaryCard.tsx new file mode 100644 index 00000000..76c2297b --- /dev/null +++ b/dashboard/src/components/session/SessionSummaryCard.tsx @@ -0,0 +1,86 @@ +import type { SessionSummary, UIState } from '../../types'; +import { formatTimeAgo } from '../../utils/format'; +import StatusDot from '../overview/StatusDot'; + +const STATUS_LABELS: Record = { + idle: 'Idle', + working: 'Working', + permission_prompt: 'Permission prompt', + bash_approval: 'Bash approval', + plan_mode: 'Plan mode', + ask_question: 'Awaiting question', + settings: 'Settings', + error: 'Error', + compacting: 'Compacting', + context_warning: 'Context warning', + waiting_for_input: 'Waiting for input', + unknown: 'Unknown', +}; + +interface SessionSummaryCardProps { + summary: SessionSummary | null; + loading: boolean; +} + +export function SessionSummaryCard({ summary, loading }: SessionSummaryCardProps) { + if (loading) { + return ( +
+ Loading summary… +
+ ); + } + + if (!summary) return null; + + // Compute per-role message counts + const roleCounts: Record = {}; + for (const msg of summary.messages) { + roleCounts[msg.role] = (roleCounts[msg.role] ?? 0) + 1; + } + const roles = Object.entries(roleCounts).sort(([a], [b]) => a.localeCompare(b)); + + return ( +
+ {/* Total messages */} +
+ Messages + {summary.totalMessages} +
+ + {/* Per-role breakdown */} + {roles.length > 0 && ( +
+ By role +
+ {roles.map(([role, count]) => ( + + {role}{' '}{count} + + ))} +
+
+ )} + + {/* Status */} +
+ Status + + {STATUS_LABELS[summary.status] ?? summary.status} +
+ + {/* Session age */} +
+ Age + {formatTimeAgo(summary.createdAt)} +
+
+ ); +} diff --git a/dashboard/src/hooks/useSessionPolling.ts b/dashboard/src/hooks/useSessionPolling.ts index bf02cd21..d0e4131f 100644 --- a/dashboard/src/hooks/useSessionPolling.ts +++ b/dashboard/src/hooks/useSessionPolling.ts @@ -1,10 +1,11 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; -import type { SessionInfo, SessionHealth, SessionMetrics } from '../types'; +import { useState, useEffect, useRef, useCallback } from 'react'; +import type { SessionInfo, SessionHealth, SessionMetrics, SessionSummary } from '../types'; import { getSession, getSessionHealth, getSessionPane, getSessionMetrics, + getSessionSummary, subscribeSSE, } from '../api/client'; import { useStore } from '../store/useStore'; @@ -24,6 +25,8 @@ interface UseSessionPollingReturn { paneLoading: boolean; metrics: SessionMetrics | null; metricsLoading: boolean; + summary: SessionSummary | null; + summaryLoading: boolean; refetchPaneAndMetrics: () => void; } @@ -45,6 +48,10 @@ export function useSessionPolling(sessionId: string): UseSessionPollingReturn { const [metrics, setMetrics] = useState(null); const [metricsLoading, setMetricsLoading] = useState(true); + // Summary state + const [summary, setSummary] = useState(null); + const [summaryLoading, setSummaryLoading] = useState(true); + // Refs for stable callbacks const sessionIdRef = useRef(sessionId); sessionIdRef.current = sessionId; @@ -81,7 +88,7 @@ export function useSessionPolling(sessionId: string): UseSessionPollingReturn { }, [addToast]); loadSessionAndHealthRef.current = loadSessionAndHealth; - // Fetch pane + metrics + // Fetch pane + metrics + summary const loadPaneAndMetrics = useCallback(async () => { if (cancelledRef.current) return; const sid = sessionIdRef.current; @@ -103,6 +110,15 @@ export function useSessionPolling(sessionId: string): UseSessionPollingReturn { } finally { if (!cancelledRef.current) setMetricsLoading(false); } + + try { + const data = await getSessionSummary(sid); + if (!cancelledRef.current) setSummary(data); + } catch (e: unknown) { + addToast('warning', 'Failed to load session summary', e instanceof Error ? e.message : undefined); + } finally { + if (!cancelledRef.current) setSummaryLoading(false); + } }, [addToast]); loadPaneAndMetricsRef.current = loadPaneAndMetrics; @@ -113,6 +129,7 @@ export function useSessionPolling(sessionId: string): UseSessionPollingReturn { setLoading(true); setPaneLoading(true); setMetricsLoading(true); + setSummaryLoading(true); loadSessionAndHealth(); loadPaneAndMetrics(); @@ -146,7 +163,7 @@ export function useSessionPolling(sessionId: string): UseSessionPollingReturn { }, 1000); }, []); - // SSE subscription — drives all refetching + // SSE subscription -- drives all refetching useEffect(() => { if (!sessionId) return; @@ -175,12 +192,12 @@ export function useSessionPolling(sessionId: string): UseSessionPollingReturn { break; case 'ended': - // Final state — re-fetch everything immediately + // Final state -- re-fetch everything immediately loadSessionAndHealthRef.current?.(); loadPaneAndMetricsRef.current?.(); break; - // 'heartbeat', 'system', 'hook', 'subagent_start', 'subagent_stop' — no action needed + // 'heartbeat', 'system', 'hook', 'subagent_start', 'subagent_stop' -- no action needed } } catch { // ignore malformed events @@ -199,6 +216,8 @@ export function useSessionPolling(sessionId: string): UseSessionPollingReturn { paneLoading, metrics, metricsLoading, + summary, + summaryLoading, refetchPaneAndMetrics: loadPaneAndMetrics, }; -} +} \ No newline at end of file diff --git a/dashboard/src/pages/SessionDetailPage.tsx b/dashboard/src/pages/SessionDetailPage.tsx index 61e59e8f..d7b97f9e 100644 --- a/dashboard/src/pages/SessionDetailPage.tsx +++ b/dashboard/src/pages/SessionDetailPage.tsx @@ -24,6 +24,7 @@ import { TranscriptViewer } from '../components/session/TranscriptViewer'; import { LiveTerminal } from '../components/session/LiveTerminal'; import { SessionMetricsPanel } from '../components/session/SessionMetricsPanel'; import { ApprovalBanner } from '../components/session/ApprovalBanner'; +import { SessionSummaryCard } from '../components/session/SessionSummaryCard'; interface ScreenshotState { image: string; @@ -48,8 +49,11 @@ export default function SessionDetailPage() { const { session, health, notFound, loading, metrics, metricsLoading, + summary, summaryLoading, } = useSessionPolling(id ?? ''); + + const [msgInput, setMsgInput] = useState(''); const [sending, setSending] = useState(false); const [selectedSlashCommand, setSelectedSlashCommand] = useState(COMMON_SLASH_COMMANDS[0]); @@ -238,6 +242,9 @@ export default function SessionDetailPage() { onKill={handleKill} /> + {/* Session summary */} + + {/* Tab bar — full-width stretch on mobile */}
{TABS.map(tab => (