From 5076a0de21a0b0c62664c9907e4c19d3994a7fab Mon Sep 17 00:00:00 2001 From: luccast <2213102+luccast@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:11:05 -0500 Subject: [PATCH] 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. --- src/components/monitor/ActionGraph.tsx | 4 + src/components/monitor/ActionNode.tsx | 112 ++++++++++--- src/integrations/clawdbot/collections.ts | 39 ++++- src/integrations/clawdbot/parser.ts | 202 +++++++++++++++++------ src/integrations/clawdbot/protocol.ts | 14 +- src/integrations/trpc/router.ts | 7 + src/routes/monitor/index.tsx | 4 + 7 files changed, 295 insertions(+), 87 deletions(-) diff --git a/src/components/monitor/ActionGraph.tsx b/src/components/monitor/ActionGraph.tsx index 6cc730b..e752425 100644 --- a/src/components/monitor/ActionGraph.tsx +++ b/src/components/monitor/ActionGraph.tsx @@ -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)" diff --git a/src/components/monitor/ActionNode.tsx b/src/components/monitor/ActionNode.tsx index a8fec4b..d3aaa89 100644 --- a/src/components/monitor/ActionNode.tsx +++ b/src/components/monitor/ActionNode.tsx @@ -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 = { + 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 ( +
setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + > + + {showTooltip && ( +
+
+
+ {tool.name} +
+
+ {tool.status} +
+ {tool.args && ( +
+                {typeof tool.args === 'string' ? tool.args : JSON.stringify(tool.args, null, 1)}
+              
+ )} + {tool.result && ( +
+ → {tool.result.slice(0, 100)}{tool.result.length > 100 ? '...' : ''} +
+ )} +
+
+ )} +
+ ) } const eventTypeLabels: Record = { @@ -173,6 +234,15 @@ export const ActionNode = memo(function ActionNode({ > {formatTime(data.timestamp)} + {/* Tool icons */} + {data.tools && data.tools.length > 0 && ( +
+ {data.tools.map((tool) => ( + + ))} +
+ )} + {/* Metadata for complete nodes */} {hasMetadata && (
@@ -191,12 +261,6 @@ export const ActionNode = memo(function ActionNode({
)} - {data.toolName && ( -
- tool: {data.toolName} -
- )} - {/* Content - markdown for both preview and expanded */} {(expanded ? fullContent : truncatedContent) && (
)} - {expanded && data.toolArgs != null && ( -
-          {JSON.stringify(data.toolArgs, null, 2) as string}
-        
- )} - ) diff --git a/src/integrations/clawdbot/collections.ts b/src/integrations/clawdbot/collections.ts index a93a300..8550c90 100644 --- a/src/integrations/clawdbot/collections.ts +++ b/src/integrations/clawdbot/collections.ts @@ -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() @@ -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 + } + } + }) } } diff --git a/src/integrations/clawdbot/parser.ts b/src/integrations/clawdbot/parser.ts index 1231bf4..8ff8714 100644 --- a/src/integrations/clawdbot/parser.ts +++ b/src/integrations/clawdbot/parser.ts @@ -5,9 +5,16 @@ import type { MonitorSession, MonitorAction, SessionInfo, + ToolCall, } from './protocol' import { parseSessionKey } from './protocol' +export interface ParsedEvent { + session?: Partial + 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 - // 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; 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 } diff --git a/src/integrations/clawdbot/protocol.ts b/src/integrations/clawdbot/protocol.ts index 632cec1..c2583a6 100644 --- a/src/integrations/clawdbot/protocol.ts +++ b/src/integrations/clawdbot/protocol.ts @@ -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 diff --git a/src/integrations/trpc/router.ts b/src/integrations/trpc/router.ts index 7b6378a..7d60aed 100644 --- a/src/integrations/trpc/router.ts +++ b/src/integrations/trpc/router.ts @@ -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 }) + } } }) diff --git a/src/routes/monitor/index.tsx b/src/routes/monitor/index.tsx index ca46b8b..1a22126 100644 --- a/src/routes/monitor/index.tsx +++ b/src/routes/monitor/index.tsx @@ -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)