diff --git a/src/components/monitor/ActionGraph.tsx b/src/components/monitor/ActionGraph.tsx index eb75fcf..d9d7a9f 100644 --- a/src/components/monitor/ActionGraph.tsx +++ b/src/components/monitor/ActionGraph.tsx @@ -138,6 +138,48 @@ function ActionGraphInner({ sessionActions.set(key, list) } + // Edge styling based on action type + const getEdgeStyle = (action: MonitorAction) => { + switch (action.type) { + case 'start': + return { + animated: true, + style: { stroke: '#98ffc8', strokeDasharray: '5 5' }, // mint dashed + markerEnd: { type: MarkerType.ArrowClosed, color: '#98ffc8' }, + } + case 'streaming': + return { + animated: true, + style: { stroke: '#00ffd5' }, // cyan + markerEnd: { type: MarkerType.ArrowClosed, color: '#00ffd5' }, + } + case 'complete': + return { + animated: false, + style: { stroke: '#98ffc8' }, // mint solid + markerEnd: { type: MarkerType.ArrowClosed, color: '#98ffc8' }, + } + case 'error': + return { + animated: false, + style: { stroke: '#ef4444' }, // red + markerEnd: { type: MarkerType.ArrowClosed, color: '#ef4444' }, + } + case 'aborted': + return { + animated: false, + style: { stroke: '#ffb399' }, // peach + markerEnd: { type: MarkerType.ArrowClosed, color: '#ffb399' }, + } + default: + return { + animated: false, + style: { stroke: '#52526e' }, + markerEnd: { type: MarkerType.ArrowClosed, color: '#52526e' }, + } + } + } + // Connect actions in a chain per session for (const [sessionKey, actions] of sessionActions) { const sorted = [...actions].sort((a, b) => a.timestamp - b.timestamp) @@ -145,6 +187,8 @@ function ActionGraphInner({ for (let i = 0; i < sorted.length; i++) { const action = sorted[i]! + const edgeStyle = getEdgeStyle(action) + if (i === 0) { // First action connects to session if (sessionNodeIds.has(sessionId)) { @@ -152,9 +196,7 @@ function ActionGraphInner({ id: `e-session-${action.id}`, source: sessionId, target: `action-${action.id}`, - animated: action.type === 'delta', - markerEnd: { type: MarkerType.ArrowClosed, color: '#52526e' }, - style: { stroke: '#52526e' }, + ...edgeStyle, }) } } else { @@ -164,9 +206,7 @@ function ActionGraphInner({ id: `e-${prev.id}-${action.id}`, source: `action-${prev.id}`, target: `action-${action.id}`, - animated: action.type === 'delta', - markerEnd: { type: MarkerType.ArrowClosed, color: '#52526e' }, - style: { stroke: '#52526e' }, + ...edgeStyle, }) } } diff --git a/src/components/monitor/ActionNode.tsx b/src/components/monitor/ActionNode.tsx index 167f6ac..eed1a06 100644 --- a/src/components/monitor/ActionNode.tsx +++ b/src/components/monitor/ActionNode.tsx @@ -9,6 +9,7 @@ import { MessageSquare, MessageCircle, Bot, + Play, } from 'lucide-react' import type { MonitorAction } from '~/integrations/clawdbot' @@ -33,16 +34,25 @@ const stateConfig: Record< bgColor: string iconColor: string animate: boolean + label?: string } > = { - delta: { + start: { + icon: Play, + borderColor: 'border-neon-mint', + bgColor: 'bg-neon-mint/10', + iconColor: 'text-neon-mint', + animate: false, + label: 'Run Started', + }, + streaming: { icon: Loader2, borderColor: 'border-neon-cyan', bgColor: 'bg-neon-cyan/10', iconColor: 'text-neon-cyan', animate: true, }, - final: { + complete: { icon: CheckCircle, borderColor: 'border-neon-mint', bgColor: 'bg-neon-mint/10', @@ -55,6 +65,7 @@ const stateConfig: Record< bgColor: 'bg-neon-peach/10', iconColor: 'text-neon-peach', animate: false, + label: 'Aborted', }, error: { icon: XCircle, @@ -85,6 +96,15 @@ const eventTypeLabels: Record 100 - ? contentStr.slice(0, 100) + '...' - : contentStr + // Use state label for start/aborted, otherwise content + const displayContent = state.label || contentStr + + const truncatedContent = displayContent + ? displayContent.length > 100 + ? displayContent.slice(0, 100) + '...' + : displayContent : null - const fullContent = contentStr + const fullContent = displayContent + + // Metadata for complete nodes + const hasMetadata = data.type === 'complete' && (data.duration || data.inputTokens || data.outputTokens) return ( > {formatTime(data.timestamp)} + {/* Metadata for complete nodes */} + {hasMetadata && ( +
+ {data.duration && ( + {formatDuration(data.duration)} + )} + {data.inputTokens && ( + in: {data.inputTokens} + )} + {data.outputTokens && ( + out: {data.outputTokens} + )} + {data.stopReason && ( + {data.stopReason} + )} +
+ )} + {data.toolName && (
tool: {data.toolName} diff --git a/src/integrations/clawdbot/collections.ts b/src/integrations/clawdbot/collections.ts index e5898f6..a93a300 100644 --- a/src/integrations/clawdbot/collections.ts +++ b/src/integrations/clawdbot/collections.ts @@ -31,7 +31,11 @@ export function upsertSession(session: MonitorSession) { } // Helper to add or update action -// For deltas, we aggregate into a single "streaming" action per runId +// Aggregation strategy per run: +// - start: one node per runId (appears immediately) +// - streaming: aggregate all deltas into one node (content updates) +// - complete: updates streaming node with final state & metadata +// - tool_call/tool_result: separate nodes export function addAction(action: MonitorAction) { // Learn runId → sessionKey mapping from actions with real session keys if (action.sessionKey && !action.sessionKey.includes('lifecycle')) { @@ -44,14 +48,30 @@ export function addAction(action: MonitorAction) { sessionKey = runSessionMap.get(action.runId) || sessionKey } - // For deltas, use runId as the key (aggregate all deltas) - if (action.type === 'delta') { + // Handle 'start' type - create dedicated start node + if (action.type === 'start') { + const startId = `${action.runId}-start` + const existing = actionsCollection.state.get(startId) + if (!existing) { + actionsCollection.insert({ + ...action, + id: startId, + sessionKey, + }) + } + return + } + + // For streaming, aggregate into single node per runId + if (action.type === 'streaming') { const streamingId = `${action.runId}-stream` const existing = actionsCollection.state.get(streamingId) if (existing) { // Append content and update sessionKey if we learned it actionsCollection.update(streamingId, (draft) => { - draft.content = (draft.content || '') + (action.content || '') + if (action.content) { + draft.content = (draft.content || '') + action.content + } draft.seq = action.seq draft.timestamp = action.timestamp if (sessionKey && sessionKey !== 'lifecycle') { @@ -69,8 +89,8 @@ export function addAction(action: MonitorAction) { return } - // For final/error/aborted, update the streaming action's type - if (action.type === 'final' || action.type === 'error' || action.type === 'aborted') { + // For complete/error/aborted, update the streaming action + if (action.type === 'complete' || action.type === 'error' || action.type === 'aborted') { const streamingId = `${action.runId}-stream` const streaming = actionsCollection.state.get(streamingId) if (streaming) { @@ -81,13 +101,24 @@ export function addAction(action: MonitorAction) { if (sessionKey && sessionKey !== 'lifecycle') { draft.sessionKey = sessionKey } + // Copy metadata from complete event + if (action.inputTokens !== undefined) draft.inputTokens = action.inputTokens + if (action.outputTokens !== undefined) draft.outputTokens = action.outputTokens + if (action.stopReason) draft.stopReason = action.stopReason + if (action.endedAt) draft.endedAt = action.endedAt + // Calculate duration if we have both timestamps + if (draft.startedAt && action.endedAt) { + draft.duration = action.endedAt - draft.startedAt + } }) return } - // No streaming action found, create as-is + // No streaming action found, create as-is with complete state + actionsCollection.insert({ ...action, sessionKey, id: `${action.runId}-complete` }) + return } - // For tool_call/tool_result or orphaned finals, add as new + // For tool_call/tool_result, add as separate nodes const existing = actionsCollection.state.get(action.id) if (!existing) { actionsCollection.insert({ ...action, sessionKey }) diff --git a/src/integrations/clawdbot/parser.ts b/src/integrations/clawdbot/parser.ts index fc3101a..1231bf4 100644 --- a/src/integrations/clawdbot/parser.ts +++ b/src/integrations/clawdbot/parser.ts @@ -22,16 +22,34 @@ export function sessionInfoToMonitor(info: SessionInfo): MonitorSession { } export function chatEventToAction(event: ChatEvent): MonitorAction { + // Map chat state to new action types + let type: MonitorAction['type'] = 'streaming' + if (event.state === 'final') type = 'complete' + else if (event.state === 'delta') type = 'streaming' + else if (event.state === 'aborted') type = 'aborted' + else if (event.state === 'error') type = 'error' + const action: MonitorAction = { id: `${event.runId}-${event.seq}`, runId: event.runId, sessionKey: event.sessionKey, seq: event.seq, - type: event.state, + type, eventType: 'chat', timestamp: Date.now(), } + // Extract usage/stopReason from final events + if (event.state === 'final') { + if (event.usage) { + action.inputTokens = event.usage.inputTokens + action.outputTokens = event.usage.outputTokens + } + if (event.stopReason) { + action.stopReason = event.stopReason + } + } + if (event.message) { if (typeof event.message === 'string') { action.content = event.message @@ -79,19 +97,23 @@ export function chatEventToAction(event: ChatEvent): MonitorAction { export function agentEventToAction(event: AgentEvent): MonitorAction { const data = event.data - let type: MonitorAction['type'] = 'delta' + let type: MonitorAction['type'] = 'streaming' let content: string | undefined let toolName: string | undefined let toolArgs: unknown | undefined + let startedAt: number | undefined + let endedAt: number | undefined // Handle lifecycle events if (event.stream === 'lifecycle') { if (data.phase === 'start') { - type = 'delta' - content = 'Agent run started' + type = 'start' + content = 'Run started' + startedAt = typeof data.startedAt === 'number' ? data.startedAt : event.ts } else if (data.phase === 'end') { - type = 'final' - content = 'Agent run completed' + type = 'complete' + content = 'Run completed' + endedAt = typeof data.endedAt === 'number' ? data.endedAt : event.ts } } else if (data.type === 'tool_use') { type = 'tool_call' @@ -102,7 +124,7 @@ export function agentEventToAction(event: AgentEvent): MonitorAction { type = 'tool_result' content = String(data.content || '') } else if (data.type === 'text') { - type = 'delta' + type = 'streaming' content = String(data.text || '') } @@ -118,6 +140,8 @@ export function agentEventToAction(event: AgentEvent): MonitorAction { content, toolName, toolArgs, + startedAt, + endedAt, } } diff --git a/src/integrations/clawdbot/protocol.ts b/src/integrations/clawdbot/protocol.ts index 1e2625a..632cec1 100644 --- a/src/integrations/clawdbot/protocol.ts +++ b/src/integrations/clawdbot/protocol.ts @@ -117,12 +117,19 @@ export interface MonitorAction { runId: string sessionKey: string seq: number - type: 'delta' | 'final' | 'aborted' | 'error' | 'tool_call' | 'tool_result' + type: 'start' | 'streaming' | 'complete' | 'aborted' | 'error' | 'tool_call' | 'tool_result' eventType: 'chat' | 'agent' | 'system' timestamp: number content?: string toolName?: string toolArgs?: unknown + // Metadata from lifecycle/chat events + startedAt?: number + endedAt?: number + duration?: number + inputTokens?: number + outputTokens?: number + stopReason?: string } // Utility functions