Enhance ActionNode and ActionGraph components with tool call integration

- Added support for displaying tool call icons in the ActionNode component, improving visual feedback for tool usage.
- Updated ActionGraph to include action type colors for error states and tools, enhancing clarity in action representation.
- Introduced new functions for managing tool calls and results in the clawdbot integration, streamlining tool tracking and updates.
- Refactored chat and agent event parsing to accommodate tool call data, ensuring comprehensive event handling.
This commit is contained in:
luccast
2026-01-26 15:11:05 -05:00
parent 1c2e2fa7fc
commit 5076a0de21
7 changed files with 295 additions and 87 deletions
+4
View File
@@ -608,6 +608,10 @@ function ActionGraphInner({
if (node.type === 'crab') return '#ef4444'
if (node.type === 'chaserCrab') return '#ef4444'
if (node.type === 'session') return '#98ffc8'
// Action type colors
const action = node.data as MonitorAction | undefined
if (action?.type === 'error') return '#ef4444'
if (action?.tools && action.tools.length > 0) return '#c4b5fd' // Has tools
return '#52526e'
}}
maskColor="rgba(10, 10, 15, 0.8)"
+85 -27
View File
@@ -11,8 +11,14 @@ import {
MessageCircle,
Bot,
Play,
FileText,
Terminal,
Search,
Edit3,
Globe,
Zap,
} from 'lucide-react'
import type { MonitorAction } from '~/integrations/clawdbot'
import type { MonitorAction, ToolCall } from '~/integrations/clawdbot'
interface ActionNodeProps {
data: MonitorAction
@@ -75,20 +81,75 @@ const stateConfig: Record<
iconColor: 'text-crab-400',
animate: false,
},
tool_call: {
icon: Wrench,
borderColor: 'border-neon-lavender',
bgColor: 'bg-neon-lavender/10',
iconColor: 'text-neon-lavender',
animate: false,
},
tool_result: {
icon: MessageSquare,
borderColor: 'border-pastel-sky',
bgColor: 'bg-pastel-sky/10',
iconColor: 'text-pastel-sky',
animate: false,
},
}
// Tool name to icon mapping
const toolIcons: Record<string, typeof Wrench> = {
Read: FileText,
Write: Edit3,
Edit: Edit3,
Bash: Terminal,
Grep: Search,
Glob: Search,
WebFetch: Globe,
WebSearch: Globe,
Task: Zap,
Skill: Zap,
}
function getToolIcon(name: string): typeof Wrench {
// Check exact match
if (toolIcons[name]) return toolIcons[name]
// Check partial match
for (const [key, icon] of Object.entries(toolIcons)) {
if (name.toLowerCase().includes(key.toLowerCase())) return icon
}
return Wrench
}
// Tool icon with hover tooltip
function ToolIcon({ tool }: { tool: ToolCall }) {
const [showTooltip, setShowTooltip] = useState(false)
const Icon = getToolIcon(tool.name)
const statusColor = {
pending: 'text-shell-400',
running: 'text-neon-cyan animate-pulse',
success: 'text-neon-mint',
error: 'text-crab-400',
}[tool.status]
return (
<div
className="relative"
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
<Icon size={14} className={statusColor} />
{showTooltip && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50 pointer-events-none">
<div className="bg-shell-800 border border-shell-600 rounded-lg px-2.5 py-2 shadow-xl min-w-[150px] max-w-[300px]">
<div className="font-console text-[11px] text-neon-lavender font-medium mb-1">
{tool.name}
</div>
<div className="font-console text-[10px] text-shell-400 mb-1">
{tool.status}
</div>
{tool.args && (
<pre className="font-console text-[9px] text-shell-500 bg-shell-950 p-1 rounded max-h-20 overflow-auto">
{typeof tool.args === 'string' ? tool.args : JSON.stringify(tool.args, null, 1)}
</pre>
)}
{tool.result && (
<div className="mt-1 font-console text-[9px] text-shell-400 line-clamp-2">
{tool.result.slice(0, 100)}{tool.result.length > 100 ? '...' : ''}
</div>
)}
</div>
</div>
)}
</div>
)
}
const eventTypeLabels: Record<MonitorAction['eventType'], { label: string; icon: typeof MessageCircle }> = {
@@ -173,6 +234,15 @@ export const ActionNode = memo(function ActionNode({
<span className="text-crab-600">&gt;</span> {formatTime(data.timestamp)}
</div>
{/* Tool icons */}
{data.tools && data.tools.length > 0 && (
<div className="flex items-center gap-1.5 mb-1.5 flex-wrap">
{data.tools.map((tool) => (
<ToolIcon key={tool.id} tool={tool} />
))}
</div>
)}
{/* Metadata for complete nodes */}
{hasMetadata && (
<div className="font-console text-xs text-shell-400 mb-1.5 flex gap-2 flex-wrap">
@@ -191,12 +261,6 @@ export const ActionNode = memo(function ActionNode({
</div>
)}
{data.toolName && (
<div className="font-console text-[10px] text-neon-lavender mb-1.5">
<span className="text-shell-500">tool:</span> {data.toolName}
</div>
)}
{/* Content - markdown for both preview and expanded */}
{(expanded ? fullContent : truncatedContent) && (
<div className={`
@@ -214,12 +278,6 @@ export const ActionNode = memo(function ActionNode({
</div>
)}
{expanded && data.toolArgs != null && (
<pre className="mt-2 font-console text-[11px] text-shell-500 bg-shell-950 p-2 rounded border border-shell-800 overflow-auto max-h-32">
{JSON.stringify(data.toolArgs, null, 2) as string}
</pre>
)}
<Handle type="source" position={Position.Bottom} className="bg-shell-600! w-2! h-2! border-shell-800!" />
</motion.div>
)
+34 -5
View File
@@ -1,5 +1,5 @@
import { createCollection, localOnlyCollectionOptions } from '@tanstack/db'
import type { MonitorSession, MonitorAction } from './protocol'
import type { MonitorSession, MonitorAction, ToolCall } from './protocol'
// Track runId → sessionKey mapping (learned from chat events)
const runSessionMap = new Map<string, string>()
@@ -117,11 +117,40 @@ export function addAction(action: MonitorAction) {
actionsCollection.insert({ ...action, sessionKey, id: `${action.runId}-complete` })
return
}
}
// For tool_call/tool_result, add as separate nodes
const existing = actionsCollection.state.get(action.id)
if (!existing) {
actionsCollection.insert({ ...action, sessionKey })
// Add a tool call to the current streaming action for a run
export function addToolCall(runId: string, tool: ToolCall) {
const streamingId = `${runId}-stream`
const streaming = actionsCollection.state.get(streamingId)
if (streaming) {
actionsCollection.update(streamingId, (draft) => {
if (!draft.tools) draft.tools = []
// Check if tool already exists (by id)
const existingIdx = draft.tools.findIndex(t => t.id === tool.id)
if (existingIdx >= 0) {
draft.tools[existingIdx] = { ...draft.tools[existingIdx], ...tool }
} else {
draft.tools.push(tool)
}
})
}
}
// Update a tool call result
export function updateToolResult(runId: string, toolId: string, result: string, status: ToolCall['status'] = 'success') {
const streamingId = `${runId}-stream`
const streaming = actionsCollection.state.get(streamingId)
if (streaming) {
actionsCollection.update(streamingId, (draft) => {
if (draft.tools) {
const tool = draft.tools.find(t => t.id === toolId)
if (tool) {
tool.result = result
tool.status = status
}
}
})
}
}
+148 -54
View File
@@ -5,9 +5,16 @@ import type {
MonitorSession,
MonitorAction,
SessionInfo,
ToolCall,
} from './protocol'
import { parseSessionKey } from './protocol'
export interface ParsedEvent {
session?: Partial<MonitorSession>
action?: MonitorAction
toolCall?: { runId: string; tool: ToolCall }
}
export function sessionInfoToMonitor(info: SessionInfo): MonitorSession {
const parsed = parseSessionKey(info.key)
return {
@@ -21,7 +28,12 @@ export function sessionInfoToMonitor(info: SessionInfo): MonitorSession {
}
}
export function chatEventToAction(event: ChatEvent): MonitorAction {
export interface ChatParseResult {
action: MonitorAction
toolCalls: ToolCall[]
}
export function chatEventToAction(event: ChatEvent): ChatParseResult {
// Map chat state to new action types
let type: MonitorAction['type'] = 'streaming'
if (event.state === 'final') type = 'complete'
@@ -39,6 +51,8 @@ export function chatEventToAction(event: ChatEvent): MonitorAction {
timestamp: Date.now(),
}
const toolCalls: ToolCall[] = []
// Extract usage/stopReason from final events
if (event.state === 'final') {
if (event.usage) {
@@ -56,7 +70,7 @@ export function chatEventToAction(event: ChatEvent): MonitorAction {
} else if (typeof event.message === 'object') {
const msg = event.message as Record<string, unknown>
// Extract text from content blocks: [{type: 'text', text: '...'}]
// Extract text and tools from content blocks
if (Array.isArray(msg.content)) {
const texts: string[] = []
for (const block of msg.content) {
@@ -65,12 +79,21 @@ export function chatEventToAction(event: ChatEvent): MonitorAction {
if (b.type === 'text' && typeof b.text === 'string') {
texts.push(b.text)
} else if (b.type === 'tool_use') {
action.type = 'tool_call'
action.toolName = String(b.name || 'unknown')
action.toolArgs = b.input
toolCalls.push({
id: String(b.id || `tool-${Date.now()}`),
name: String(b.name || 'unknown'),
args: b.input,
status: 'pending',
timestamp: Date.now(),
})
} else if (b.type === 'tool_result') {
action.type = 'tool_result'
if (typeof b.content === 'string') {
// Find matching tool call and update it
const toolId = String(b.tool_use_id || '')
const existingTool = toolCalls.find(t => t.id === toolId)
if (existingTool) {
existingTool.result = typeof b.content === 'string' ? b.content : JSON.stringify(b.content)
existingTool.status = b.is_error ? 'error' : 'success'
} else if (typeof b.content === 'string') {
texts.push(b.content)
}
}
@@ -91,21 +114,25 @@ export function chatEventToAction(event: ChatEvent): MonitorAction {
action.content = event.errorMessage
}
return action
return { action, toolCalls }
}
export function agentEventToAction(event: AgentEvent): MonitorAction {
const data = event.data
export interface AgentParseResult {
action?: MonitorAction
toolCall?: ToolCall
toolResult?: { toolId: string; result: string; isError: boolean }
}
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
export function agentEventToAction(event: AgentEvent): AgentParseResult {
const data = event.data
// Handle lifecycle events
if (event.stream === 'lifecycle') {
let type: MonitorAction['type'] = 'streaming'
let content: string | undefined
let startedAt: number | undefined
let endedAt: number | undefined
if (data.phase === 'start') {
type = 'start'
content = 'Run started'
@@ -115,39 +142,67 @@ export function agentEventToAction(event: AgentEvent): MonitorAction {
content = 'Run completed'
endedAt = typeof data.endedAt === 'number' ? data.endedAt : event.ts
}
} else if (data.type === 'tool_use') {
type = 'tool_call'
toolName = String(data.name || 'unknown')
toolArgs = data.input
content = `Tool: ${toolName}`
} else if (data.type === 'tool_result') {
type = 'tool_result'
content = String(data.content || '')
} else if (data.type === 'text') {
type = 'streaming'
content = String(data.text || '')
return {
action: {
id: `${event.runId}-${event.seq}`,
runId: event.runId,
sessionKey: event.sessionKey || event.stream,
seq: event.seq,
type,
eventType: 'agent' as const,
timestamp: event.ts,
content,
startedAt,
endedAt,
}
}
}
return {
id: `${event.runId}-${event.seq}`,
runId: event.runId,
// Use sessionKey from event if available, fallback to stream
sessionKey: event.sessionKey || event.stream,
seq: event.seq,
type,
eventType: 'agent' as const,
timestamp: event.ts,
content,
toolName,
toolArgs,
startedAt,
endedAt,
// Handle tool use - return as tool call to be aggregated
if (data.type === 'tool_use') {
return {
toolCall: {
id: String(data.id || `tool-${event.seq}`),
name: String(data.name || 'unknown'),
args: data.input,
status: 'running',
timestamp: event.ts,
}
}
}
// Handle tool result
if (data.type === 'tool_result') {
return {
toolResult: {
toolId: String(data.tool_use_id || ''),
result: typeof data.content === 'string' ? data.content : JSON.stringify(data.content),
isError: Boolean(data.is_error),
}
}
}
// Text streaming (usually duplicates chat events)
if (data.type === 'text') {
return {
action: {
id: `${event.runId}-${event.seq}`,
runId: event.runId,
sessionKey: event.sessionKey || event.stream,
seq: event.seq,
type: 'streaming',
eventType: 'agent' as const,
timestamp: event.ts,
content: String(data.text || ''),
}
}
}
return {}
}
export function parseEventFrame(
frame: EventFrame
): { session?: Partial<MonitorSession>; action?: MonitorAction } | null {
export function parseEventFrame(frame: EventFrame): ParsedEvent | null {
// Skip system events
if (frame.event === 'health' || frame.event === 'tick') {
return null
@@ -155,28 +210,33 @@ export function parseEventFrame(
if (frame.event === 'chat' && frame.payload) {
const chatEvent = frame.payload as ChatEvent
return {
action: chatEventToAction(chatEvent),
const { action, toolCalls } = chatEventToAction(chatEvent)
const result: ParsedEvent = {
action,
session: {
key: chatEvent.sessionKey,
status: chatEvent.state === 'delta' ? 'thinking' : 'active',
lastActivityAt: Date.now(),
},
}
// If there are tool calls, return the first one (others will come in subsequent events)
if (toolCalls.length > 0) {
result.toolCall = { runId: chatEvent.runId, tool: toolCalls[0]! }
}
return result
}
if (frame.event === 'agent' && frame.payload) {
const agentEvent = frame.payload as AgentEvent
const parsed = agentEventToAction(agentEvent)
// Skip assistant stream - it duplicates chat events
if (agentEvent.stream === 'assistant') {
return null
}
// Only process lifecycle events (start/end markers)
if (agentEvent.stream === 'lifecycle') {
// Lifecycle events return action
if (parsed.action) {
return {
action: agentEventToAction(agentEvent),
action: parsed.action,
session: agentEvent.sessionKey ? {
key: agentEvent.sessionKey,
status: agentEvent.data?.phase === 'start' ? 'thinking' : 'active',
@@ -185,6 +245,40 @@ export function parseEventFrame(
}
}
// Tool calls
if (parsed.toolCall) {
return {
toolCall: { runId: agentEvent.runId, tool: parsed.toolCall },
session: agentEvent.sessionKey ? {
key: agentEvent.sessionKey,
status: 'thinking',
lastActivityAt: Date.now(),
} : undefined,
}
}
// Tool results - need special handling
if (parsed.toolResult) {
// Return as a pseudo tool call that will update existing tool
return {
toolCall: {
runId: agentEvent.runId,
tool: {
id: parsed.toolResult.toolId,
name: '', // Will be filled from existing
result: parsed.toolResult.result,
status: parsed.toolResult.isError ? 'error' : 'success',
timestamp: agentEvent.ts,
}
},
session: agentEvent.sessionKey ? {
key: agentEvent.sessionKey,
status: 'active',
lastActivityAt: Date.now(),
} : undefined,
}
}
return null
}
+13 -1
View File
@@ -112,15 +112,27 @@ export interface MonitorSession {
status: 'idle' | 'active' | 'thinking'
}
export interface ToolCall {
id: string
name: string
args?: unknown
result?: string
status: 'pending' | 'running' | 'success' | 'error'
timestamp: number
}
export interface MonitorAction {
id: string
runId: string
sessionKey: string
seq: number
type: 'start' | 'streaming' | 'complete' | 'aborted' | 'error' | 'tool_call' | 'tool_result'
type: 'start' | 'streaming' | 'complete' | 'aborted' | 'error'
eventType: 'chat' | 'agent' | 'system'
timestamp: number
content?: string
// Inline tool calls for this action
tools?: ToolCall[]
// Legacy fields (for backwards compat)
toolName?: string
toolArgs?: unknown
// Metadata from lifecycle/chat events
+7
View File
@@ -8,6 +8,7 @@ import {
sessionInfoToMonitor,
type MonitorSession,
type MonitorAction,
type ToolCall,
} from '~/integrations/clawdbot'
// Server-side debug mode state
@@ -157,12 +158,18 @@ const clawdbotRouter = router({
if (debugMode && parsed.action) {
console.log('[DEBUG] Parsed action:', parsed.action.type, parsed.action.eventType, 'sessionKey:', parsed.action.sessionKey)
}
if (debugMode && parsed.toolCall) {
console.log('[DEBUG] Tool call:', parsed.toolCall.tool.name, 'status:', parsed.toolCall.tool.status)
}
if (parsed.session) {
emit.next({ type: 'session', session: parsed.session })
}
if (parsed.action) {
emit.next({ type: 'action', action: parsed.action })
}
if (parsed.toolCall) {
emit.next({ type: 'toolCall', runId: parsed.toolCall.runId, tool: parsed.toolCall.tool })
}
}
})
+4
View File
@@ -9,6 +9,7 @@ import {
actionsCollection,
upsertSession,
addAction,
addToolCall,
updateSessionStatus,
clearCollections,
} from '~/integrations/clawdbot'
@@ -242,6 +243,9 @@ function MonitorPage() {
if (data.type === 'action' && data.action) {
addAction(data.action)
}
if (data.type === 'toolCall' && data.runId && data.tool) {
addToolCall(data.runId, data.tool)
}
},
onError: (err) => {
console.error('[monitor] subscription error:', err)