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:
jedarden 2026-04-24 06:48:02 -04:00
parent 0c1a4eebeb
commit 8b3c9adb05
6 changed files with 636 additions and 87 deletions

View file

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

View file

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

View 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)">&#8593;</button>
<button className="conversation-search-nav" onClick={nextSearchResult} title="Next (Enter)">&#8595;</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;

View file

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

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

View file

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