diff --git a/src/web/frontend/src/App.tsx b/src/web/frontend/src/App.tsx index 20da28a..e88d6dd 100644 --- a/src/web/frontend/src/App.tsx +++ b/src/web/frontend/src/App.tsx @@ -258,6 +258,7 @@ const App: React.FC = () => { budget: { limit: number; spent: number; percentUsed: number; isOverBudget: boolean; warningLevel: 'none' | 'warning' | 'critical'; remaining: number }; burnRate: { costPerMinute: number; minutesToExhaustion: number | null; timeToExhaustion: string | null; projectedTotalCost: number; windowMinutes: number; isHighBurnRate: boolean }; } | null>(null); + const [highlightSequence, setHighlightSequence] = useState(null); const [selectedTimelineTime, setSelectedTimelineTime] = useState(null); const [recoverySuggestions, setRecoverySuggestions] = useState([]); @@ -557,6 +558,16 @@ const App: React.FC = () => { setTimeout(() => setSelectedTimelineTime(null), 5000); }, []); + // Activity stream → conversation sync: clicking an event selects the worker + // and highlights the corresponding turn in the conversation view + const handleEventSelect = useCallback((event: LogEvent) => { + setSelectedWorker(event.worker); + if (event.sequence != null) { + setHighlightSequence(event.sequence); + setTimeout(() => setHighlightSequence(null), 4000); + } + }, []); + // Filter workers and events based on Focus Mode const filteredWorkers = focusModeEnabled && pinnedWorkers.size > 0 ? workers.filter(w => pinnedWorkers.has(w.id)) @@ -845,6 +856,7 @@ const App: React.FC = () => { onTogglePinBead={togglePinBead} focusModeEnabled={focusModeEnabled} selectedTimelineTime={selectedTimelineTime} + onEventSelect={handleEventSelect} /> {selectedWorkerInfo && ( @@ -852,6 +864,7 @@ const App: React.FC = () => { worker={selectedWorkerInfo} onClose={() => setSelectedWorker(null)} allWorkerEvents={selectedWorker ? filteredEvents : undefined} + highlightSequence={highlightSequence} /> )} diff --git a/src/web/frontend/src/components/ActivityStream.tsx b/src/web/frontend/src/components/ActivityStream.tsx index 67fb8dc..5b93b87 100644 --- a/src/web/frontend/src/components/ActivityStream.tsx +++ b/src/web/frontend/src/components/ActivityStream.tsx @@ -11,6 +11,7 @@ interface ActivityStreamProps { onTogglePinBead?: (beadId: string) => void; focusModeEnabled?: boolean; selectedTimelineTime?: number | null; + onEventSelect?: (event: LogEvent) => void; } const ActivityStream: React.FC = ({ @@ -22,6 +23,7 @@ const ActivityStream: React.FC = ({ onTogglePinBead, focusModeEnabled = false, selectedTimelineTime, + onEventSelect, }) => { const listRef = useRef(null); const [filter, setFilter] = React.useState({}); @@ -193,6 +195,8 @@ const ActivityStream: React.FC = ({
onEventSelect?.(event)} + style={{ cursor: onEventSelect ? 'pointer' : 'default' }} > {formatTime(event.timestamp)} {event.level} diff --git a/src/web/frontend/src/components/ConversationTranscriptPanel.tsx b/src/web/frontend/src/components/ConversationTranscriptPanel.tsx new file mode 100644 index 0000000..078fee9 --- /dev/null +++ b/src/web/frontend/src/components/ConversationTranscriptPanel.tsx @@ -0,0 +1,346 @@ +import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react'; +import { ConversationTurn } from '../types'; + +interface ConversationTranscriptPanelProps { + turns: ConversationTurn[]; + onJumpToTurn?: (turnId: string) => void; + highlightSequence?: number | null; +} + +const ROLE_COLORS: Record = { + system: { bg: 'var(--bg-tertiary)', text: 'var(--text-secondary)', border: 'var(--bg-tertiary)' }, + user: { bg: 'rgba(33,150,243,0.15)', text: 'var(--info)', border: 'var(--info)' }, + assistant: { bg: 'rgba(0,200,83,0.12)', text: 'var(--success)', border: 'var(--success)' }, + tool: { bg: 'rgba(255,193,7,0.1)', text: 'var(--warning)', border: 'var(--warning)' }, +}; + +const ROLE_LABELS: Record = { + system: 'SYSTEM', + user: 'USER', + assistant: 'ASSISTANT', + tool: 'TOOL', +}; + +const ConversationTranscriptPanel: React.FC = ({ + turns, + onJumpToTurn, + highlightSequence, +}) => { + const [searchQuery, setSearchQuery] = useState(''); + const [searchActive, setSearchActive] = useState(false); + const [collapsedIds, setCollapsedIds] = useState>(() => { + const initial = new Set(); + turns.forEach(t => { if (t.isCollapsible && t.isCollapsed) initial.add(t.id); }); + return initial; + }); + const [searchResultIndices, setSearchResultIndices] = useState([]); + const [currentSearchIdx, setCurrentSearchIdx] = useState(-1); + const [highlightedTurnId, setHighlightedTurnId] = useState(null); + const turnRefs = useRef>(new Map()); + const searchInputRef = useRef(null); + + // Update collapsed state when turns change + useEffect(() => { + setCollapsedIds(prev => { + const next = new Set(); + turns.forEach(t => { + if (t.isCollapsible && (prev.has(t.id) || t.isCollapsed)) next.add(t.id); + }); + return next; + }); + }, [turns]); + + const filteredTurns = useMemo(() => { + if (!searchQuery.trim()) return turns; + const q = searchQuery.toLowerCase(); + return turns.filter(t => + t.content.toLowerCase().includes(q) || + t.eventType.toLowerCase().includes(q) || + (t.tool && t.tool.toLowerCase().includes(q)) + ); + }, [turns, searchQuery]); + + // Search result indices map into filteredTurns + const searchHits = useMemo(() => { + if (!searchQuery.trim()) return []; + const q = searchQuery.toLowerCase(); + const hits: number[] = []; + filteredTurns.forEach((t, i) => { + if ( + t.content.toLowerCase().includes(q) || + t.eventType.toLowerCase().includes(q) || + (t.tool && t.tool.toLowerCase().includes(q)) + ) { + hits.push(i); + } + }); + return hits; + }, [filteredTurns, searchQuery]); + + useEffect(() => { + setSearchResultIndices(searchHits); + setCurrentSearchIdx(searchHits.length > 0 ? 0 : -1); + }, [searchHits]); + + const toggleCollapse = useCallback((id: string) => { + setCollapsedIds(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const collapseAll = useCallback(() => { + setCollapsedIds(new Set(turns.filter(t => t.isCollapsible).map(t => t.id))); + }, [turns]); + + const expandAll = useCallback(() => { + setCollapsedIds(new Set()); + }, []); + + const scrollToTurn = useCallback((index: number) => { + if (index < 0 || index >= filteredTurns.length) return; + const turn = filteredTurns[index]; + const el = turnRefs.current.get(turn.id); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + if (onJumpToTurn) onJumpToTurn(turn.id); + }, [filteredTurns, onJumpToTurn]); + + const nextSearchResult = useCallback(() => { + if (searchResultIndices.length === 0) return; + const next = (currentSearchIdx + 1) % searchResultIndices.length; + setCurrentSearchIdx(next); + scrollToTurn(searchResultIndices[next]); + }, [searchResultIndices, currentSearchIdx, scrollToTurn]); + + const prevSearchResult = useCallback(() => { + if (searchResultIndices.length === 0) return; + const prev = (currentSearchIdx - 1 + searchResultIndices.length) % searchResultIndices.length; + setCurrentSearchIdx(prev); + scrollToTurn(searchResultIndices[prev]); + }, [searchResultIndices, currentSearchIdx, scrollToTurn]); + + // Keyboard shortcuts + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (searchActive) { + if (e.key === 'Escape') { + setSearchActive(false); + setSearchQuery(''); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (e.shiftKey) prevSearchResult(); + else nextSearchResult(); + } + return; + } + if (e.key === '/' && !e.metaKey && !e.ctrlKey) { + const tag = (e.target as HTMLElement).tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA') return; + e.preventDefault(); + setSearchActive(true); + setTimeout(() => searchInputRef.current?.focus(), 0); + } + if (e.key === 'n' && !e.metaKey && !e.ctrlKey) nextSearchResult(); + if (e.key === 'N' && e.shiftKey) prevSearchResult(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [searchActive, nextSearchResult, prevSearchResult]); + + // Scroll to first search result + useEffect(() => { + if (searchResultIndices.length > 0 && currentSearchIdx >= 0) { + scrollToTurn(searchResultIndices[currentSearchIdx]); + } + }, [searchResultIndices]); // eslint-disable-line react-hooks/exhaustive-deps + + // Scroll to turn highlighted from activity stream + useEffect(() => { + if (highlightSequence == null) { + setHighlightedTurnId(null); + return; + } + const turn = turns.find(t => t.sequence === highlightSequence); + if (turn) { + setHighlightedTurnId(turn.id); + const el = turnRefs.current.get(turn.id); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + // Clear highlight after 3s + const timer = setTimeout(() => setHighlightedTurnId(null), 3000); + return () => clearTimeout(timer); + } + }, [highlightSequence, turns]); + + const formatTime = (ts: number): string => { + const d = new Date(ts); + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + }; + + const formatDuration = (ms: number): string => { + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + const mins = Math.floor(ms / 60000); + const secs = Math.floor((ms % 60000) / 1000); + return `${mins}m ${secs}s`; + }; + + const highlightText = (text: string, query: string): React.ReactNode => { + if (!query.trim()) return text; + const idx = text.toLowerCase().indexOf(query.toLowerCase()); + if (idx === -1) return text; + return ( + <> + {text.slice(0, idx)} + {text.slice(idx, idx + query.length)} + {text.slice(idx + query.length)} + + ); + }; + + return ( +
+ {/* Toolbar */} +
+ + {filteredTurns.length} turn{filteredTurns.length !== 1 ? 's' : ''} + +
+ + + +
+
+ + {/* Search bar */} + {searchActive && ( +
+ setSearchQuery(e.target.value)} + /> + {searchQuery && ( + + {searchResultIndices.length === 0 + ? 'No results' + : `${currentSearchIdx + 1}/${searchResultIndices.length}`} + + )} + + + +
+ )} + + {/* Turn list */} +
+ {filteredTurns.length === 0 ? ( +
+ {turns.length === 0 ? 'No conversation events' : 'No matching turns'} +
+ ) : ( + filteredTurns.map((turn, idx) => { + const isCollapsed = collapsedIds.has(turn.id); + const isSearchHit = searchResultIndices.includes(idx); + const isCurrentHit = searchResultIndices[currentSearchIdx] === idx; + const colors = ROLE_COLORS[turn.role] || ROLE_COLORS.system; + + return ( +
{ if (el) turnRefs.current.set(turn.id, el); }} + className={[ + 'conversation-turn', + `conversation-turn-${turn.role}`, + isSearchHit ? 'conversation-turn-search-hit' : '', + isCurrentHit ? 'conversation-turn-current-hit' : '', + highlightedTurnId === turn.id ? 'conversation-turn-activity-highlight' : '', + ].filter(Boolean).join(' ')} + style={{ borderLeftColor: colors.border }} + > + {/* Turn header */} +
toggleCollapse(turn.id) : undefined} + style={{ cursor: turn.isCollapsible ? 'pointer' : 'default' }} + > + + {ROLE_LABELS[turn.role]} + + {turn.tool && ( + {turn.tool} + )} + {turn.eventType} + {formatTime(turn.timestamp)} + {turn.durationMs != null && ( + {formatDuration(turn.durationMs)} + )} + {turn.error && ( + ERROR + )} + {turn.isCollapsible && ( + + {isCollapsed ? '[+]' : '[-]'} + + )} +
+ + {/* Turn content */} + {(!turn.isCollapsible || !isCollapsed) && ( +
+ {turn.role === 'tool' && !isCollapsed ? ( +
+                        {highlightText(turn.content, searchQuery)}
+                      
+ ) : ( +
+ {highlightText(turn.content, searchQuery)} +
+ )} + {turn.error && ( +
{turn.error}
+ )} + {turn.meta && Object.keys(turn.meta).length > 0 && ( +
+ {turn.meta.bead && bead: {String(turn.meta.bead)}} + {turn.meta.path && path: {String(turn.meta.path)}} + {turn.meta.model && model: {String(turn.meta.model)}} +
+ )} +
+ )} +
+ ); + }) + )} +
+
+ ); +}; + +export default ConversationTranscriptPanel; diff --git a/src/web/frontend/src/components/WorkerDetail.tsx b/src/web/frontend/src/components/WorkerDetail.tsx index d173179..341ded9 100644 --- a/src/web/frontend/src/components/WorkerDetail.tsx +++ b/src/web/frontend/src/components/WorkerDetail.tsx @@ -1,5 +1,7 @@ -import React from 'react'; +import React, { useState, useMemo, useEffect } from 'react'; import { WorkerInfo, LogEvent, NeedleState } from '../types'; +import ConversationTranscriptPanel from './ConversationTranscriptPanel'; +import { logEventsToTurns } from '../utils/conversationTurns'; const NEEDLE_STATE_ICONS: Record = { BOOTING: '⏳', @@ -19,22 +21,35 @@ const NEEDLE_STATE_COLORS: Record = { STOPPED: '#777', }; +type WorkerTab = 'overview' | 'conversation'; + interface WorkerDetailProps { - /** The worker to display details for */ worker: WorkerInfo; - - /** Callback when the detail panel should close */ onClose: () => void; - - /** Optional: all events for this worker (if provided, shows more history) */ allWorkerEvents?: LogEvent[]; + highlightSequence?: number | null; } const WorkerDetail: React.FC = ({ worker, onClose, allWorkerEvents, + highlightSequence, }) => { + const [activeTab, setActiveTab] = useState('overview'); + + // Auto-switch to conversation tab when highlighting a turn + useEffect(() => { + if (highlightSequence != null) { + setActiveTab('conversation'); + } + }, [highlightSequence]); + + const conversationTurns = useMemo( + () => logEventsToTurns(allWorkerEvents || worker.recentEvents || []), + [allWorkerEvents, worker.recentEvents], + ); + const formatLastSeen = (timestamp: string): string => { const diff = Date.now() - new Date(timestamp).getTime(); const seconds = Math.floor(diff / 1000); @@ -83,93 +98,123 @@ const WorkerDetail: React.FC = ({
- {/* Collision warning if applicable */} - {worker.hasCollision && ( -
- ⚠️ - File collision detected! - {worker.activeFiles && worker.activeFiles.length > 0 && ( -
- {worker.activeFiles.slice(0, 3).map((file, i) => ( - - {file.split('/').pop()} - - ))} - {worker.activeFiles.length > 3 && ( - - +{worker.activeFiles.length - 3} more - - )} -
+ {/* Tab bar */} +
+ +
- )} - - {/* Status Section */} -
-

Status

-
- State - - {stateLabel} - -
-
- Events - {worker.eventCount} -
-
- Current Tool - - {worker.currentTool || '-'} - -
-
- Last Seen - - {formatLastSeen(worker.lastSeen)} - -
+
- {/* Recent Events Section */} -
-

Recent Events ({eventsToShow.length})

- {eventsToShow.length === 0 ? ( -
No events recorded
- ) : ( -
- {eventsToShow.slice(-10).map((event, i) => ( -
- - {formatTime(event.timestamp)} - - - {event.level.slice(0, 3).toUpperCase()} - - - {event.message.length > 35 - ? event.message.slice(0, 35) + '...' - : event.message} + {/* Tab content */} +
+ {activeTab === 'overview' && ( + <> + {/* Collision warning if applicable */} + {worker.hasCollision && ( +
+ ⚠️ + File collision detected! + {worker.activeFiles && worker.activeFiles.length > 0 && ( +
+ {worker.activeFiles.slice(0, 3).map((file, i) => ( + + {file.split('/').pop()} + + ))} + {worker.activeFiles.length > 3 && ( + + +{worker.activeFiles.length - 3} more + + )} +
+ )} +
+ )} + + {/* Status Section */} +
+

Status

+
+ State + + {stateLabel}
- ))} -
+
+ Events + {worker.eventCount} +
+
+ Current Tool + + {worker.currentTool || '-'} + +
+
+ Last Seen + + {formatLastSeen(worker.lastSeen)} + +
+
+ + {/* Recent Events Section */} +
+

Recent Events ({eventsToShow.length})

+ {eventsToShow.length === 0 ? ( +
No events recorded
+ ) : ( +
+ {eventsToShow.slice(-10).map((event, i) => ( +
+ + {formatTime(event.timestamp)} + + + {event.level.slice(0, 3).toUpperCase()} + + + {event.message.length > 35 + ? event.message.slice(0, 35) + '...' + : event.message} + +
+ ))} +
+ )} +
+ + {/* Tool Activity Section */} + {worker.currentTool && ( +
+

Current Activity

+
+ {worker.currentTool} +
+
+ )} + + )} + + {activeTab === 'conversation' && ( + )}
- - {/* Tool Activity Section */} - {worker.currentTool && ( -
-

Current Activity

-
- {worker.currentTool} -
-
- )} ); }; diff --git a/src/web/frontend/src/utils/conversationTurns.ts b/src/web/frontend/src/utils/conversationTurns.ts new file mode 100644 index 0000000..33faff6 --- /dev/null +++ b/src/web/frontend/src/utils/conversationTurns.ts @@ -0,0 +1,141 @@ +import { LogEvent, ConversationTurn, ConversationTurnRole } from '../types'; + +/** + * Convert LogEvents into ConversationTurns grouped by bead. + * + * Mapping strategy: + * - bead.prompt_built / bead.claimed → user (the worker received a task) + * - bead.agent_started / strand.started → system + * - bead.agent_completed / strand.completed → assistant + * - events with tool field → tool + * - worker.state_transition → system + * - error.* → system (error) + * - everything else → system + */ +export function logEventsToTurns(events: LogEvent[]): ConversationTurn[] { + const sorted = [...events].sort((a, b) => { + const seqA = a.sequence ?? a.ts; + const seqB = b.sequence ?? b.ts; + return seqA - seqB; + }); + + return sorted.map((event, i) => { + const { role, isCollapsible } = classifyEvent(event); + const content = extractContent(event); + + return { + id: `${event.worker}-${event.sequence ?? i}`, + role, + eventType: event.msg || event.level, + timestamp: event.ts ?? new Date(event.timestamp).getTime(), + content, + isCollapsible, + isCollapsed: isCollapsible, + tool: event.tool, + durationMs: typeof event.duration_ms === 'number' ? event.duration_ms : undefined, + error: event.error, + success: !event.error, + sequence: event.sequence, + meta: buildMeta(event), + }; + }); +} + +function classifyEvent(event: LogEvent): { role: ConversationTurnRole; isCollapsible: boolean } { + const msg = (event.msg || '').toLowerCase(); + const eventType = (event.msg || ''); + + // Tool events + if (event.tool) { + return { role: 'tool', isCollapsible: true }; + } + + // User prompt — the worker received a bead to work on + if ( + eventType === 'bead.prompt_built' || + eventType === 'bead.claimed' || + eventType === 'bead.agent_started' + ) { + return { role: 'user', isCollapsible: false }; + } + + // Assistant responses + if ( + eventType === 'bead.agent_completed' || + eventType === 'bead.completed' || + eventType === 'strand.completed' + ) { + return { role: 'assistant', isCollapsible: false }; + } + + // System events with long content + if (msg.includes('error') || eventType.startsWith('error.')) { + return { role: 'system', isCollapsible: !!event.error }; + } + + return { role: 'system', isCollapsible: false }; +} + +function extractContent(event: LogEvent): string { + const msg = event.msg || ''; + + // Tool events: show tool name + any context from raw + if (event.tool) { + const raw = typeof event.raw === 'string' ? event.raw : ''; + if (raw) { + try { + const parsed = JSON.parse(raw); + if (parsed.data) { + const dataStr = + typeof parsed.data === 'string' ? parsed.data : JSON.stringify(parsed.data, null, 2); + // Limit to first 500 chars + return dataStr.length > 500 ? dataStr.slice(0, 500) + '...' : dataStr; + } + } catch { + // fall through + } + } + return msg || `Tool: ${event.tool}`; + } + + // For bead events, try to extract meaningful content + const raw = typeof event.raw === 'string' ? event.raw : ''; + if (raw && (msg === 'bead.prompt_built' || msg === 'bead.agent_completed')) { + try { + const parsed = JSON.parse(raw); + if (parsed.data) { + if (typeof parsed.data === 'string') { + return parsed.data.length > 500 + ? parsed.data.slice(0, 500) + '...' + : parsed.data; + } + // For objects, check common content fields + const data = parsed.data as Record; + if (typeof data.content === 'string') return truncate(data.content, 500); + if (typeof data.prompt === 'string') return truncate(data.prompt, 500); + if (typeof data.message === 'string') return truncate(data.message, 500); + if (typeof data.result === 'string') return truncate(data.result, 500); + const str = JSON.stringify(data, null, 2); + return truncate(str, 500); + } + } catch { + // fall through + } + } + + return event.message || msg; +} + +function truncate(str: string, max: number): string { + return str.length > max ? str.slice(0, max) + '...' : str; +} + +function buildMeta(event: LogEvent): Record { + const meta: Record = {}; + if (event.bead) meta.bead = event.bead; + if (event.path) meta.path = event.path; + if (event.provider) meta.provider = event.provider; + if (event.model) meta.model = event.model; + if (event.session) meta.session = event.session; + return meta; +} diff --git a/src/web/frontend/test/WorkerDetail.test.tsx b/src/web/frontend/test/WorkerDetail.test.tsx index 4a62025..afdd6bd 100644 --- a/src/web/frontend/test/WorkerDetail.test.tsx +++ b/src/web/frontend/test/WorkerDetail.test.tsx @@ -435,7 +435,7 @@ describe('WorkerDetail', () => { render(); - const closeButton = screen.getByRole('button'); + const closeButton = screen.getByTitle('Close details'); expect(closeButton).toHaveAttribute('title', 'Close details'); });