Enhance action handling and visualization in Clawdbot integration

- Introduced new action types: 'start', 'streaming', and 'complete' to improve event tracking and state management.
- Updated ActionGraph component to apply edge styling based on action type, enhancing visual representation of action flows.
- Modified ActionNode to display state labels and metadata for completed actions, improving user feedback.
- Refined addAction function to handle new action types and associated metadata, ensuring accurate session tracking.
- Enhanced chatEventToAction and agentEventToAction functions to map new action types and extract relevant metadata.
This commit is contained in:
luccast
2026-01-26 09:02:24 -05:00
parent 14e2eeabe0
commit 7ffc975884
5 changed files with 175 additions and 29 deletions
+46 -6
View File
@@ -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,
})
}
}
+51 -7
View File
@@ -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<MonitorAction['eventType'], { label: string; icon:
system: { label: 'System', icon: MessageSquare },
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`
const secs = ms / 1000
if (secs < 60) return `${secs.toFixed(1)}s`
const mins = Math.floor(secs / 60)
const remainSecs = Math.floor(secs % 60)
return `${mins}m ${remainSecs}s`
}
export const ActionNode = memo(function ActionNode({
data,
selected,
@@ -102,13 +122,19 @@ export const ActionNode = memo(function ActionNode({
? JSON.stringify(data.content)
: null
const truncatedContent = contentStr
? contentStr.length > 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 (
<motion.div
@@ -145,6 +171,24 @@ export const ActionNode = memo(function ActionNode({
<span className="text-crab-600">&gt;</span> {formatTime(data.timestamp)}
</div>
{/* Metadata for complete nodes */}
{hasMetadata && (
<div className="font-console text-[10px] text-shell-400 mb-1.5 flex gap-2 flex-wrap">
{data.duration && (
<span className="text-neon-cyan">{formatDuration(data.duration)}</span>
)}
{data.inputTokens && (
<span><span className="text-shell-500">in:</span> {data.inputTokens}</span>
)}
{data.outputTokens && (
<span><span className="text-shell-500">out:</span> {data.outputTokens}</span>
)}
{data.stopReason && (
<span className="text-neon-peach">{data.stopReason}</span>
)}
</div>
)}
{data.toolName && (
<div className="font-console text-[10px] text-neon-lavender mb-1.5">
<span className="text-shell-500">tool:</span> {data.toolName}
+39 -8
View File
@@ -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 })
+31 -7
View File
@@ -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,
}
}
+8 -1
View File
@@ -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