diff --git a/frontend/src/app/dashboard/agents/[threadId]/page.tsx b/frontend/src/app/dashboard/agents/[threadId]/page.tsx index 187dd726..03dd300c 100644 --- a/frontend/src/app/dashboard/agents/[threadId]/page.tsx +++ b/frontend/src/app/dashboard/agents/[threadId]/page.tsx @@ -185,6 +185,7 @@ export default function ThreadPage({ params }: { params: Promise } const [isSidePanelOpen, setIsSidePanelOpen] = useState(false); const [toolCalls, setToolCalls] = useState([]); const [currentToolIndex, setCurrentToolIndex] = useState(0); + const [autoOpenedPanel, setAutoOpenedPanel] = useState(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); @@ -263,6 +264,11 @@ export default function ThreadPage({ params }: { params: Promise } return [...prev, message]; } }); + + // If we received a tool message, refresh the tool panel + if (message.type === 'tool') { + setAutoOpenedPanel(false); + } }, []); const handleStreamStatusChange = useCallback((hookStatus: string) => { @@ -274,6 +280,8 @@ export default function ThreadPage({ params }: { params: Promise } case 'agent_not_running': setAgentStatus('idle'); setAgentRunId(null); + // Reset auto-opened state when agent completes to trigger tool detection + setAutoOpenedPanel(false); break; case 'connecting': setAgentStatus('connecting'); @@ -539,6 +547,10 @@ export default function ThreadPage({ params }: { params: Promise } console.log('[PAGE] Refetched messages after stop:', unifiedMessages.length); setMessages(unifiedMessages); + + // Clear auto-opened state to trigger tool detection with fresh messages + setAutoOpenedPanel(false); + scrollToBottom('smooth'); } } catch (err) { @@ -594,6 +606,82 @@ export default function ThreadPage({ params }: { params: Promise } setFileViewerOpen(true); }, []); + // Automatically detect and populate tool calls from messages + useEffect(() => { + // Skip if we've already auto-opened the panel or if it's already open + if (autoOpenedPanel || isSidePanelOpen) return; + + const historicalToolPairs: ToolCallInput[] = []; + const assistantMessages = messages.filter(m => m.type === 'assistant' && m.message_id); + + if (assistantMessages.length === 0) return; + + assistantMessages.forEach(assistantMsg => { + const resultMessage = messages.find(toolMsg => { + if (toolMsg.type !== 'tool' || !toolMsg.metadata || !assistantMsg.message_id) return false; + try { + const metadata = JSON.parse(toolMsg.metadata); + return metadata.assistant_message_id === assistantMsg.message_id; + } catch (e) { + return false; + } + }); + + if (resultMessage) { + // Determine tool name from assistant message content + let toolName = 'unknown'; + try { + // Try to extract tool name from content + const xmlMatch = assistantMsg.content.match(/<([a-zA-Z\-_]+)(?:\s+[^>]*)?>|<([a-zA-Z\-_]+)(?:\s+[^>]*)?\/>/); + if (xmlMatch) { + toolName = xmlMatch[1] || xmlMatch[2] || 'unknown'; + } else { + // Fallback to checking for tool_calls JSON structure + const assistantContentParsed = safeJsonParse<{ tool_calls?: { name: string }[] }>(assistantMsg.content, {}); + if (assistantContentParsed.tool_calls && assistantContentParsed.tool_calls.length > 0) { + toolName = assistantContentParsed.tool_calls[0].name || 'unknown'; + } + } + } catch {} + + let isSuccess = true; + try { + const toolContent = resultMessage.content?.toLowerCase() || ''; + isSuccess = !(toolContent.includes('failed') || + toolContent.includes('error') || + toolContent.includes('failure')); + } catch {} + + historicalToolPairs.push({ + assistantCall: { + name: toolName, + content: assistantMsg.content, + timestamp: assistantMsg.created_at + }, + toolResult: { + content: resultMessage.content, + isSuccess: isSuccess, + timestamp: resultMessage.created_at + } + }); + } + }); + + if (historicalToolPairs.length > 0) { + setToolCalls(historicalToolPairs); + setCurrentToolIndex(historicalToolPairs.length - 1); // Set to the most recent tool call + setIsSidePanelOpen(true); + setAutoOpenedPanel(true); + } + }, [messages, autoOpenedPanel, isSidePanelOpen]); + + // Reset auto-opened state when panel is closed + useEffect(() => { + if (!isSidePanelOpen) { + setAutoOpenedPanel(false); + } + }, [isSidePanelOpen]); + const handleToolClick = useCallback((clickedAssistantMessageId: string | null, clickedToolName: string) => { if (!clickedAssistantMessageId) { console.warn("Clicked assistant message ID is null. Cannot open side panel."); @@ -626,12 +714,17 @@ export default function ThreadPage({ params }: { params: Promise } // otherwise fallback to a generic name or the one passed from the click. let toolNameForResult = clickedToolName; // Fallback try { - const assistantContentParsed = safeJsonParse<{ tool_calls?: { name: string }[] }>(assistantMsg.content, {}); - // A simple heuristic: if the assistant message content has tool_calls structure - if (assistantContentParsed.tool_calls && assistantContentParsed.tool_calls.length > 0) { - toolNameForResult = assistantContentParsed.tool_calls[0].name || clickedToolName; + // Try to extract tool name from content + const xmlMatch = assistantMsg.content.match(/<([a-zA-Z\-_]+)(?:\s+[^>]*)?>|<([a-zA-Z\-_]+)(?:\s+[^>]*)?\/>/); + if (xmlMatch) { + toolNameForResult = xmlMatch[1] || xmlMatch[2] || clickedToolName; + } else { + // Fallback to checking for tool_calls JSON structure + const assistantContentParsed = safeJsonParse<{ tool_calls?: { name: string }[] }>(assistantMsg.content, {}); + if (assistantContentParsed.tool_calls && assistantContentParsed.tool_calls.length > 0) { + toolNameForResult = assistantContentParsed.tool_calls[0].name || clickedToolName; + } } - // More advanced: parse the XML in assistant message content to find the tool name associated with this result } catch {} let isSuccess = true; @@ -681,6 +774,7 @@ export default function ThreadPage({ params }: { params: Promise } } setIsSidePanelOpen(true); + setAutoOpenedPanel(true); }, [messages]); @@ -689,17 +783,54 @@ export default function ThreadPage({ params }: { params: Promise } if (!toolCall) return; console.log("[STREAM] Received tool call:", toolCall.name || toolCall.xml_tag_name); - // Now with Markdown support, we can enable the side panel for streaming calls + // Create a properly formatted tool call input for the streaming tool + // that matches the format of historical tool calls + const toolName = toolCall.name || toolCall.xml_tag_name || 'Unknown Tool'; + const toolArguments = toolCall.arguments || ''; + + // Format the arguments in a way that matches the expected XML format for each tool + // This ensures the specialized tool views render correctly + let formattedContent = toolArguments; + if (toolName.toLowerCase().includes('command') && !toolArguments.includes('')) { + formattedContent = `${toolArguments}`; + } else if (toolName.toLowerCase().includes('file') && !toolArguments.includes('')) { + // For file operations, wrap with appropriate tag if not already wrapped + const fileOpTags = ['create-file', 'delete-file', 'full-file-rewrite']; + const matchingTag = fileOpTags.find(tag => toolName.toLowerCase().includes(tag)); + if (matchingTag && !toolArguments.includes(`<${matchingTag}>`)) { + formattedContent = `<${matchingTag}>${toolArguments}`; + } + } + const newToolCall: ToolCallInput = { assistantCall: { - name: toolCall.name || toolCall.xml_tag_name || 'Unknown Tool', - content: toolCall.arguments || '', + name: toolName, + content: formattedContent, + timestamp: new Date().toISOString() + }, + // For streaming tool calls, provide empty content that indicates streaming + toolResult: { + content: "STREAMING", + isSuccess: true, timestamp: new Date().toISOString() } - // No toolResult available yet for streaming calls }; - setToolCalls([newToolCall]); + // Update the tool calls state to reflect the streaming tool + setToolCalls(prev => { + // If the same tool is already being streamed, update it instead of adding a new one + if (prev.length > 0 && prev[0].assistantCall.name === toolName) { + return [{ + ...prev[0], + assistantCall: { + ...prev[0].assistantCall, + content: formattedContent + } + }]; + } + return [newToolCall]; + }); + setCurrentToolIndex(0); setIsSidePanelOpen(true); }, []); diff --git a/frontend/src/components/thread/tool-call-side-panel.tsx b/frontend/src/components/thread/tool-call-side-panel.tsx index 574f85a6..f54d2da5 100644 --- a/frontend/src/components/thread/tool-call-side-panel.tsx +++ b/frontend/src/components/thread/tool-call-side-panel.tsx @@ -149,6 +149,8 @@ interface ToolCallSidePanelProps { currentIndex: number; onNavigate: (newIndex: number) => void; project?: Project; + renderAssistantMessage?: (assistantContent?: string, toolContent?: string) => React.ReactNode; + renderToolResult?: (toolContent?: string, isSuccess?: boolean) => React.ReactNode; } export function ToolCallSidePanel({ @@ -157,7 +159,9 @@ export function ToolCallSidePanel({ toolCalls, currentIndex, onNavigate, - project + project, + renderAssistantMessage, + renderToolResult }: ToolCallSidePanelProps) { if (!isOpen) return null; @@ -166,6 +170,26 @@ export function ToolCallSidePanel({ const currentToolName = currentToolCall?.assistantCall?.name || 'Tool Call'; const CurrentToolIcon = getToolIcon(currentToolName === 'Tool Call' ? 'unknown' : currentToolName); + // Determine if this is a streaming tool call + const isStreaming = currentToolCall?.toolResult?.content === "STREAMING"; + + // Set up a pulse animation for streaming + const [dots, setDots] = React.useState(''); + + React.useEffect(() => { + if (!isStreaming) return; + + // Create a loading animation with dots + const interval = setInterval(() => { + setDots(prev => { + if (prev === '...') return ''; + return prev + '.'; + }); + }, 500); + + return () => clearInterval(interval); + }, [isStreaming]); + const renderContent = () => { if (!currentToolCall) { return ( @@ -182,7 +206,7 @@ export function ToolCallSidePanel({ currentToolCall.toolResult?.content, currentToolCall.assistantCall.timestamp, currentToolCall.toolResult?.timestamp, - currentToolCall.toolResult?.isSuccess ?? true, + isStreaming ? true : (currentToolCall.toolResult?.isSuccess ?? true), project ); }; @@ -190,7 +214,11 @@ export function ToolCallSidePanel({ return (
-

