Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions dashboard/src/__tests__/SessionDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ vi.mock('../components/session/SessionMetricsPanel', () => ({
vi.mock('../components/session/ApprovalBanner', () => ({
ApprovalBanner: () => <div data-testid="approval-banner">approval</div>,
}));
vi.mock('../components/session/SessionSummaryCard', () => ({
SessionSummaryCard: () => null,
}));


function renderPage(): void {
render(
Expand Down Expand Up @@ -106,6 +110,8 @@ describe('SessionDetailPage quick actions', () => {
},
metrics: null,
metricsLoading: false,
summary: null,
summaryLoading: false,
});
});

Expand Down
73 changes: 73 additions & 0 deletions dashboard/src/__tests__/SessionSummaryCard.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<SessionSummaryCard summary={null} loading={true} />);
expect(screen.getByText('Loading summary\u2026')).toBeDefined();
});

it('renders nothing when summary is null and not loading', () => {
const { container } = render(<SessionSummaryCard summary={null} loading={false} />);
expect(container.firstChild).toBeNull();
});

it('shows total message count', () => {
const { container } = render(<SessionSummaryCard summary={BASE_SUMMARY} loading={false} />);
expect(container.textContent).toContain('5');
});

it('shows per-role breakdown', () => {
const { container } = render(<SessionSummaryCard summary={BASE_SUMMARY} loading={false} />);
expect(container.textContent).toContain('user');
expect(container.textContent).toContain('assistant');
expect(container.textContent).toContain('tool');
});

it('shows the session status label', () => {
render(<SessionSummaryCard summary={BASE_SUMMARY} loading={false} />);
expect(screen.getByText('Idle')).toBeDefined();
});

it('shows the session age', () => {
const { container } = render(<SessionSummaryCard summary={BASE_SUMMARY} loading={false} />);
// createdAt is ~65s ago → formatTimeAgo returns "1m ago"
expect(container.textContent).toContain('1m ago');
});

it('renders the session summary aria label', () => {
render(<SessionSummaryCard summary={BASE_SUMMARY} loading={false} />);
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(<SessionSummaryCard summary={emptySummary} loading={false} />);
// Should not show role breakdown section
expect(container.textContent).not.toContain('By role');
expect(container.textContent).toContain('0');
});
});
14 changes: 13 additions & 1 deletion dashboard/src/__tests__/useSessionPolling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ vi.mock('../api/client', () => ({
getSessionHealth: vi.fn(),
getSessionPane: vi.fn(),
getSessionMetrics: vi.fn(),
getSessionSummary: vi.fn(),
subscribeSSE: vi.fn(),
}));

Expand All @@ -23,14 +24,15 @@ 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';

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;
Expand Down Expand Up @@ -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) => {
Expand Down
86 changes: 86 additions & 0 deletions dashboard/src/components/session/SessionSummaryCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type { SessionSummary, UIState } from '../../types';
import { formatTimeAgo } from '../../utils/format';
import StatusDot from '../overview/StatusDot';

const STATUS_LABELS: Record<UIState, string> = {
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 (
<div className="bg-[#111118] border border-[#1a1a2e] rounded-lg px-4 py-3 animate-pulse text-[#555] text-xs">
Loading summary…
</div>
);
}

if (!summary) return null;

// Compute per-role message counts
const roleCounts: Record<string, number> = {};
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 (
<div
aria-label="Session summary"
role="region"
className="bg-[#111118] border border-[#1a1a2e] rounded-lg px-4 py-3 flex flex-wrap items-center gap-x-6 gap-y-2 text-xs"
>
{/* Total messages */}
<div className="flex items-center gap-1.5">
<span className="text-[#555] uppercase tracking-wider">Messages</span>
<span className="font-mono font-semibold text-[#00e5ff]">{summary.totalMessages}</span>
</div>

{/* Per-role breakdown */}
{roles.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[#555] uppercase tracking-wider">By role</span>
<div className="flex gap-2">
{roles.map(([role, count]) => (
<span
key={role}
className="font-mono text-[#888] bg-[#0a0a0f] border border-[#1a1a2e] rounded px-1.5 py-0.5"
>
{role}{' '}<span className="text-[#00e5ff]">{count}</span>
</span>
))}
</div>
</div>
)}

{/* Status */}
<div className="flex items-center gap-1.5">
<span className="text-[#555] uppercase tracking-wider">Status</span>
<StatusDot status={summary.status} />
<span className="text-[#c0c0d0]">{STATUS_LABELS[summary.status] ?? summary.status}</span>
</div>

{/* Session age */}
<div className="flex items-center gap-1.5">
<span className="text-[#555] uppercase tracking-wider">Age</span>
<span className="text-[#c0c0d0] font-mono">{formatTimeAgo(summary.createdAt)}</span>
</div>
</div>
);
}
33 changes: 26 additions & 7 deletions dashboard/src/hooks/useSessionPolling.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -24,6 +25,8 @@ interface UseSessionPollingReturn {
paneLoading: boolean;
metrics: SessionMetrics | null;
metricsLoading: boolean;
summary: SessionSummary | null;
summaryLoading: boolean;
refetchPaneAndMetrics: () => void;
}

Expand All @@ -45,6 +48,10 @@ export function useSessionPolling(sessionId: string): UseSessionPollingReturn {
const [metrics, setMetrics] = useState<SessionMetrics | null>(null);
const [metricsLoading, setMetricsLoading] = useState(true);

// Summary state
const [summary, setSummary] = useState<SessionSummary | null>(null);
const [summaryLoading, setSummaryLoading] = useState(true);

// Refs for stable callbacks
const sessionIdRef = useRef(sessionId);
sessionIdRef.current = sessionId;
Expand Down Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -113,6 +129,7 @@ export function useSessionPolling(sessionId: string): UseSessionPollingReturn {
setLoading(true);
setPaneLoading(true);
setMetricsLoading(true);
setSummaryLoading(true);

loadSessionAndHealth();
loadPaneAndMetrics();
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand All @@ -199,6 +216,8 @@ export function useSessionPolling(sessionId: string): UseSessionPollingReturn {
paneLoading,
metrics,
metricsLoading,
summary,
summaryLoading,
refetchPaneAndMetrics: loadPaneAndMetrics,
};
}
}
7 changes: 7 additions & 0 deletions dashboard/src/pages/SessionDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string>(COMMON_SLASH_COMMANDS[0]);
Expand Down Expand Up @@ -238,6 +242,9 @@ export default function SessionDetailPage() {
onKill={handleKill}
/>

{/* Session summary */}
<SessionSummaryCard summary={summary} loading={summaryLoading} />

{/* Tab bar — full-width stretch on mobile */}
<div className="flex border-b border-[#1a1a2e]" role="tablist">
{TABS.map(tab => (
Expand Down