From 55611370bb3f5f9875945e1f058bc817ee1fd3dc Mon Sep 17 00:00:00 2001 From: jedarden Date: Tue, 26 May 2026 17:19:58 -0400 Subject: [PATCH] feat(web): add Historical Session Index API and browser UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Phase 6 Historical session index for comparisons. Backend (src/web/server.ts): - GET /api/sessions — list sessions (paginated, with start/end filter) - GET /api/sessions/:id — get single session detail with per-worker summaries - GET /api/sessions/:id/workers — get worker summaries for a session All endpoints use the existing HistoricalStore infrastructure. Frontend (src/web/frontend/src/components/HistoricalSessionsPanel.tsx): - Sessions list table with duration, workers, tasks, cost, tokens, time range - Click-to-expand session detail with worker performance breakdown - Metrics source badge (otlp-metric, otlp-span, log-derived) - Empty state with helpful hint when no sessions exist - Refresh button for manual reload Integration: - Added to App.tsx with Sessions toggle button in header - Command palette action: show:sessions - Follows existing panel patterns (ProductivityPanel, AnalyticsDashboard) Closes: bf-5xch Co-Authored-By: Claude Opus 4.7 --- src/web/frontend/src/App.tsx | 19 ++ .../components/HistoricalSessionsPanel.tsx | 254 ++++++++++++++++++ src/web/server.ts | 80 ++++++ 3 files changed, 353 insertions(+) create mode 100644 src/web/frontend/src/components/HistoricalSessionsPanel.tsx diff --git a/src/web/frontend/src/App.tsx b/src/web/frontend/src/App.tsx index 5cd8843..12f3e72 100644 --- a/src/web/frontend/src/App.tsx +++ b/src/web/frontend/src/App.tsx @@ -21,6 +21,7 @@ import SessionDigestPanel from './components/SessionDigestPanel'; import GitIntegrationPanel from './components/GitIntegrationPanel'; import ProductivityPanel from './components/ProductivityPanel'; import FleetSummaryBar from './components/FleetSummaryBar'; +import HistoricalSessionsPanel from './components/HistoricalSessionsPanel'; import CommandPalette from './components/CommandPalette'; import { extractReplayFromUrl, ReplayExport } from './utils/replayExport'; import { FocusPresetManager, createWebPresetManager, FocusPreset } from './utils/focusPresets'; @@ -260,6 +261,7 @@ const App: React.FC = () => { const [showGitIntegration, setShowGitIntegration] = useState(false); const [showNarrative, setShowNarrative] = useState(false); const [showProductivity, setShowProductivity] = useState(false); + const [showHistoricalSessions, setShowHistoricalSessions] = useState(false); const [budgetBannerDismissed, setBudgetBannerDismissed] = useState(false); const [hideTestWorkers, setHideTestWorkers] = useState(true); @@ -543,6 +545,8 @@ const App: React.FC = () => { setShowGitIntegration(true); } else if (action === 'show:narrative') { setShowNarrative(true); + } else if (action === 'show:sessions') { + setShowHistoricalSessions(true); } else if (action.startsWith('worker:')) { const workerId = action.slice('worker:'.length); setSelectedWorker(workerId); @@ -838,6 +842,14 @@ const App: React.FC = () => { 🏆 Productivity + + + + + + {error &&
{error}
} + +
+ {sessions.length === 0 && !loading && !error && ( +
+

No historical sessions found.

+

Sessions are recorded when workers complete tasks and metrics are finalized.

+
+ )} + + {sessions.length > 0 && ( +
+ + + + + + + + + + + + + + {sessions.map((session) => ( + fetchSessionDetail(session.id)} + style={{ cursor: 'pointer' }} + > + + + + + + + + + ))} + +
Session IDDurationWorkersTasksCostTokensTime Range
+ {session.id.slice(-12)} + + {formatDuration(session.started_at, session.ended_at)} + {session.worker_count}{session.task_count}{formatCost(session.total_cost)}{formatTokens(session.total_tokens)} +
{formatDate(session.started_at)}
+
{formatDate(session.ended_at)}
+
+
+ )} + + {selectedSession && ( +
+
+

Session Details: {selectedSession.id.slice(-12)}

+ +
+ + {detailError &&
{detailError}
} + + {detailLoading &&
Loading session details...
} + + {!detailLoading && selectedSession.workerSummaries.length > 0 && ( +
+
Worker Performance
+ + + + + + + + + + + + + + {selectedSession.workerSummaries.map((summary) => ( + + + + + + + + + + ))} + +
WorkerBeads CompletedBeads FailedErrorsCostTokens (In/Out)Source
+ {summary.workerId} + {summary.beadsCompleted}{summary.beadsFailed}{summary.errors}{formatCost(summary.costUsd)} + {formatTokens(summary.tokensIn)} / {formatTokens(summary.tokensOut)} + + + {summary.metricsSource} + +
+
+ )} + + {!detailLoading && selectedSession.workerSummaries.length === 0 && ( +
+

No worker summaries available for this session.

+
+ )} +
+ )} +
+ + ); +}; + +export default HistoricalSessionsPanel; diff --git a/src/web/server.ts b/src/web/server.ts index a9d2431..2fa1f95 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1307,6 +1307,86 @@ export function createWebServer(options: WebServerOptions): WebServer { } }); + // ============================================ + // Historical Sessions API Endpoints + // ============================================ + + // Get historical sessions list (paginated, with time range filter) + app.get('/api/sessions', (req: Request, res: Response) => { + try { + const historical = store.getHistoricalStore(); + const startTime = req.query.start ? parseInt(req.query.start as string) : undefined; + const endTime = req.query.end ? parseInt(req.query.end as string) : undefined; + const limit = req.query.limit ? parseInt(req.query.limit as string) : 100; + + const sessions = historical.getSessions({ + startTime, + endTime, + limit, + }); + + res.json({ + sessions, + count: sessions.length, + }); + } catch (error) { + console.error('Error fetching sessions:', error); + res.status(500).json({ + error: 'Failed to fetch sessions', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // Get a specific session by ID + app.get('/api/sessions/:id', (req: Request, res: Response) => { + try { + const sessionId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const historical = store.getHistoricalStore(); + const session = historical.getSession(sessionId); + + if (!session) { + res.status(404).json({ error: 'Session not found' }); + return; + } + + // Get worker summaries for this session + const workerSummaries = historical.getSessionWorkerSummaries({ sessionId }); + + res.json({ + session, + workerSummaries, + }); + } catch (error) { + console.error('Error fetching session:', error); + res.status(500).json({ + error: 'Failed to fetch session', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // Get worker summaries for a specific session + app.get('/api/sessions/:id/workers', (req: Request, res: Response) => { + try { + const sessionId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const historical = store.getHistoricalStore(); + const workerSummaries = historical.getSessionWorkerSummaries({ sessionId }); + + res.json({ + sessionId, + workers: workerSummaries, + count: workerSummaries.length, + }); + } catch (error) { + console.error('Error fetching session workers:', error); + res.status(500).json({ + error: 'Failed to fetch session workers', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + // Serve static frontend files const staticPath = join(__dirname, 'public'); app.use(express.static(staticPath));