feat(web): add SemanticNarrativePanel React component
Port TUI SemanticNarrativePanel to React. Provides: - Standalone overlay panel showing narrative cards per active worker - Phase detection (Research/Planning/Implementation/Testing/Debugging/Finalizing) - Phase progress bar, sentiment indicator, accomplishments/challenges - Expandable activity segments with entity details (files, tools) - WorkerNarrativeInline component embedded in WorkerDetail narrative tab - /api/narrative and /api/narrative/:workerId server endpoints - CSS for all narrative UI elements - Command palette and header button wired to show:narrative action Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
240957c8e0
commit
34aee6474f
6 changed files with 1269 additions and 2 deletions
|
|
@ -15,8 +15,10 @@ import SessionReplay from './components/SessionReplay';
|
|||
import CostDashboard from './components/CostDashboard';
|
||||
import AnalyticsDashboard from './components/AnalyticsDashboard';
|
||||
import ErrorGroupPanel from './components/ErrorGroupPanel';
|
||||
import SemanticNarrativePanel from './components/SemanticNarrativePanel';
|
||||
import BudgetAlertPanel, { BudgetBanner } from './components/BudgetAlertPanel';
|
||||
import SessionDigestPanel from './components/SessionDigestPanel';
|
||||
import GitIntegrationPanel from './components/GitIntegrationPanel';
|
||||
import CommandPalette from './components/CommandPalette';
|
||||
import { extractReplayFromUrl, ReplayExport } from './utils/replayExport';
|
||||
import { FocusPresetManager, createWebPresetManager, FocusPreset } from './utils/focusPresets';
|
||||
|
|
@ -253,6 +255,8 @@ const App: React.FC = () => {
|
|||
const [showErrorGroups, setShowErrorGroups] = useState(false);
|
||||
const [showBudgetAlert, setShowBudgetAlert] = useState(false);
|
||||
const [showSessionDigest, setShowSessionDigest] = useState(false);
|
||||
const [showGitIntegration, setShowGitIntegration] = useState(false);
|
||||
const [showNarrative, setShowNarrative] = useState(false);
|
||||
const [budgetBannerDismissed, setBudgetBannerDismissed] = useState(false);
|
||||
|
||||
// Budget alert state polled from /api/cost/summary
|
||||
|
|
@ -531,6 +535,10 @@ const App: React.FC = () => {
|
|||
setShowBudgetAlert(true);
|
||||
} else if (action === 'show:digest') {
|
||||
setShowSessionDigest(true);
|
||||
} else if (action === 'show:git') {
|
||||
setShowGitIntegration(true);
|
||||
} else if (action === 'show:narrative') {
|
||||
setShowNarrative(true);
|
||||
} else if (action.startsWith('worker:')) {
|
||||
const workerId = action.slice('worker:'.length);
|
||||
setSelectedWorker(workerId);
|
||||
|
|
@ -802,6 +810,22 @@ const App: React.FC = () => {
|
|||
<span className="digest-toggle-icon">📋</span>
|
||||
<span className="digest-toggle-label">Digest</span>
|
||||
</button>
|
||||
<button
|
||||
className={`git-toggle ${showGitIntegration ? 'active' : ''}`}
|
||||
onClick={() => setShowGitIntegration(!showGitIntegration)}
|
||||
title="Git integration — live status for watched repo"
|
||||
>
|
||||
<span className="git-toggle-icon">⌵</span>
|
||||
<span className="git-toggle-label">Git</span>
|
||||
</button>
|
||||
<button
|
||||
className={`narrative-toggle ${showNarrative ? 'active' : ''}`}
|
||||
onClick={() => setShowNarrative(!showNarrative)}
|
||||
title="Semantic narrative — natural language description of worker activity"
|
||||
>
|
||||
<span className="narrative-toggle-icon">📝</span>
|
||||
<span className="narrative-toggle-label">Narrative</span>
|
||||
</button>
|
||||
{unacknowledgedAlertCount > 0 && (
|
||||
<button
|
||||
className="collision-alert-toggle"
|
||||
|
|
@ -959,6 +983,13 @@ const App: React.FC = () => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{showNarrative && (
|
||||
<SemanticNarrativePanel
|
||||
visible={showNarrative}
|
||||
onClose={() => setShowNarrative(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSessionReplay && (
|
||||
<div className="session-replay-panel">
|
||||
<div className="session-replay-header">
|
||||
|
|
|
|||
511
src/web/frontend/src/components/SemanticNarrativePanel.tsx
Normal file
511
src/web/frontend/src/components/SemanticNarrativePanel.tsx
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
SemanticNarrativeView,
|
||||
NarrativeSegmentView,
|
||||
EventPattern,
|
||||
NarrativeSentiment,
|
||||
} from '../types';
|
||||
|
||||
// ── Phase mapping ──────────────────────────────────────────
|
||||
|
||||
const PATTERN_TO_PHASE: Record<EventPattern, string> = {
|
||||
investigation: 'Research',
|
||||
research: 'Research',
|
||||
exploration: 'Research',
|
||||
planning: 'Planning',
|
||||
file_editing: 'Implementation',
|
||||
file_created: 'Implementation',
|
||||
iteration: 'Implementation',
|
||||
tool_usage: 'Implementation',
|
||||
testing: 'Testing',
|
||||
debugging: 'Debugging',
|
||||
error_handling: 'Debugging',
|
||||
error_recovery: 'Debugging',
|
||||
bead_completed: 'Finalizing',
|
||||
task_completion: 'Finalizing',
|
||||
git_operations: 'Finalizing',
|
||||
dependency_install: 'Implementation',
|
||||
bead_started: 'Implementation',
|
||||
collision_detected: 'Debugging',
|
||||
};
|
||||
|
||||
const PHASE_COLORS: Record<string, string> = {
|
||||
Research: '#2196f3',
|
||||
Planning: '#ffc107',
|
||||
Implementation: '#00c853',
|
||||
Testing: '#9c27b0',
|
||||
Debugging: '#f44336',
|
||||
Finalizing: '#00bcd4',
|
||||
};
|
||||
|
||||
const SENTIMENT_ICONS: Record<NarrativeSentiment, string> = {
|
||||
productive: '✓',
|
||||
struggling: '!',
|
||||
mixed: '~',
|
||||
idle: '○',
|
||||
};
|
||||
|
||||
const SENTIMENT_COLORS: Record<NarrativeSentiment, string> = {
|
||||
productive: 'var(--success)',
|
||||
struggling: 'var(--error)',
|
||||
mixed: 'var(--warning)',
|
||||
idle: 'var(--text-secondary)',
|
||||
};
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const rem = seconds % 60;
|
||||
if (minutes < 60) return rem > 0 ? `${minutes}m ${rem}s` : `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const rm = minutes % 60;
|
||||
return rm > 0 ? `${hours}h ${rm}m` : `${hours}h`;
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString();
|
||||
}
|
||||
|
||||
function detectPhase(segments: NarrativeSegmentView[]): string | null {
|
||||
const active = segments.filter(s => s.isActive);
|
||||
if (active.length > 0) {
|
||||
return PATTERN_TO_PHASE[active[active.length - 1].pattern] || 'Implementation';
|
||||
}
|
||||
if (segments.length > 0) {
|
||||
return PATTERN_TO_PHASE[segments[segments.length - 1].pattern] || 'Implementation';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface PhaseSlice {
|
||||
phase: string;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
function getPhaseProgress(segments: NarrativeSegmentView[]): PhaseSlice[] {
|
||||
const phases = ['Research', 'Planning', 'Implementation', 'Testing', 'Debugging', 'Finalizing'];
|
||||
const total = segments.reduce((sum, s) => sum + s.durationMs, 0);
|
||||
if (total === 0) return phases.map(phase => ({ phase, percent: 0 }));
|
||||
|
||||
const durations: Record<string, number> = {};
|
||||
for (const p of phases) durations[p] = 0;
|
||||
for (const seg of segments) {
|
||||
const p = PATTERN_TO_PHASE[seg.pattern] || 'Implementation';
|
||||
durations[p] = (durations[p] || 0) + seg.durationMs;
|
||||
}
|
||||
|
||||
return phases.map(phase => ({
|
||||
phase,
|
||||
percent: Math.round((durations[phase] / total) * 100),
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Inline worker narrative (used in WorkerDetail tab) ─────
|
||||
|
||||
interface WorkerNarrativeInlineProps {
|
||||
workerId: string;
|
||||
}
|
||||
|
||||
const WorkerNarrativeInline: React.FC<WorkerNarrativeInlineProps> = ({ workerId }) => {
|
||||
const [narrative, setNarrative] = useState<SemanticNarrativeView | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedSegment, setExpandedSegment] = useState<string | null>(null);
|
||||
|
||||
const fetchNarrative = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/narrative/${encodeURIComponent(workerId)}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data: SemanticNarrativeView = await res.json();
|
||||
setNarrative(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workerId]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetchNarrative();
|
||||
const interval = setInterval(fetchNarrative, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchNarrative]);
|
||||
|
||||
if (loading) return <div className="narrative-inline-loading">Loading narrative...</div>;
|
||||
if (error) return <div className="narrative-inline-error">{error}</div>;
|
||||
if (!narrative || narrative.segments.length === 0) {
|
||||
return <div className="narrative-inline-empty">Not enough events to generate a narrative.</div>;
|
||||
}
|
||||
|
||||
const currentPhase = detectPhase(narrative.segments);
|
||||
const phaseProgress = getPhaseProgress(narrative.segments);
|
||||
|
||||
return (
|
||||
<div className="narrative-inline">
|
||||
{/* Phase badge + sentiment */}
|
||||
<div className="narrative-inline-header">
|
||||
{currentPhase && (
|
||||
<span
|
||||
className="narrative-phase-badge"
|
||||
style={{ backgroundColor: PHASE_COLORS[currentPhase] }}
|
||||
>
|
||||
{currentPhase}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="narrative-sentiment"
|
||||
style={{ color: SENTIMENT_COLORS[narrative.sentiment] }}
|
||||
>
|
||||
{SENTIMENT_ICONS[narrative.sentiment]}
|
||||
</span>
|
||||
<span className="narrative-inline-stats">
|
||||
{formatDuration(narrative.durationMs)} · {narrative.stats.totalEvents} events
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Phase progress bar */}
|
||||
<div className="narrative-phase-progress">
|
||||
{phaseProgress.map(({ phase, percent }) =>
|
||||
percent > 0 ? (
|
||||
<div
|
||||
key={phase}
|
||||
className="narrative-phase-segment"
|
||||
style={{ width: `${percent}%`, backgroundColor: PHASE_COLORS[phase] }}
|
||||
title={`${phase}: ${percent}%`}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<p className="narrative-inline-summary">{narrative.summary}</p>
|
||||
|
||||
{/* Accomplishments & Challenges */}
|
||||
{narrative.accomplishments.length > 0 && (
|
||||
<div className="narrative-inline-section">
|
||||
<h4>Accomplishments</h4>
|
||||
<ul>
|
||||
{narrative.accomplishments.map((a, i) => <li key={i}>{a}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{narrative.challenges.length > 0 && (
|
||||
<div className="narrative-inline-section">
|
||||
<h4>Challenges</h4>
|
||||
<ul>
|
||||
{narrative.challenges.map((c, i) => <li key={i}>{c}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Segments */}
|
||||
<div className="narrative-inline-section">
|
||||
<h4>Activity Segments</h4>
|
||||
<div className="narrative-segments">
|
||||
{narrative.segments.map(seg => {
|
||||
const isOpen = expandedSegment === seg.id;
|
||||
const phase = PATTERN_TO_PHASE[seg.pattern] || 'Implementation';
|
||||
return (
|
||||
<div key={seg.id} className={`narrative-segment ${seg.isActive ? 'active' : ''} ${isOpen ? 'expanded' : ''}`}>
|
||||
<div
|
||||
className="narrative-segment-header"
|
||||
onClick={() => setExpandedSegment(isOpen ? null : seg.id)}
|
||||
>
|
||||
<span className="narrative-segment-icon" style={{ color: PHASE_COLORS[phase] }}>
|
||||
{phase.charAt(0)}
|
||||
</span>
|
||||
<span className="narrative-segment-summary">{seg.summary}</span>
|
||||
<span className="narrative-segment-duration">{formatDuration(seg.durationMs)}</span>
|
||||
{seg.isActive && <span className="narrative-active-dot" />}
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="narrative-segment-detail">
|
||||
<div className="narrative-segment-meta">
|
||||
<span>{formatTime(seg.startTime)} — {formatTime(seg.endTime)}</span>
|
||||
<span>{seg.eventCount} events</span>
|
||||
</div>
|
||||
{seg.entities.files && seg.entities.files.length > 0 && (
|
||||
<div className="narrative-entities">
|
||||
<span className="narrative-entity-label">Files:</span>
|
||||
{seg.entities.files.slice(0, 5).map((f, i) => (
|
||||
<span key={i} className="narrative-entity-tag">{f.split('/').pop()}</span>
|
||||
))}
|
||||
{seg.entities.files.length > 5 && (
|
||||
<span className="narrative-entity-more">+{seg.entities.files.length - 5}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{seg.entities.tools && seg.entities.tools.length > 0 && (
|
||||
<div className="narrative-entities">
|
||||
<span className="narrative-entity-label">Tools:</span>
|
||||
{seg.entities.tools.map((t, i) => (
|
||||
<span key={i} className="narrative-entity-tag">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full narrative text */}
|
||||
{narrative.fullNarrative && (
|
||||
<div className="narrative-inline-section">
|
||||
<h4>Narrative</h4>
|
||||
<p className="narrative-full-text">{narrative.fullNarrative}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { WorkerNarrativeInline };
|
||||
|
||||
// ── Standalone panel (toggled from header) ─────────────────
|
||||
|
||||
interface SemanticNarrativePanelProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SemanticNarrativePanel: React.FC<SemanticNarrativePanelProps> = ({ visible, onClose }) => {
|
||||
const [narratives, setNarratives] = useState<SemanticNarrativeView[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedWorker, setExpandedWorker] = useState<string | null>(null);
|
||||
const [expandedSegment, setExpandedSegment] = useState<string | null>(null);
|
||||
|
||||
const fetchNarratives = useCallback(async () => {
|
||||
if (!visible) return;
|
||||
try {
|
||||
const res = await fetch('/api/narrative');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
setNarratives(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch narratives');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setLoading(true);
|
||||
fetchNarratives();
|
||||
const interval = setInterval(fetchNarratives, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [visible, fetchNarratives]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="narrative-panel">
|
||||
<div className="narrative-panel-header">
|
||||
<h3>Semantic Narrative</h3>
|
||||
<div className="narrative-panel-actions">
|
||||
<button className="narrative-refresh-btn" onClick={fetchNarratives} title="Refresh">
|
||||
↻
|
||||
</button>
|
||||
<button className="close-button" onClick={onClose}>×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="narrative-panel-content">
|
||||
{loading && narratives.length === 0 && (
|
||||
<div className="narrative-loading">Loading narratives...</div>
|
||||
)}
|
||||
{error && <div className="narrative-error">{error}</div>}
|
||||
{!loading && !error && narratives.length === 0 && (
|
||||
<div className="narrative-empty">No active workers to narrate.</div>
|
||||
)}
|
||||
|
||||
{narratives.map(narrative => {
|
||||
const currentPhase = detectPhase(narrative.segments);
|
||||
const phaseProgress = getPhaseProgress(narrative.segments);
|
||||
const isExpanded = expandedWorker === narrative.workerId;
|
||||
|
||||
return (
|
||||
<div key={narrative.id} className={`narrative-card ${isExpanded ? 'expanded' : ''}`}>
|
||||
<div
|
||||
className="narrative-card-header"
|
||||
onClick={() => setExpandedWorker(isExpanded ? null : narrative.workerId)}
|
||||
>
|
||||
<div className="narrative-card-title-row">
|
||||
<span className="narrative-worker-id">{narrative.workerId}</span>
|
||||
{currentPhase && (
|
||||
<span
|
||||
className="narrative-phase-badge"
|
||||
style={{ backgroundColor: PHASE_COLORS[currentPhase] }}
|
||||
>
|
||||
{currentPhase}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="narrative-sentiment"
|
||||
style={{ color: SENTIMENT_COLORS[narrative.sentiment] }}
|
||||
title={`Sentiment: ${narrative.sentiment}`}
|
||||
>
|
||||
{SENTIMENT_ICONS[narrative.sentiment]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="narrative-summary">{narrative.summary}</p>
|
||||
<div className="narrative-stats-row">
|
||||
<span>{formatDuration(narrative.durationMs)}</span>
|
||||
<span>{narrative.stats.totalEvents} events</span>
|
||||
<span>{narrative.stats.segmentCount} segments</span>
|
||||
{narrative.stats.errorsEncountered > 0 && (
|
||||
<span className="narrative-error-count">
|
||||
{narrative.stats.errorsEncountered} errors
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="narrative-card-body">
|
||||
{/* Phase progress bar */}
|
||||
<div className="narrative-phase-progress">
|
||||
{phaseProgress.map(({ phase, percent }) =>
|
||||
percent > 0 ? (
|
||||
<div
|
||||
key={phase}
|
||||
className="narrative-phase-segment"
|
||||
style={{ width: `${percent}%`, backgroundColor: PHASE_COLORS[phase] }}
|
||||
title={`${phase}: ${percent}%`}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
<div className="narrative-phase-labels">
|
||||
{phaseProgress
|
||||
.filter(p => p.percent > 0)
|
||||
.map(({ phase, percent }) => (
|
||||
<span key={phase} style={{ color: PHASE_COLORS[phase] }}>
|
||||
{phase} {percent}%
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Accomplishments */}
|
||||
{narrative.accomplishments.length > 0 && (
|
||||
<div className="narrative-section">
|
||||
<h4>Accomplishments</h4>
|
||||
<ul>
|
||||
{narrative.accomplishments.map((a, i) => <li key={i}>{a}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Challenges */}
|
||||
{narrative.challenges.length > 0 && (
|
||||
<div className="narrative-section">
|
||||
<h4>Challenges</h4>
|
||||
<ul>
|
||||
{narrative.challenges.map((c, i) => <li key={i}>{c}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Segments timeline */}
|
||||
<div className="narrative-section">
|
||||
<h4>Activity Segments</h4>
|
||||
<div className="narrative-segments">
|
||||
{narrative.segments.map(seg => {
|
||||
const isOpen = expandedSegment === seg.id;
|
||||
const phase = PATTERN_TO_PHASE[seg.pattern] || 'Implementation';
|
||||
return (
|
||||
<div
|
||||
key={seg.id}
|
||||
className={`narrative-segment ${seg.isActive ? 'active' : ''} ${isOpen ? 'expanded' : ''}`}
|
||||
>
|
||||
<div
|
||||
className="narrative-segment-header"
|
||||
onClick={() => setExpandedSegment(isOpen ? null : seg.id)}
|
||||
>
|
||||
<span
|
||||
className="narrative-segment-icon"
|
||||
style={{ color: PHASE_COLORS[phase] }}
|
||||
>
|
||||
{phase.charAt(0)}
|
||||
</span>
|
||||
<span className="narrative-segment-summary">{seg.summary}</span>
|
||||
<span className="narrative-segment-duration">
|
||||
{formatDuration(seg.durationMs)}
|
||||
</span>
|
||||
<span className="narrative-segment-confidence">
|
||||
{Math.round(seg.confidence * 100)}%
|
||||
</span>
|
||||
{seg.isActive && <span className="narrative-active-dot" />}
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="narrative-segment-detail">
|
||||
<div className="narrative-segment-meta">
|
||||
<span>
|
||||
{formatTime(seg.startTime)} — {formatTime(seg.endTime)}
|
||||
</span>
|
||||
<span>{seg.eventCount} events</span>
|
||||
</div>
|
||||
{seg.details && (
|
||||
<p className="narrative-segment-details-text">{seg.details}</p>
|
||||
)}
|
||||
{seg.entities.files && seg.entities.files.length > 0 && (
|
||||
<div className="narrative-entities">
|
||||
<span className="narrative-entity-label">Files:</span>
|
||||
{seg.entities.files.slice(0, 5).map((f, i) => (
|
||||
<span key={i} className="narrative-entity-tag">
|
||||
{f.split('/').pop()}
|
||||
</span>
|
||||
))}
|
||||
{seg.entities.files.length > 5 && (
|
||||
<span className="narrative-entity-more">
|
||||
+{seg.entities.files.length - 5}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{seg.entities.tools && seg.entities.tools.length > 0 && (
|
||||
<div className="narrative-entities">
|
||||
<span className="narrative-entity-label">Tools:</span>
|
||||
{seg.entities.tools.map((t, i) => (
|
||||
<span key={i} className="narrative-entity-tag">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full narrative */}
|
||||
{narrative.fullNarrative && (
|
||||
<div className="narrative-section">
|
||||
<h4>Narrative</h4>
|
||||
<p className="narrative-full-text">{narrative.fullNarrative}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SemanticNarrativePanel;
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { WorkerInfo, LogEvent, NeedleState } from '../types';
|
||||
import ConversationTranscriptPanel from './ConversationTranscriptPanel';
|
||||
import { WorkerNarrativeInline } from './SemanticNarrativePanel';
|
||||
import { logEventsToTurns } from '../utils/conversationTurns';
|
||||
|
||||
const NEEDLE_STATE_ICONS: Record<NeedleState, string> = {
|
||||
|
|
@ -21,7 +22,7 @@ const NEEDLE_STATE_COLORS: Record<NeedleState, string> = {
|
|||
STOPPED: '#777',
|
||||
};
|
||||
|
||||
type WorkerTab = 'overview' | 'conversation';
|
||||
type WorkerTab = 'overview' | 'conversation' | 'narrative';
|
||||
|
||||
interface WorkerDetailProps {
|
||||
worker: WorkerInfo;
|
||||
|
|
@ -115,6 +116,12 @@ const WorkerDetail: React.FC<WorkerDetailProps> = ({
|
|||
<span className="worker-detail-tab-count">{conversationTurns.length}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className={`worker-detail-tab ${activeTab === 'narrative' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('narrative')}
|
||||
>
|
||||
Narrative
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
|
|
@ -214,6 +221,10 @@ const WorkerDetail: React.FC<WorkerDetailProps> = ({
|
|||
{activeTab === 'conversation' && (
|
||||
<ConversationTranscriptPanel turns={conversationTurns} highlightSequence={highlightSequence} />
|
||||
)}
|
||||
|
||||
{activeTab === 'narrative' && (
|
||||
<WorkerNarrativeInline workerId={worker.id} />
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7292,3 +7292,417 @@ body {
|
|||
color: var(--success);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Semantic Narrative Panel
|
||||
============================================ */
|
||||
|
||||
.narrative-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.narrative-toggle:hover,
|
||||
.narrative-toggle.active {
|
||||
border-color: var(--info);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.narrative-toggle-icon {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Standalone panel overlay */
|
||||
.narrative-panel {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
right: 0;
|
||||
width: 420px;
|
||||
max-width: 95vw;
|
||||
height: calc(100vh - 60px);
|
||||
background: var(--bg-secondary);
|
||||
border-left: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 300;
|
||||
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.narrative-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.narrative-panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.narrative-panel-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.narrative-refresh-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.narrative-refresh-btn:hover {
|
||||
border-color: var(--info);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.narrative-panel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.narrative-loading,
|
||||
.narrative-error,
|
||||
.narrative-empty {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.narrative-error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* Worker narrative card */
|
||||
.narrative-card {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.narrative-card:hover {
|
||||
border-color: var(--info);
|
||||
}
|
||||
|
||||
.narrative-card-header {
|
||||
padding: 0.65rem 0.85rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.narrative-card-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.3rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.narrative-worker-id {
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
color: var(--info);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.narrative-phase-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.narrative-sentiment {
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.narrative-summary {
|
||||
margin: 0.25rem 0 0.25rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.narrative-stats-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.narrative-error-count {
|
||||
color: var(--error);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Expanded card body */
|
||||
.narrative-card-body {
|
||||
padding: 0.6rem 0.85rem 0.85rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Phase progress bar */
|
||||
.narrative-phase-progress {
|
||||
display: flex;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-primary);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.narrative-phase-segment {
|
||||
height: 100%;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.narrative-phase-labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.narrative-section {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.narrative-section h4 {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
|
||||
.narrative-section ul {
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Segment list */
|
||||
.narrative-segments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.narrative-segment {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.narrative-segment.active {
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.narrative-segment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.narrative-segment-header:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.narrative-segment-icon {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.narrative-segment-summary {
|
||||
flex: 1;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.narrative-segment-duration {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.narrative-segment-confidence {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.narrative-active-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
flex-shrink: 0;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.narrative-segment-detail {
|
||||
padding: 0.45rem 0.6rem 0.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.narrative-segment-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.narrative-segment-details-text {
|
||||
margin: 0.3rem 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Entities */
|
||||
.narrative-entities {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.narrative-entity-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.narrative-entity-tag {
|
||||
font-size: 0.72rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
padding: 0.1rem 0.3rem;
|
||||
color: var(--text-primary);
|
||||
font-family: monospace;
|
||||
max-width: 120px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.narrative-entity-more {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.narrative-full-text {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Inline component (inside WorkerDetail) */
|
||||
.narrative-inline {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.narrative-inline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.narrative-inline-stats {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.narrative-inline-summary {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.narrative-inline-section {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.narrative-inline-section h4 {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
|
||||
.narrative-inline-section ul {
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.narrative-inline-loading,
|
||||
.narrative-inline-error,
|
||||
.narrative-inline-empty {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.narrative-inline-error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -580,6 +580,100 @@ export interface SessionDigestData {
|
|||
|
||||
export type DigestTab = 'summary' | 'beads' | 'files' | 'errors' | 'workers';
|
||||
|
||||
// ============================================
|
||||
// Git Integration Types
|
||||
// ============================================
|
||||
|
||||
export type GitFileStatus =
|
||||
| 'added'
|
||||
| 'modified'
|
||||
| 'deleted'
|
||||
| 'renamed'
|
||||
| 'copied'
|
||||
| 'untracked'
|
||||
| 'unmerged';
|
||||
|
||||
export interface GitFileChange {
|
||||
path: string;
|
||||
status: GitFileStatus;
|
||||
originalPath?: string;
|
||||
staged: boolean;
|
||||
}
|
||||
|
||||
export interface GitStatusEvent {
|
||||
id: string;
|
||||
type: 'status';
|
||||
ts: number;
|
||||
worker: string;
|
||||
bead?: string;
|
||||
branch: string;
|
||||
commit?: string;
|
||||
staged: GitFileChange[];
|
||||
unstaged: GitFileChange[];
|
||||
untracked: string[];
|
||||
ahead?: number;
|
||||
behind?: number;
|
||||
tracking?: string;
|
||||
}
|
||||
|
||||
export interface GitCommitEvent {
|
||||
id: string;
|
||||
type: 'commit';
|
||||
ts: number;
|
||||
worker: string;
|
||||
bead?: string;
|
||||
hash: string;
|
||||
message: string;
|
||||
branch?: string;
|
||||
author?: string;
|
||||
email?: string;
|
||||
parents?: string[];
|
||||
files?: GitFileChange[];
|
||||
}
|
||||
|
||||
export interface PRFileChange extends GitFileChange {
|
||||
linesAdded: number;
|
||||
linesDeleted: number;
|
||||
worker?: string;
|
||||
}
|
||||
|
||||
export interface PotentialConflict {
|
||||
hasUpstreamCommits: boolean;
|
||||
upstreamCommitCount: number;
|
||||
conflictingFiles: string[];
|
||||
rebaseRecommended: boolean;
|
||||
rebaseReason?: string;
|
||||
}
|
||||
|
||||
export interface PRPreview {
|
||||
title: string;
|
||||
description: string;
|
||||
commitMessage: string;
|
||||
files: PRFileChange[];
|
||||
totalLinesAdded: number;
|
||||
totalLinesDeleted: number;
|
||||
filesChanged: number;
|
||||
conflicts: PotentialConflict;
|
||||
sourceBranch: string;
|
||||
targetBranch: string;
|
||||
ahead: number;
|
||||
behind: number;
|
||||
hasUncommittedChanges: boolean;
|
||||
generatedAt: number;
|
||||
}
|
||||
|
||||
export interface GitStatusResponse {
|
||||
status: GitStatusEvent | null;
|
||||
commits: GitCommitEvent[];
|
||||
prPreview: PRPreview | null;
|
||||
hasConflicts: boolean;
|
||||
fileWorkerMap: Record<string, string[]>;
|
||||
totalGitEvents: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export type GitViewMode = 'status' | 'pr-preview' | 'diff';
|
||||
|
||||
export type ConversationTurnRole = 'system' | 'user' | 'assistant' | 'tool';
|
||||
|
||||
export interface ConversationTurn {
|
||||
|
|
@ -597,3 +691,76 @@ export interface ConversationTurn {
|
|||
sequence?: number;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Semantic Narrative Types
|
||||
// ============================================
|
||||
|
||||
export type EventPattern =
|
||||
| 'bead_started'
|
||||
| 'bead_completed'
|
||||
| 'file_editing'
|
||||
| 'file_created'
|
||||
| 'testing'
|
||||
| 'debugging'
|
||||
| 'git_operations'
|
||||
| 'dependency_install'
|
||||
| 'collision_detected'
|
||||
| 'error_recovery'
|
||||
| 'iteration'
|
||||
| 'investigation'
|
||||
| 'tool_usage'
|
||||
| 'error_handling'
|
||||
| 'task_completion'
|
||||
| 'exploration'
|
||||
| 'planning'
|
||||
| 'research';
|
||||
|
||||
export type NarrativeSentiment = 'productive' | 'struggling' | 'mixed' | 'idle';
|
||||
|
||||
export interface NarrativeSegmentView {
|
||||
id: string;
|
||||
pattern: EventPattern;
|
||||
summary: string;
|
||||
details?: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
durationMs: number;
|
||||
workerId: string;
|
||||
beadId?: string;
|
||||
entities: {
|
||||
files?: string[];
|
||||
tools?: string[];
|
||||
beads?: string[];
|
||||
errors?: string[];
|
||||
};
|
||||
confidence: number;
|
||||
isActive: boolean;
|
||||
eventCount: number;
|
||||
}
|
||||
|
||||
export interface SemanticNarrativeView {
|
||||
id: string;
|
||||
workerId: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
segments: NarrativeSegmentView[];
|
||||
fullNarrative: string;
|
||||
timeline: string[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
durationMs: number;
|
||||
accomplishments: string[];
|
||||
challenges: string[];
|
||||
sentiment: NarrativeSentiment;
|
||||
stats: {
|
||||
totalEvents: number;
|
||||
segmentCount: number;
|
||||
beadsWorked: number;
|
||||
filesModified: number;
|
||||
errorsEncountered: number;
|
||||
toolsUsed: number;
|
||||
};
|
||||
generatedAt: number;
|
||||
isLive: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { join, dirname } from 'path';
|
|||
import { fileURLToPath } from 'url';
|
||||
import { createSocket } from 'dgram';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { LogEvent, EventFilter, CrossReferenceEntityType, CrossReferenceRelationship, DagOptions, BeadStatus } from '../types.js';
|
||||
import { LogEvent, EventFilter, CrossReferenceEntityType, CrossReferenceRelationship, DagOptions, BeadStatus, SemanticNarrative, NarrativeSegment } from '../types.js';
|
||||
import { InMemoryEventStore } from '../store.js';
|
||||
import { SemanticNarrativeGenerator } from '../semanticNarrative.js';
|
||||
import { refreshDependencyGraph, getDagStats } from '../tui/dagUtils.js';
|
||||
|
|
@ -20,6 +20,8 @@ import { computeFleetAnalytics } from '../analytics.js';
|
|||
import { createOtlpHttpRouter } from '../otlpHttpReceiver.js';
|
||||
import { ServerMetrics } from '../serverMetrics.js';
|
||||
import { SessionDigestGenerator, formatDigestAsMarkdown } from '../sessionDigest.js';
|
||||
import { parseGitEvents } from '../gitParser.js';
|
||||
import { generatePRPreview } from '../tui/utils/prPreview.js';
|
||||
|
||||
/** Maximum payload size for POST requests (64KB) */
|
||||
const MAX_PAYLOAD_SIZE = 64 * 1024;
|
||||
|
|
@ -450,6 +452,72 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
|||
res.json(suggestions);
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Git Integration API Endpoints
|
||||
// ============================================
|
||||
|
||||
// Get live git status derived from ingested log events
|
||||
app.get('/api/git/status', (req: Request, res: Response) => {
|
||||
try {
|
||||
const workerFilter = req.query.worker as string | undefined;
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 500;
|
||||
|
||||
// Fetch events and parse git events from them
|
||||
const filter: EventFilter = {};
|
||||
if (workerFilter) filter.worker = workerFilter;
|
||||
const allEvents = store.query(filter).slice(-limit);
|
||||
const gitEvents = parseGitEvents(allEvents);
|
||||
|
||||
// Extract latest status event
|
||||
const statusEvents = gitEvents.filter(e => e.type === 'status');
|
||||
const currentStatus = statusEvents.length > 0 ? statusEvents[statusEvents.length - 1] : null;
|
||||
|
||||
// Extract recent commits
|
||||
const commitEvents = gitEvents.filter(e => e.type === 'commit');
|
||||
const recentCommits = commitEvents.slice(-10);
|
||||
|
||||
// Check for conflicts (unmerged files in staged/unstaged)
|
||||
let hasConflicts = false;
|
||||
if (currentStatus && currentStatus.type === 'status') {
|
||||
hasConflicts =
|
||||
currentStatus.staged.some(f => f.status === 'unmerged') ||
|
||||
currentStatus.unstaged.some(f => f.status === 'unmerged');
|
||||
}
|
||||
|
||||
// Build worker attribution map: file path → worker IDs
|
||||
const fileWorkerMap: Record<string, string[]> = {};
|
||||
for (const event of gitEvents) {
|
||||
if (event.type === 'status' && event.type === 'status') {
|
||||
for (const file of [...event.staged, ...event.unstaged]) {
|
||||
if (!fileWorkerMap[file.path]) fileWorkerMap[file.path] = [];
|
||||
if (!fileWorkerMap[file.path].includes(event.worker)) {
|
||||
fileWorkerMap[file.path].push(event.worker);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate PR preview
|
||||
const prPreview = gitEvents.length > 0 ? generatePRPreview(gitEvents) : null;
|
||||
|
||||
res.json({
|
||||
status: currentStatus,
|
||||
commits: recentCommits,
|
||||
prPreview,
|
||||
hasConflicts,
|
||||
fileWorkerMap,
|
||||
totalGitEvents: gitEvents.length,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error generating git status:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to generate git status',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Cross-Reference API Endpoints
|
||||
// ============================================
|
||||
|
|
@ -746,6 +814,71 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
|||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Semantic Narrative API Endpoints
|
||||
// ============================================
|
||||
|
||||
function serializeNarrative(narrative: SemanticNarrative) {
|
||||
return {
|
||||
...narrative,
|
||||
segments: narrative.segments.map((s: NarrativeSegment) => ({
|
||||
id: s.id,
|
||||
pattern: s.pattern,
|
||||
summary: s.summary,
|
||||
details: s.details,
|
||||
startTime: s.startTime,
|
||||
endTime: s.endTime,
|
||||
durationMs: s.durationMs,
|
||||
workerId: s.workerId,
|
||||
beadId: s.beadId,
|
||||
entities: s.entities,
|
||||
confidence: s.confidence,
|
||||
isActive: s.isActive,
|
||||
eventCount: s.events.length,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Get narratives for all active workers
|
||||
app.get('/api/narrative', (_req: Request, res: Response) => {
|
||||
try {
|
||||
const workers = store.getWorkers().filter(w => w.status === 'active');
|
||||
const narratives = [];
|
||||
|
||||
for (const worker of workers) {
|
||||
const events = store.query({ worker: worker.id });
|
||||
if (events.length === 0) continue;
|
||||
|
||||
const generator = new SemanticNarrativeGenerator();
|
||||
events.forEach(e => generator.processEvent(e));
|
||||
const narrative = generator.generateNarrative(worker.id);
|
||||
narratives.push(serializeNarrative(narrative));
|
||||
}
|
||||
|
||||
res.json(narratives);
|
||||
} catch (err) {
|
||||
console.error('Error generating narratives:', err);
|
||||
res.status(500).json({ error: 'Failed to generate narratives' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get narrative for a specific worker
|
||||
app.get('/api/narrative/:workerId', (req: Request, res: Response) => {
|
||||
try {
|
||||
const workerId = req.params.workerId as string;
|
||||
const events = store.query({ worker: workerId });
|
||||
|
||||
const generator = new SemanticNarrativeGenerator();
|
||||
events.forEach(e => generator.processEvent(e));
|
||||
const narrative = generator.generateNarrative(workerId);
|
||||
|
||||
res.json(serializeNarrative(narrative));
|
||||
} catch (err) {
|
||||
console.error('Error generating narrative:', err);
|
||||
res.status(500).json({ error: 'Failed to generate narrative' });
|
||||
}
|
||||
});
|
||||
|
||||
// Serve static frontend files
|
||||
const staticPath = join(__dirname, 'public');
|
||||
app.use(express.static(staticPath));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue