feat(web): add conversation transcript view with activity stream sync
Port ConversationTranscript to React with role-labeled turns (System/User/Assistant/Tool), collapsible tool calls, search within conversation, and jump between turns. WorkerDetail now has tabbed overview/conversation view. Clicking events in ActivityStream selects the worker and highlights the matching turn in the conversation tab. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
0c1a4eebeb
commit
8b3c9adb05
6 changed files with 636 additions and 87 deletions
|
|
@ -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<number | null>(null);
|
||||
const [selectedTimelineTime, setSelectedTimelineTime] = useState<number | null>(null);
|
||||
const [recoverySuggestions, setRecoverySuggestions] = useState<RecoverySuggestion[]>([]);
|
||||
|
||||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ interface ActivityStreamProps {
|
|||
onTogglePinBead?: (beadId: string) => void;
|
||||
focusModeEnabled?: boolean;
|
||||
selectedTimelineTime?: number | null;
|
||||
onEventSelect?: (event: LogEvent) => void;
|
||||
}
|
||||
|
||||
const ActivityStream: React.FC<ActivityStreamProps> = ({
|
||||
|
|
@ -22,6 +23,7 @@ const ActivityStream: React.FC<ActivityStreamProps> = ({
|
|||
onTogglePinBead,
|
||||
focusModeEnabled = false,
|
||||
selectedTimelineTime,
|
||||
onEventSelect,
|
||||
}) => {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [filter, setFilter] = React.useState<ActivityFilter>({});
|
||||
|
|
@ -193,6 +195,8 @@ const ActivityStream: React.FC<ActivityStreamProps> = ({
|
|||
<div
|
||||
key={`${event.timestamp}-${i}`}
|
||||
className={`event-item ${eventBeadPinned ? 'bead-pinned' : ''}`}
|
||||
onClick={() => onEventSelect?.(event)}
|
||||
style={{ cursor: onEventSelect ? 'pointer' : 'default' }}
|
||||
>
|
||||
<span className="event-time">{formatTime(event.timestamp)}</span>
|
||||
<span className={`event-level ${event.level}`}>{event.level}</span>
|
||||
|
|
|
|||
346
src/web/frontend/src/components/ConversationTranscriptPanel.tsx
Normal file
346
src/web/frontend/src/components/ConversationTranscriptPanel.tsx
Normal file
|
|
@ -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<string, { bg: string; text: string; border: string }> = {
|
||||
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<string, string> = {
|
||||
system: 'SYSTEM',
|
||||
user: 'USER',
|
||||
assistant: 'ASSISTANT',
|
||||
tool: 'TOOL',
|
||||
};
|
||||
|
||||
const ConversationTranscriptPanel: React.FC<ConversationTranscriptPanelProps> = ({
|
||||
turns,
|
||||
onJumpToTurn,
|
||||
highlightSequence,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchActive, setSearchActive] = useState(false);
|
||||
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(() => {
|
||||
const initial = new Set<string>();
|
||||
turns.forEach(t => { if (t.isCollapsible && t.isCollapsed) initial.add(t.id); });
|
||||
return initial;
|
||||
});
|
||||
const [searchResultIndices, setSearchResultIndices] = useState<number[]>([]);
|
||||
const [currentSearchIdx, setCurrentSearchIdx] = useState(-1);
|
||||
const [highlightedTurnId, setHighlightedTurnId] = useState<string | null>(null);
|
||||
const turnRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Update collapsed state when turns change
|
||||
useEffect(() => {
|
||||
setCollapsedIds(prev => {
|
||||
const next = new Set<string>();
|
||||
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)}
|
||||
<mark className="search-highlight">{text.slice(idx, idx + query.length)}</mark>
|
||||
{text.slice(idx + query.length)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="conversation-transcript">
|
||||
{/* Toolbar */}
|
||||
<div className="conversation-toolbar">
|
||||
<span className="conversation-turn-count">
|
||||
{filteredTurns.length} turn{filteredTurns.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<div className="conversation-toolbar-actions">
|
||||
<button
|
||||
className="conversation-toolbar-btn"
|
||||
onClick={() => { setSearchActive(true); setTimeout(() => searchInputRef.current?.focus(), 0); }}
|
||||
title="Search ( / )"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
<button className="conversation-toolbar-btn" onClick={collapseAll} title="Collapse all tool calls">
|
||||
Collapse
|
||||
</button>
|
||||
<button className="conversation-toolbar-btn" onClick={expandAll} title="Expand all tool calls">
|
||||
Expand
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
{searchActive && (
|
||||
<div className="conversation-search-bar">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
className="conversation-search-input"
|
||||
placeholder="Search turns..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<span className="conversation-search-results">
|
||||
{searchResultIndices.length === 0
|
||||
? 'No results'
|
||||
: `${currentSearchIdx + 1}/${searchResultIndices.length}`}
|
||||
</span>
|
||||
)}
|
||||
<button className="conversation-search-nav" onClick={prevSearchResult} title="Previous (Shift+Enter)">↑</button>
|
||||
<button className="conversation-search-nav" onClick={nextSearchResult} title="Next (Enter)">↓</button>
|
||||
<button
|
||||
className="conversation-search-close"
|
||||
onClick={() => { setSearchActive(false); setSearchQuery(''); }}
|
||||
title="Close (Esc)"
|
||||
>
|
||||
Esc
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Turn list */}
|
||||
<div className="conversation-turn-list">
|
||||
{filteredTurns.length === 0 ? (
|
||||
<div className="conversation-empty">
|
||||
{turns.length === 0 ? 'No conversation events' : 'No matching turns'}
|
||||
</div>
|
||||
) : (
|
||||
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 (
|
||||
<div
|
||||
key={turn.id}
|
||||
ref={el => { 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 */}
|
||||
<div
|
||||
className="conversation-turn-header"
|
||||
onClick={turn.isCollapsible ? () => toggleCollapse(turn.id) : undefined}
|
||||
style={{ cursor: turn.isCollapsible ? 'pointer' : 'default' }}
|
||||
>
|
||||
<span className="conversation-turn-role" style={{ color: colors.text }}>
|
||||
{ROLE_LABELS[turn.role]}
|
||||
</span>
|
||||
{turn.tool && (
|
||||
<span className="conversation-turn-tool">{turn.tool}</span>
|
||||
)}
|
||||
<span className="conversation-turn-event">{turn.eventType}</span>
|
||||
<span className="conversation-turn-time">{formatTime(turn.timestamp)}</span>
|
||||
{turn.durationMs != null && (
|
||||
<span className="conversation-turn-duration">{formatDuration(turn.durationMs)}</span>
|
||||
)}
|
||||
{turn.error && (
|
||||
<span className="conversation-turn-error-badge">ERROR</span>
|
||||
)}
|
||||
{turn.isCollapsible && (
|
||||
<span className="conversation-turn-collapse-icon">
|
||||
{isCollapsed ? '[+]' : '[-]'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Turn content */}
|
||||
{(!turn.isCollapsible || !isCollapsed) && (
|
||||
<div className="conversation-turn-content">
|
||||
{turn.role === 'tool' && !isCollapsed ? (
|
||||
<pre className="conversation-turn-code">
|
||||
{highlightText(turn.content, searchQuery)}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="conversation-turn-text">
|
||||
{highlightText(turn.content, searchQuery)}
|
||||
</div>
|
||||
)}
|
||||
{turn.error && (
|
||||
<div className="conversation-turn-error">{turn.error}</div>
|
||||
)}
|
||||
{turn.meta && Object.keys(turn.meta).length > 0 && (
|
||||
<div className="conversation-turn-meta">
|
||||
{turn.meta.bead && <span>bead: {String(turn.meta.bead)}</span>}
|
||||
{turn.meta.path && <span>path: {String(turn.meta.path)}</span>}
|
||||
{turn.meta.model && <span>model: {String(turn.meta.model)}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConversationTranscriptPanel;
|
||||
|
|
@ -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<NeedleState, string> = {
|
||||
BOOTING: '⏳',
|
||||
|
|
@ -19,22 +21,35 @@ const NEEDLE_STATE_COLORS: Record<NeedleState, string> = {
|
|||
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<WorkerDetailProps> = ({
|
||||
worker,
|
||||
onClose,
|
||||
allWorkerEvents,
|
||||
highlightSequence,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<WorkerTab>('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<WorkerDetailProps> = ({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Collision warning if applicable */}
|
||||
{worker.hasCollision && (
|
||||
<div className="collision-alert">
|
||||
<span className="collision-alert-icon">⚠️</span>
|
||||
<span>File collision detected!</span>
|
||||
{worker.activeFiles && worker.activeFiles.length > 0 && (
|
||||
<div className="collision-files">
|
||||
{worker.activeFiles.slice(0, 3).map((file, i) => (
|
||||
<span key={i} className="collision-file" title={file}>
|
||||
{file.split('/').pop()}
|
||||
</span>
|
||||
))}
|
||||
{worker.activeFiles.length > 3 && (
|
||||
<span className="collision-more">
|
||||
+{worker.activeFiles.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Tab bar */}
|
||||
<div className="worker-detail-tabs">
|
||||
<button
|
||||
className={`worker-detail-tab ${activeTab === 'overview' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('overview')}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
className={`worker-detail-tab ${activeTab === 'conversation' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('conversation')}
|
||||
>
|
||||
Conversation
|
||||
{conversationTurns.length > 0 && (
|
||||
<span className="worker-detail-tab-count">{conversationTurns.length}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Section */}
|
||||
<div className="detail-section">
|
||||
<h3>Status</h3>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">State</span>
|
||||
<span
|
||||
className={`detail-value worker-status ${stateCssClass ?? ''}`}
|
||||
style={stateColor ? { color: stateColor } : undefined}
|
||||
>
|
||||
{stateLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Events</span>
|
||||
<span className="detail-value">{worker.eventCount}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Current Tool</span>
|
||||
<span className="detail-value tool-name">
|
||||
{worker.currentTool || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Last Seen</span>
|
||||
<span className="detail-value" title={worker.lastSeen}>
|
||||
{formatLastSeen(worker.lastSeen)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Recent Events Section */}
|
||||
<div className="detail-section">
|
||||
<h3>Recent Events ({eventsToShow.length})</h3>
|
||||
{eventsToShow.length === 0 ? (
|
||||
<div className="detail-empty">No events recorded</div>
|
||||
) : (
|
||||
<div className="detail-events">
|
||||
{eventsToShow.slice(-10).map((event, i) => (
|
||||
<div key={i} className="detail-event-item">
|
||||
<span className="detail-event-time">
|
||||
{formatTime(event.timestamp)}
|
||||
</span>
|
||||
<span className={`detail-event-level ${event.level}`}>
|
||||
{event.level.slice(0, 3).toUpperCase()}
|
||||
</span>
|
||||
<span className="detail-event-msg" title={event.message}>
|
||||
{event.message.length > 35
|
||||
? event.message.slice(0, 35) + '...'
|
||||
: event.message}
|
||||
{/* Tab content */}
|
||||
<div className="worker-detail-tab-content">
|
||||
{activeTab === 'overview' && (
|
||||
<>
|
||||
{/* Collision warning if applicable */}
|
||||
{worker.hasCollision && (
|
||||
<div className="collision-alert">
|
||||
<span className="collision-alert-icon">⚠️</span>
|
||||
<span>File collision detected!</span>
|
||||
{worker.activeFiles && worker.activeFiles.length > 0 && (
|
||||
<div className="collision-files">
|
||||
{worker.activeFiles.slice(0, 3).map((file, i) => (
|
||||
<span key={i} className="collision-file" title={file}>
|
||||
{file.split('/').pop()}
|
||||
</span>
|
||||
))}
|
||||
{worker.activeFiles.length > 3 && (
|
||||
<span className="collision-more">
|
||||
+{worker.activeFiles.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Section */}
|
||||
<div className="detail-section">
|
||||
<h3>Status</h3>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">State</span>
|
||||
<span
|
||||
className={`detail-value worker-status ${stateCssClass ?? ''}`}
|
||||
style={stateColor ? { color: stateColor } : undefined}
|
||||
>
|
||||
{stateLabel}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Events</span>
|
||||
<span className="detail-value">{worker.eventCount}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Current Tool</span>
|
||||
<span className="detail-value tool-name">
|
||||
{worker.currentTool || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Last Seen</span>
|
||||
<span className="detail-value" title={worker.lastSeen}>
|
||||
{formatLastSeen(worker.lastSeen)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Events Section */}
|
||||
<div className="detail-section">
|
||||
<h3>Recent Events ({eventsToShow.length})</h3>
|
||||
{eventsToShow.length === 0 ? (
|
||||
<div className="detail-empty">No events recorded</div>
|
||||
) : (
|
||||
<div className="detail-events">
|
||||
{eventsToShow.slice(-10).map((event, i) => (
|
||||
<div key={i} className="detail-event-item">
|
||||
<span className="detail-event-time">
|
||||
{formatTime(event.timestamp)}
|
||||
</span>
|
||||
<span className={`detail-event-level ${event.level}`}>
|
||||
{event.level.slice(0, 3).toUpperCase()}
|
||||
</span>
|
||||
<span className="detail-event-msg" title={event.message}>
|
||||
{event.message.length > 35
|
||||
? event.message.slice(0, 35) + '...'
|
||||
: event.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tool Activity Section */}
|
||||
{worker.currentTool && (
|
||||
<div className="detail-section">
|
||||
<h3>Current Activity</h3>
|
||||
<div className="tool-activity">
|
||||
<span className="tool-name">{worker.currentTool}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'conversation' && (
|
||||
<ConversationTranscriptPanel turns={conversationTurns} highlightSequence={highlightSequence} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tool Activity Section */}
|
||||
{worker.currentTool && (
|
||||
<div className="detail-section">
|
||||
<h3>Current Activity</h3>
|
||||
<div className="tool-activity">
|
||||
<span className="tool-name">{worker.currentTool}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
141
src/web/frontend/src/utils/conversationTurns.ts
Normal file
141
src/web/frontend/src/utils/conversationTurns.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
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<string, unknown> {
|
||||
const meta: Record<string, unknown> = {};
|
||||
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;
|
||||
}
|
||||
|
|
@ -435,7 +435,7 @@ describe('WorkerDetail', () => {
|
|||
|
||||
render(<WorkerDetail worker={worker} onClose={mockOnClose} />);
|
||||
|
||||
const closeButton = screen.getByRole('button');
|
||||
const closeButton = screen.getByTitle('Close details');
|
||||
expect(closeButton).toHaveAttribute('title', 'Close details');
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue