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:
parent
8b3c9adb05
commit
240957c8e0
5 changed files with 1003 additions and 0 deletions
|
|
@ -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">
|
||||
|
|
|
|||
530
src/web/frontend/src/components/SessionDigestPanel.tsx
Normal file
530
src/web/frontend/src/components/SessionDigestPanel.tsx
Normal 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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue