feat(web): add SessionDigestPanel React component

Port src/tui/components/SessionDigest.ts to React. The panel exposes:
- 5-tab view (Summary, Beads, Files, Errors, Workers) matching TUI output
- Generate Digest button calling /api/digest (GET, no auth required)
- Export to JSON, Markdown, and plain text via browser download
- CSS styles for all digest UI classes in index.css
- Integration in App.tsx via digest-toggle header button and show:digest command

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-24 06:57:03 -04:00
parent 8b3c9adb05
commit 240957c8e0
5 changed files with 1003 additions and 0 deletions

View file

@ -16,6 +16,7 @@ import CostDashboard from './components/CostDashboard';
import AnalyticsDashboard from './components/AnalyticsDashboard';
import ErrorGroupPanel from './components/ErrorGroupPanel';
import BudgetAlertPanel, { BudgetBanner } from './components/BudgetAlertPanel';
import SessionDigestPanel from './components/SessionDigestPanel';
import CommandPalette from './components/CommandPalette';
import { extractReplayFromUrl, ReplayExport } from './utils/replayExport';
import { FocusPresetManager, createWebPresetManager, FocusPreset } from './utils/focusPresets';
@ -251,6 +252,7 @@ const App: React.FC = () => {
const [showCostDashboard, setShowCostDashboard] = useState(false);
const [showErrorGroups, setShowErrorGroups] = useState(false);
const [showBudgetAlert, setShowBudgetAlert] = useState(false);
const [showSessionDigest, setShowSessionDigest] = useState(false);
const [budgetBannerDismissed, setBudgetBannerDismissed] = useState(false);
// Budget alert state polled from /api/cost/summary
@ -527,6 +529,8 @@ const App: React.FC = () => {
setShowErrorGroups(true);
} else if (action === 'show:budget') {
setShowBudgetAlert(true);
} else if (action === 'show:digest') {
setShowSessionDigest(true);
} else if (action.startsWith('worker:')) {
const workerId = action.slice('worker:'.length);
setSelectedWorker(workerId);
@ -790,6 +794,14 @@ const App: React.FC = () => {
<span className="budget-alert-badge">!</span>
)}
</button>
<button
className={`digest-toggle ${showSessionDigest ? 'active' : ''}`}
onClick={() => setShowSessionDigest(!showSessionDigest)}
title="Generate session digest"
>
<span className="digest-toggle-icon">📋</span>
<span className="digest-toggle-label">Digest</span>
</button>
{unacknowledgedAlertCount > 0 && (
<button
className="collision-alert-toggle"
@ -940,6 +952,13 @@ const App: React.FC = () => {
/>
)}
{showSessionDigest && (
<SessionDigestPanel
visible={showSessionDigest}
onClose={() => setShowSessionDigest(false)}
/>
)}
{showSessionReplay && (
<div className="session-replay-panel">
<div className="session-replay-header">

View file

@ -0,0 +1,530 @@
import React, { useState, useCallback } from 'react';
import {
SessionDigestData,
DigestTab,
DigestBeadCompletion,
DigestFileModification,
DigestErrorOccurrence,
DigestWorkerSummary,
ErrorCategory,
} from '../types';
interface SessionDigestPanelProps {
visible: boolean;
onClose: () => void;
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
if (ms < 3600000) {
const mins = Math.floor(ms / 60000);
const secs = Math.floor((ms % 60000) / 1000);
return `${mins}m ${secs}s`;
}
const hours = Math.floor(ms / 3600000);
const mins = Math.floor((ms % 3600000) / 60000);
return `${hours}h ${mins}m`;
}
function formatAsMarkdown(d: SessionDigestData): string {
const lines: string[] = [];
lines.push('# Session Digest');
lines.push('');
lines.push(`**Session ID:** ${d.sessionId}`);
lines.push(`**Start Time:** ${new Date(d.startTime).toLocaleString()}`);
lines.push(`**End Time:** ${new Date(d.endTime).toLocaleString()}`);
lines.push(`**Duration:** ${formatDuration(d.durationMs)}`);
lines.push('');
lines.push('## Statistics');
lines.push('');
lines.push('| Metric | Value |');
lines.push('|--------|-------|');
lines.push(`| Total Events | ${d.stats.totalEvents} |`);
lines.push(`| Total Workers | ${d.stats.totalWorkers} |`);
lines.push(`| Total Beads | ${d.stats.totalBeads} |`);
lines.push(`| Total Files | ${d.stats.totalFiles} |`);
lines.push(`| Total Errors | ${d.stats.totalErrors} |`);
lines.push('');
if (d.cost.totalTokens > 0) {
lines.push('## Cost Breakdown');
lines.push('');
lines.push(`- **Input Tokens:** ${d.cost.inputTokens.toLocaleString()}`);
lines.push(`- **Output Tokens:** ${d.cost.outputTokens.toLocaleString()}`);
lines.push(`- **Total Tokens:** ${d.cost.totalTokens.toLocaleString()}`);
lines.push(`- **Estimated Cost:** $${d.cost.estimatedCostUsd.toFixed(4)}`);
lines.push('');
}
lines.push('## Completed Beads');
lines.push('');
if (d.beadsCompleted.length === 0) {
lines.push('_No beads completed_');
} else {
lines.push('| Bead ID | Worker | Completed At | Duration |');
lines.push('|---------|--------|--------------|----------|');
for (const bead of d.beadsCompleted) {
const time = new Date(bead.completedAt).toLocaleString();
const duration = bead.durationMs ? formatDuration(bead.durationMs) : '-';
lines.push(`| ${bead.beadId} | ${bead.workerId.slice(0, 8)} | ${time} | ${duration} |`);
}
}
lines.push('');
lines.push('## Files Modified');
lines.push('');
if (d.filesModified.length === 0) {
lines.push('_No files modified_');
} else {
lines.push('| Path | Modifications | Workers |');
lines.push('|------|---------------|---------|');
for (const file of d.filesModified) {
lines.push(`| \`${file.path}\` | ${file.modifications} | ${file.workers.length} |`);
}
}
lines.push('');
lines.push('## Errors');
lines.push('');
if (d.errors.length === 0) {
lines.push('_No errors encountered_');
} else {
lines.push('| Time | Category | Worker | Message |');
lines.push('|------|----------|--------|---------|');
for (const err of d.errors) {
const time = new Date(err.timestamp).toLocaleTimeString();
const msg = err.message.slice(0, 50).replace(/\n/g, ' ');
lines.push(`| ${time} | ${err.category} | ${err.workerId.slice(0, 8)} | ${msg} |`);
}
}
lines.push('');
lines.push('## Worker Summary');
lines.push('');
lines.push('| Worker ID | Beads | Files | Errors | Active Time |');
lines.push('|-----------|-------|-------|--------|-------------|');
for (const worker of d.workers) {
lines.push(`| ${worker.workerId.slice(0, 8)} | ${worker.beadsCompleted} | ${worker.filesModified} | ${worker.errorsEncountered} | ${formatDuration(worker.activeTimeMs)} |`);
}
lines.push('');
lines.push('---');
lines.push(`*Generated by FABRIC at ${new Date().toLocaleString()}*`);
return lines.join('\n');
}
function formatAsText(d: SessionDigestData): string {
const lines: string[] = [];
lines.push('SESSION DIGEST');
lines.push('='.repeat(50));
lines.push('');
lines.push(`Session ID: ${d.sessionId}`);
lines.push(`Start Time: ${new Date(d.startTime).toLocaleString()}`);
lines.push(`End Time: ${new Date(d.endTime).toLocaleString()}`);
lines.push(`Duration: ${formatDuration(d.durationMs)}`);
lines.push('');
lines.push('STATISTICS');
lines.push('-'.repeat(30));
lines.push(`Total Events: ${d.stats.totalEvents}`);
lines.push(`Total Workers: ${d.stats.totalWorkers}`);
lines.push(`Total Beads: ${d.stats.totalBeads}`);
lines.push(`Total Files: ${d.stats.totalFiles}`);
lines.push(`Total Errors: ${d.stats.totalErrors}`);
lines.push(`Avg Events/Worker: ${d.stats.avgEventsPerWorker.toFixed(1)}`);
lines.push(`Avg Beads/Worker: ${d.stats.avgBeadsPerWorker.toFixed(1)}`);
lines.push('');
if (d.cost.totalTokens > 0) {
lines.push('COST BREAKDOWN');
lines.push('-'.repeat(30));
lines.push(`Input Tokens: ${d.cost.inputTokens.toLocaleString()}`);
lines.push(`Output Tokens: ${d.cost.outputTokens.toLocaleString()}`);
lines.push(`Total Tokens: ${d.cost.totalTokens.toLocaleString()}`);
lines.push(`Estimated Cost: $${d.cost.estimatedCostUsd.toFixed(4)}`);
lines.push('');
}
lines.push('COMPLETED BEADS');
lines.push('-'.repeat(30));
for (const bead of d.beadsCompleted) {
const time = new Date(bead.completedAt).toLocaleString();
const duration = bead.durationMs ? ` (${formatDuration(bead.durationMs)})` : '';
lines.push(`${bead.beadId} by ${bead.workerId.slice(0, 8)} at ${time}${duration}`);
}
if (d.beadsCompleted.length === 0) lines.push('No beads completed');
lines.push('');
lines.push('FILES MODIFIED');
lines.push('-'.repeat(30));
for (const file of d.filesModified) {
lines.push(`${file.path} (${file.modifications} mods by ${file.workers.length} workers)`);
}
if (d.filesModified.length === 0) lines.push('No files modified');
lines.push('');
lines.push('ERRORS');
lines.push('-'.repeat(30));
for (const err of d.errors) {
const time = new Date(err.timestamp).toLocaleTimeString();
lines.push(`[${err.category.toUpperCase()}] ${time} ${err.workerId.slice(0, 8)}: ${err.message.slice(0, 100)}`);
}
if (d.errors.length === 0) lines.push('No errors encountered');
lines.push('');
lines.push('WORKER SUMMARY');
lines.push('-'.repeat(30));
for (const worker of d.workers) {
lines.push(`${worker.workerId}`);
lines.push(` Beads: ${worker.beadsCompleted} Files: ${worker.filesModified} Errors: ${worker.errorsEncountered} Events: ${worker.totalEvents}`);
lines.push(` Active: ${formatDuration(worker.activeTimeMs)}`);
}
if (d.workers.length === 0) lines.push('No workers');
lines.push('');
lines.push('---');
lines.push(`Generated by FABRIC at ${new Date().toLocaleString()}`);
return lines.join('\n');
}
function downloadFile(content: string, filename: string, mimeType: string) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// ── Tab content renderers ────────────────────────────────────────────────────
const SummaryTab: React.FC<{ d: SessionDigestData }> = ({ d }) => (
<div className="digest-tab-content">
<div className="digest-section">
<h4>Session Info</h4>
<table className="digest-table">
<tbody>
<tr><td>Session ID</td><td className="digest-value">{d.sessionId}</td></tr>
<tr><td>Start Time</td><td className="digest-value">{new Date(d.startTime).toLocaleString()}</td></tr>
<tr><td>End Time</td><td className="digest-value">{new Date(d.endTime).toLocaleString()}</td></tr>
<tr><td>Duration</td><td className="digest-value">{formatDuration(d.durationMs)}</td></tr>
</tbody>
</table>
</div>
<div className="digest-section">
<h4>Statistics</h4>
<table className="digest-table">
<tbody>
<tr><td>Total Events</td><td className="digest-value">{d.stats.totalEvents.toLocaleString()}</td></tr>
<tr><td>Total Workers</td><td className="digest-value">{d.stats.totalWorkers}</td></tr>
<tr><td>Total Beads</td><td className="digest-value">{d.stats.totalBeads}</td></tr>
<tr><td>Total Files</td><td className="digest-value">{d.stats.totalFiles}</td></tr>
<tr><td>Total Errors</td><td className="digest-value digest-error">{d.stats.totalErrors}</td></tr>
<tr><td>Avg Events/Worker</td><td className="digest-value">{d.stats.avgEventsPerWorker.toFixed(1)}</td></tr>
<tr><td>Avg Beads/Worker</td><td className="digest-value">{d.stats.avgBeadsPerWorker.toFixed(1)}</td></tr>
</tbody>
</table>
</div>
{d.cost.totalTokens > 0 && (
<div className="digest-section">
<h4>Cost Breakdown</h4>
<table className="digest-table">
<tbody>
<tr><td>Input Tokens</td><td className="digest-value">{d.cost.inputTokens.toLocaleString()}</td></tr>
<tr><td>Output Tokens</td><td className="digest-value">{d.cost.outputTokens.toLocaleString()}</td></tr>
<tr><td>Total Tokens</td><td className="digest-value">{d.cost.totalTokens.toLocaleString()}</td></tr>
<tr><td>Estimated Cost</td><td className="digest-value digest-cost">${d.cost.estimatedCostUsd.toFixed(4)}</td></tr>
</tbody>
</table>
</div>
)}
<div className="digest-section">
<h4>Completed Work</h4>
<div className="digest-summary-row">
<span className="digest-badge digest-badge-green">{d.beadsCompleted.length} beads</span>
<span className="digest-badge digest-badge-cyan">{d.filesModified.length} files</span>
<span className="digest-badge digest-badge-default">{d.workers.length} workers</span>
{d.errors.length > 0 && <span className="digest-badge digest-badge-red">{d.errors.length} errors</span>}
</div>
</div>
</div>
);
const BeadsTab: React.FC<{ beads: DigestBeadCompletion[] }> = ({ beads }) => {
if (beads.length === 0) {
return <div className="digest-empty">No beads completed in this session</div>;
}
const sorted = [...beads].sort((a, b) => b.completedAt - a.completedAt);
return (
<div className="digest-tab-content">
<table className="digest-data-table">
<thead>
<tr>
<th>Bead ID</th>
<th>Worker</th>
<th>Completed At</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
{sorted.map((bead, i) => (
<tr key={i}>
<td className="digest-mono digest-purple">{bead.beadId}</td>
<td className="digest-mono digest-cyan">{bead.workerId.slice(0, 8)}</td>
<td>{new Date(bead.completedAt).toLocaleTimeString()}</td>
<td>{bead.durationMs ? formatDuration(bead.durationMs) : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
const FilesTab: React.FC<{ files: DigestFileModification[] }> = ({ files }) => {
if (files.length === 0) {
return <div className="digest-empty">No files modified in this session</div>;
}
const sorted = [...files].sort((a, b) => b.modifications - a.modifications);
return (
<div className="digest-tab-content">
<table className="digest-data-table">
<thead>
<tr>
<th>Path</th>
<th>Mods</th>
<th>Workers</th>
<th>Tools</th>
</tr>
</thead>
<tbody>
{sorted.map((file, i) => {
const heat = file.modifications >= 10 ? 'digest-red'
: file.modifications >= 5 ? 'digest-yellow'
: file.modifications >= 3 ? 'digest-cyan'
: 'digest-green';
return (
<tr key={i}>
<td className="digest-mono digest-path">{file.path}</td>
<td className={`digest-count ${heat}`}>{file.modifications}</td>
<td>{file.workers.length}</td>
<td className="digest-muted">{file.tools.join(', ')}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
};
const ErrorsTab: React.FC<{ errors: DigestErrorOccurrence[] }> = ({ errors }) => {
if (errors.length === 0) {
return <div className="digest-empty digest-success">No errors encountered in this session</div>;
}
const sorted = [...errors].sort((a, b) => b.timestamp - a.timestamp);
return (
<div className="digest-tab-content">
{sorted.map((err, i) => (
<div key={i} className="digest-error-card">
<div className="digest-error-header">
<span className="digest-error-category">[{err.category.toUpperCase()}]</span>
<span className="digest-muted">{new Date(err.timestamp).toLocaleTimeString()}</span>
<span className="digest-cyan">{err.workerId.slice(0, 8)}</span>
</div>
<div className="digest-error-message">
{err.message.slice(0, 200)}{err.message.length > 200 ? '...' : ''}
</div>
{err.fingerprint && (
<div className="digest-fingerprint">fp: {err.fingerprint}</div>
)}
</div>
))}
</div>
);
};
const WorkersTab: React.FC<{ workers: DigestWorkerSummary[] }> = ({ workers }) => {
if (workers.length === 0) {
return <div className="digest-empty">No workers in this session</div>;
}
const sorted = [...workers].sort((a, b) => b.beadsCompleted - a.beadsCompleted);
return (
<div className="digest-tab-content">
{sorted.map((worker, i) => (
<div key={i} className="digest-worker-card">
<div className="digest-worker-id">{worker.workerId}</div>
<div className="digest-worker-stats">
<span><strong>Beads:</strong> <span className="digest-green">{worker.beadsCompleted}</span></span>
<span><strong>Files:</strong> {worker.filesModified}</span>
<span><strong>Errors:</strong> <span className="digest-error">{worker.errorsEncountered}</span></span>
<span><strong>Events:</strong> {worker.totalEvents}</span>
<span><strong>Active:</strong> {formatDuration(worker.activeTimeMs)}</span>
</div>
<div className="digest-worker-time">
<span className="digest-muted">
{new Date(worker.firstActivity).toLocaleTimeString()} {new Date(worker.lastActivity).toLocaleTimeString()}
</span>
</div>
</div>
))}
</div>
);
};
// ── Main Component ────────────────────────────────────────────────────────────
const TABS: Array<{ key: DigestTab; label: string }> = [
{ key: 'summary', label: 'Summary' },
{ key: 'beads', label: 'Beads' },
{ key: 'files', label: 'Files' },
{ key: 'errors', label: 'Errors' },
{ key: 'workers', label: 'Workers' },
];
const SessionDigestPanel: React.FC<SessionDigestPanelProps> = ({ visible, onClose }) => {
const [digest, setDigest] = useState<SessionDigestData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<DigestTab>('summary');
const [exportStatus, setExportStatus] = useState<string | null>(null);
const generateDigest = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/digest');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: SessionDigestData = await res.json();
setDigest(data);
setActiveTab('summary');
} catch (err) {
setError(String(err));
} finally {
setLoading(false);
}
}, []);
const handleExport = useCallback((format: 'json' | 'markdown' | 'text') => {
if (!digest) return;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const base = `session-digest-${timestamp}`;
let content: string;
let filename: string;
let mime: string;
switch (format) {
case 'json':
content = JSON.stringify(digest, null, 2);
filename = `${base}.json`;
mime = 'application/json';
break;
case 'markdown':
content = formatAsMarkdown(digest);
filename = `${base}.md`;
mime = 'text/markdown';
break;
case 'text':
content = formatAsText(digest);
filename = `${base}.txt`;
mime = 'text/plain';
break;
}
downloadFile(content, filename, mime);
setExportStatus(`Exported ${filename}`);
setTimeout(() => setExportStatus(null), 3000);
}, [digest]);
if (!visible) return null;
return (
<div className="digest-panel">
<div className="digest-header">
<h3>Session Digest</h3>
<div className="digest-header-actions">
<button
className="digest-generate-btn"
onClick={generateDigest}
disabled={loading}
>
{loading ? 'Generating...' : 'Generate Digest'}
</button>
<button className="close-button" onClick={onClose}>×</button>
</div>
</div>
{error && (
<div className="digest-fetch-error">Failed to generate digest: {error}</div>
)}
{!digest && !loading && !error && (
<div className="digest-placeholder">
<p>Click <strong>Generate Digest</strong> to summarize the current session.</p>
</div>
)}
{digest && (
<>
<div className="digest-meta">
<span><strong>Session:</strong> {digest.sessionId.slice(0, 20)}</span>
<span><strong>Duration:</strong> {formatDuration(digest.durationMs)}</span>
<span><strong>Events:</strong> {digest.stats.totalEvents.toLocaleString()}</span>
<span><strong>Workers:</strong> {digest.stats.totalWorkers}</span>
</div>
<div className="digest-tabs">
{TABS.map(tab => (
<button
key={tab.key}
className={`digest-tab ${activeTab === tab.key ? 'active' : ''}`}
onClick={() => setActiveTab(tab.key)}
>
{tab.label}
{tab.key === 'errors' && digest.errors.length > 0 && (
<span className="digest-tab-badge">{digest.errors.length}</span>
)}
{tab.key === 'beads' && digest.beadsCompleted.length > 0 && (
<span className="digest-tab-badge digest-tab-badge-green">{digest.beadsCompleted.length}</span>
)}
</button>
))}
</div>
<div className="digest-content">
{activeTab === 'summary' && <SummaryTab d={digest} />}
{activeTab === 'beads' && <BeadsTab beads={digest.beadsCompleted} />}
{activeTab === 'files' && <FilesTab files={digest.filesModified} />}
{activeTab === 'errors' && <ErrorsTab errors={digest.errors} />}
{activeTab === 'workers' && <WorkersTab workers={digest.workers} />}
</div>
<div className="digest-footer">
<div className="digest-export-buttons">
<span className="digest-export-label">Export:</span>
<button className="digest-export-btn" onClick={() => handleExport('json')}>JSON</button>
<button className="digest-export-btn" onClick={() => handleExport('markdown')}>Markdown</button>
<button className="digest-export-btn" onClick={() => handleExport('text')}>Text</button>
</div>
{exportStatus && <span className="digest-export-status">{exportStatus}</span>}
</div>
</>
)}
</div>
);
};
export default SessionDigestPanel;

View file

@ -1131,6 +1131,12 @@ body {
border-radius: 2px;
}
.conversation-turn-activity-highlight {
background: rgba(33, 150, 243, 0.12);
outline: 2px solid rgba(33, 150, 243, 0.4);
transition: background 0.3s, outline-color 0.3s;
}
/* ============================================
Session Replay Component Styles
============================================ */
@ -6918,3 +6924,371 @@ body {
font-weight: 600;
}
/* ── Session Digest ──────────────────────────────────────────────────────── */
.digest-toggle {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.6rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.2s;
}
.digest-toggle:hover,
.digest-toggle.active {
border-color: var(--info);
color: var(--info);
}
.digest-toggle-icon { font-size: 0.9rem; }
.digest-toggle-label { font-size: 0.78rem; }
.digest-panel {
grid-column: 1 / -1;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
overflow: hidden;
display: flex;
flex-direction: column;
max-height: 60vh;
animation: fadeIn 0.15s ease-out;
}
.digest-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.digest-header h3 {
font-size: 0.9375rem;
font-weight: 600;
margin: 0;
color: var(--text-primary);
}
.digest-header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.digest-generate-btn {
padding: 0.3rem 0.75rem;
background: var(--info);
color: #fff;
border: none;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.digest-generate-btn:hover:not(:disabled) { opacity: 0.85; }
.digest-generate-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.digest-fetch-error {
padding: 0.75rem 1rem;
color: var(--error);
font-size: 0.85rem;
background: rgba(var(--error-rgb, 239,68,68), 0.08);
border-bottom: 1px solid var(--border-color);
}
.digest-placeholder {
padding: 2rem;
text-align: center;
color: var(--text-secondary);
font-size: 0.875rem;
}
.digest-meta {
display: flex;
gap: 1.25rem;
padding: 0.5rem 1rem;
font-size: 0.8rem;
color: var(--text-secondary);
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
flex-wrap: wrap;
}
.digest-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.digest-tab {
padding: 0.5rem 1rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-secondary);
font-size: 0.82rem;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
gap: 0.35rem;
}
.digest-tab:hover { color: var(--text-primary); }
.digest-tab.active {
color: var(--info);
border-bottom-color: var(--info);
font-weight: 600;
}
.digest-tab-badge {
background: var(--error);
color: #fff;
font-size: 0.65rem;
padding: 0.1rem 0.35rem;
border-radius: 8px;
font-weight: 700;
}
.digest-tab-badge-green {
background: var(--success);
}
.digest-content {
flex: 1;
overflow-y: auto;
}
.digest-tab-content {
padding: 1rem;
}
.digest-section {
margin-bottom: 1.25rem;
}
.digest-section h4 {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 0.5rem 0;
}
.digest-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.digest-table td {
padding: 0.25rem 0.5rem;
color: var(--text-secondary);
}
.digest-table td:first-child {
width: 45%;
color: var(--text-muted, var(--text-secondary));
font-weight: 500;
}
.digest-value { color: var(--text-primary) !important; }
.digest-error { color: var(--error) !important; }
.digest-cost { color: var(--success) !important; }
.digest-green { color: var(--success) !important; }
.digest-cyan { color: var(--info) !important; }
.digest-yellow { color: var(--warning) !important; }
.digest-red { color: var(--error) !important; }
.digest-purple { color: #a78bfa !important; }
.digest-muted { color: var(--text-secondary) !important; }
.digest-mono { font-family: monospace; font-size: 0.8rem; }
.digest-success { color: var(--success) !important; }
.digest-summary-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.digest-badge {
padding: 0.2rem 0.6rem;
border-radius: 12px;
font-size: 0.78rem;
font-weight: 600;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.digest-badge-green { border-color: var(--success); color: var(--success); }
.digest-badge-cyan { border-color: var(--info); color: var(--info); }
.digest-badge-red { border-color: var(--error); color: var(--error); }
.digest-badge-default { border-color: var(--border-color); }
.digest-data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
.digest-data-table th {
text-align: left;
padding: 0.4rem 0.6rem;
color: var(--text-secondary);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid var(--border-color);
}
.digest-data-table td {
padding: 0.35rem 0.6rem;
color: var(--text-primary);
border-bottom: 1px solid var(--border-color);
vertical-align: top;
}
.digest-data-table tr:last-child td { border-bottom: none; }
.digest-data-table tr:hover td { background: var(--hover-bg); }
.digest-path {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.digest-count { font-weight: 700; }
.digest-error-card {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-left: 3px solid var(--error);
border-radius: 4px;
padding: 0.6rem 0.75rem;
margin-bottom: 0.5rem;
}
.digest-error-header {
display: flex;
gap: 0.75rem;
align-items: center;
margin-bottom: 0.4rem;
font-size: 0.8rem;
}
.digest-error-category {
color: var(--error);
font-weight: 700;
font-size: 0.75rem;
}
.digest-error-message {
font-size: 0.83rem;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
}
.digest-fingerprint {
font-size: 0.72rem;
color: var(--text-secondary);
margin-top: 0.25rem;
font-family: monospace;
}
.digest-empty {
padding: 2rem;
text-align: center;
color: var(--text-secondary);
font-size: 0.875rem;
}
.digest-worker-card {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.6rem 0.75rem;
margin-bottom: 0.5rem;
}
.digest-worker-id {
font-family: monospace;
font-size: 0.82rem;
color: var(--info);
font-weight: 600;
margin-bottom: 0.35rem;
}
.digest-worker-stats {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
font-size: 0.82rem;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}
.digest-worker-time {
font-size: 0.75rem;
}
.digest-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
border-top: 1px solid var(--border-color);
background: var(--bg-tertiary);
flex-shrink: 0;
flex-wrap: wrap;
gap: 0.5rem;
}
.digest-export-buttons {
display: flex;
align-items: center;
gap: 0.4rem;
}
.digest-export-label {
font-size: 0.78rem;
color: var(--text-secondary);
margin-right: 0.25rem;
}
.digest-export-btn {
padding: 0.25rem 0.6rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 0.78rem;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
}
.digest-export-btn:hover {
border-color: var(--info);
color: var(--info);
}
.digest-export-status {
font-size: 0.78rem;
color: var(--success);
}

View file

@ -515,6 +515,71 @@ export interface SimilarError {
// Conversation Transcript Types
// ============================================
// ============================================
// Session Digest Types
// ============================================
export interface DigestBeadCompletion {
beadId: string;
workerId: string;
completedAt: number;
durationMs?: number;
}
export interface DigestFileModification {
path: string;
modifications: number;
workers: string[];
tools: string[];
}
export interface DigestErrorOccurrence {
message: string;
category: ErrorCategory;
workerId: string;
timestamp: number;
fingerprint?: string;
}
export interface DigestWorkerSummary {
workerId: string;
beadsCompleted: number;
filesModified: number;
errorsEncountered: number;
totalEvents: number;
activeTimeMs: number;
firstActivity: number;
lastActivity: number;
}
export interface SessionDigestData {
sessionId: string;
startTime: number;
endTime: number;
durationMs: number;
beadsCompleted: DigestBeadCompletion[];
filesModified: DigestFileModification[];
errors: DigestErrorOccurrence[];
workers: DigestWorkerSummary[];
cost: {
totalTokens: number;
inputTokens: number;
outputTokens: number;
estimatedCostUsd: number;
};
stats: {
totalEvents: number;
totalWorkers: number;
totalBeads: number;
totalFiles: number;
totalErrors: number;
avgEventsPerWorker: number;
avgBeadsPerWorker: number;
};
}
export type DigestTab = 'summary' | 'beads' | 'files' | 'errors' | 'workers';
export type ConversationTurnRole = 'system' | 'user' | 'assistant' | 'tool';
export interface ConversationTurn {

View file

@ -13,11 +13,13 @@ import { createSocket } from 'dgram';
import { WebSocketServer, WebSocket } from 'ws';
import { LogEvent, EventFilter, CrossReferenceEntityType, CrossReferenceRelationship, DagOptions, BeadStatus } from '../types.js';
import { InMemoryEventStore } from '../store.js';
import { SemanticNarrativeGenerator } from '../semanticNarrative.js';
import { refreshDependencyGraph, getDagStats } from '../tui/dagUtils.js';
import { normalizeToLogEvent, EventDeduplicator } from '../normalizer.js';
import { computeFleetAnalytics } from '../analytics.js';
import { createOtlpHttpRouter } from '../otlpHttpReceiver.js';
import { ServerMetrics } from '../serverMetrics.js';
import { SessionDigestGenerator, formatDigestAsMarkdown } from '../sessionDigest.js';
/** Maximum payload size for POST requests (64KB) */
const MAX_PAYLOAD_SIZE = 64 * 1024;
@ -731,6 +733,19 @@ export function createWebServer(options: WebServerOptions): WebServer {
}
});
app.get('/api/digest', (req: Request, res: Response) => {
try {
const generator = new SessionDigestGenerator(store);
const opts: Record<string, unknown> = {};
if (req.query.startTime) opts.startTime = Number(req.query.startTime);
if (req.query.endTime) opts.endTime = Number(req.query.endTime);
const digest = generator.generateDigest(opts);
res.json(digest);
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
// Serve static frontend files
const staticPath = join(__dirname, 'public');
app.use(express.static(staticPath));