diff --git a/src/web/frontend/src/App.tsx b/src/web/frontend/src/App.tsx index 550270f..b072994 100644 --- a/src/web/frontend/src/App.tsx +++ b/src/web/frontend/src/App.tsx @@ -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 = () => { 📋 Digest + + {unacknowledgedAlertCount > 0 && ( + + + + +
+ {loading && narratives.length === 0 && ( +
Loading narratives...
+ )} + {error &&
{error}
} + {!loading && !error && narratives.length === 0 && ( +
No active workers to narrate.
+ )} + + {narratives.map(narrative => { + const currentPhase = detectPhase(narrative.segments); + const phaseProgress = getPhaseProgress(narrative.segments); + const isExpanded = expandedWorker === narrative.workerId; + + return ( +
+
setExpandedWorker(isExpanded ? null : narrative.workerId)} + > +
+ {narrative.workerId} + {currentPhase && ( + + {currentPhase} + + )} + + {SENTIMENT_ICONS[narrative.sentiment]} + +
+

{narrative.summary}

+
+ {formatDuration(narrative.durationMs)} + {narrative.stats.totalEvents} events + {narrative.stats.segmentCount} segments + {narrative.stats.errorsEncountered > 0 && ( + + {narrative.stats.errorsEncountered} errors + + )} +
+
+ + {isExpanded && ( +
+ {/* Phase progress bar */} +
+ {phaseProgress.map(({ phase, percent }) => + percent > 0 ? ( +
+ ) : null, + )} +
+
+ {phaseProgress + .filter(p => p.percent > 0) + .map(({ phase, percent }) => ( + + {phase} {percent}% + + ))} +
+ + {/* Accomplishments */} + {narrative.accomplishments.length > 0 && ( +
+

Accomplishments

+
    + {narrative.accomplishments.map((a, i) =>
  • {a}
  • )} +
+
+ )} + + {/* Challenges */} + {narrative.challenges.length > 0 && ( +
+

Challenges

+
    + {narrative.challenges.map((c, i) =>
  • {c}
  • )} +
+
+ )} + + {/* Segments timeline */} +
+

Activity Segments

+
+ {narrative.segments.map(seg => { + const isOpen = expandedSegment === seg.id; + const phase = PATTERN_TO_PHASE[seg.pattern] || 'Implementation'; + return ( +
+
setExpandedSegment(isOpen ? null : seg.id)} + > + + {phase.charAt(0)} + + {seg.summary} + + {formatDuration(seg.durationMs)} + + + {Math.round(seg.confidence * 100)}% + + {seg.isActive && } +
+ + {isOpen && ( +
+
+ + {formatTime(seg.startTime)} — {formatTime(seg.endTime)} + + {seg.eventCount} events +
+ {seg.details && ( +

{seg.details}

+ )} + {seg.entities.files && seg.entities.files.length > 0 && ( +
+ Files: + {seg.entities.files.slice(0, 5).map((f, i) => ( + + {f.split('/').pop()} + + ))} + {seg.entities.files.length > 5 && ( + + +{seg.entities.files.length - 5} + + )} +
+ )} + {seg.entities.tools && seg.entities.tools.length > 0 && ( +
+ Tools: + {seg.entities.tools.map((t, i) => ( + {t} + ))} +
+ )} +
+ )} +
+ ); + })} +
+
+ + {/* Full narrative */} + {narrative.fullNarrative && ( +
+

Narrative

+

{narrative.fullNarrative}

+
+ )} +
+ )} +
+ ); + })} +
+
+ ); +}; + +export default SemanticNarrativePanel; diff --git a/src/web/frontend/src/components/WorkerDetail.tsx b/src/web/frontend/src/components/WorkerDetail.tsx index 341ded9..83e9cb1 100644 --- a/src/web/frontend/src/components/WorkerDetail.tsx +++ b/src/web/frontend/src/components/WorkerDetail.tsx @@ -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 = { @@ -21,7 +22,7 @@ const NEEDLE_STATE_COLORS: Record = { STOPPED: '#777', }; -type WorkerTab = 'overview' | 'conversation'; +type WorkerTab = 'overview' | 'conversation' | 'narrative'; interface WorkerDetailProps { worker: WorkerInfo; @@ -115,6 +116,12 @@ const WorkerDetail: React.FC = ({ {conversationTurns.length} )} + {/* Tab content */} @@ -214,6 +221,10 @@ const WorkerDetail: React.FC = ({ {activeTab === 'conversation' && ( )} + + {activeTab === 'narrative' && ( + + )} ); diff --git a/src/web/frontend/src/index.css b/src/web/frontend/src/index.css index db73f0f..4f35344 100644 --- a/src/web/frontend/src/index.css +++ b/src/web/frontend/src/index.css @@ -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); +} + diff --git a/src/web/frontend/src/types.ts b/src/web/frontend/src/types.ts index 9454cff..e98e8f8 100644 --- a/src/web/frontend/src/types.ts +++ b/src/web/frontend/src/types.ts @@ -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; + 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; } + +// ============================================ +// 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; +} diff --git a/src/web/server.ts b/src/web/server.ts index da0fb18..239a5b2 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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 = {}; + 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));