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:
jedarden 2026-04-24 11:59:44 -04:00
parent 240957c8e0
commit 34aee6474f
6 changed files with 1269 additions and 2 deletions

View file

@ -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">&#x2335;</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">&#x1F4DD;</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">

View 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)} &middot; {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)} &mdash; {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)} &mdash; {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;

View file

@ -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>
);

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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));