Suna's Computer

+

+ {isStreaming + ? `Suna's Computer (Running${dots})` + : "Suna's Computer"} +

@@ -204,7 +232,7 @@ export function ToolCallSidePanel({
- {currentToolName} + {currentToolName} {isStreaming && `(Running${dots})`}
diff --git a/frontend/src/components/thread/tool-views/GenericToolView.tsx b/frontend/src/components/thread/tool-views/GenericToolView.tsx index 5d4dab6e..d2738bd5 100644 --- a/frontend/src/components/thread/tool-views/GenericToolView.tsx +++ b/frontend/src/components/thread/tool-views/GenericToolView.tsx @@ -2,6 +2,8 @@ import React from "react"; import { ToolViewProps } from "./types"; import { formatTimestamp } from "./utils"; import { getToolIcon } from "../utils"; +import { CircleDashed } from "lucide-react"; +import { Markdown } from "@/components/home/ui/markdown"; export function GenericToolView({ name, @@ -12,6 +14,23 @@ export function GenericToolView({ toolTimestamp }: ToolViewProps & { name?: string }) { const toolName = name || 'Unknown Tool'; + const isStreaming = toolContent === "STREAMING"; + + // Parse the assistant content to extract tool parameters + const parsedContent = React.useMemo(() => { + if (!assistantContent) return null; + + // Try to extract content from XML tags + const xmlMatch = assistantContent.match(/<([a-zA-Z\-_]+)(?:\s+[^>]*)?>([^<]*)<\/\1>/); + if (xmlMatch) { + return { + tag: xmlMatch[1], + content: xmlMatch[2].trim() + }; + } + + return null; + }, [assistantContent]); return (
@@ -25,39 +44,86 @@ export function GenericToolView({
- {toolContent && ( + {toolContent && !isStreaming && (
{isSuccess ? 'Success' : 'Failed'}
)} + + {isStreaming && ( +
+ + Running +
+ )}
- {/* Assistant Message */} -
-
-
Assistant Message
- {assistantTimestamp && ( -
{formatTimestamp(assistantTimestamp)}
- )} + {/* Tool Parameters */} + {parsedContent && ( +
+
+
Tool Parameters
+ {assistantTimestamp && ( +
{formatTimestamp(assistantTimestamp)}
+ )} +
+
+ {parsedContent.content.startsWith('{') ? ( + // If content looks like JSON, render it prettified +
+                {JSON.stringify(JSON.parse(parsedContent.content), null, 2)}
+              
+ ) : ( + // Otherwise render as Markdown + + {parsedContent.content} + + )} +
-
-
{assistantContent}
+ )} + + {/* Show original assistant content if couldn't parse properly or for debugging */} + {assistantContent && !parsedContent && !isStreaming && ( +
+
+
Assistant Message
+ {assistantTimestamp && ( +
{formatTimestamp(assistantTimestamp)}
+ )} +
+
+
{assistantContent}
+
-
+ )} {/* Tool Result */} {toolContent && (
-
Tool Result
- {toolTimestamp && ( +
+ {isStreaming ? "Tool Execution" : "Tool Result"} +
+ {toolTimestamp && !isStreaming && (
{formatTimestamp(toolTimestamp)}
)}
-
-
{toolContent}
+
+ {isStreaming ? ( +
+ + Executing {toolName.toLowerCase()}... +
+ ) : ( +
{toolContent}
+ )}
)}