This commit is contained in:
Adam Cohen Hillel 2025-04-19 21:02:44 +01:00
parent 918f82fff9
commit 542c18cd2f
3 changed files with 255 additions and 30 deletions

View File

@ -185,6 +185,7 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
const [isSidePanelOpen, setIsSidePanelOpen] = useState(false);
const [toolCalls, setToolCalls] = useState<ToolCallInput[]>([]);
const [currentToolIndex, setCurrentToolIndex] = useState<number>(0);
const [autoOpenedPanel, setAutoOpenedPanel] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
@ -263,6 +264,11 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
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<ThreadParams> }
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<ThreadParams> }
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<ThreadParams> }
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<ThreadParams> }
// 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<ThreadParams> }
}
setIsSidePanelOpen(true);
setAutoOpenedPanel(true);
}, [messages]);
@ -689,17 +783,54 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
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('<execute-command>')) {
formattedContent = `<execute-command>${toolArguments}</execute-command>`;
} else if (toolName.toLowerCase().includes('file') && !toolArguments.includes('<create-file>')) {
// 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}</${matchingTag}>`;
}
}
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);
}, []);

View File

@ -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 (
<div className="fixed inset-y-0 right-0 w-[90%] sm:w-[450px] md:w-[500px] lg:w-[550px] xl:w-[600px] bg-background border-l flex flex-col z-10">
<div className="p-4 flex items-center justify-between">
<h3 className="text-sm font-semibold">Suna&apos;s Computer</h3>
<h3 className="text-sm font-semibold">
{isStreaming
? `Suna's Computer (Running${dots})`
: "Suna's Computer"}
</h3>
<Button variant="ghost" size="icon" onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="h-4 w-4" />
</Button>
@ -204,7 +232,7 @@ export function ToolCallSidePanel({
<div className="flex items-center gap-2 min-w-0">
<CurrentToolIcon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="text-xs font-medium text-foreground truncate" title={currentToolName}>
{currentToolName}
{currentToolName} {isStreaming && `(Running${dots})`}
</span>
</div>
<span className="text-xs text-muted-foreground flex-shrink-0">

View File

@ -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 (
<div className="space-y-4 p-4">
@ -25,39 +44,86 @@ export function GenericToolView({
</div>
</div>
{toolContent && (
{toolContent && !isStreaming && (
<div className={`px-2 py-1 rounded-full text-xs ${
isSuccess ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
isSuccess ? 'bg-green-50 text-green-700 dark:bg-green-900 dark:text-green-300'
: 'bg-red-50 text-red-700 dark:bg-red-900 dark:text-red-300'
}`}>
{isSuccess ? 'Success' : 'Failed'}
</div>
)}
{isStreaming && (
<div className="px-2 py-1 rounded-full text-xs bg-blue-50 text-blue-700 dark:bg-blue-900 dark:text-blue-300 flex items-center gap-1">
<CircleDashed className="h-3 w-3 animate-spin" />
<span>Running</span>
</div>
)}
</div>
{/* Assistant Message */}
<div className="space-y-1">
<div className="flex justify-between items-center">
<div className="text-xs font-medium text-muted-foreground">Assistant Message</div>
{assistantTimestamp && (
<div className="text-xs text-muted-foreground">{formatTimestamp(assistantTimestamp)}</div>
)}
{/* Tool Parameters */}
{parsedContent && (
<div className="space-y-1">
<div className="flex justify-between items-center">
<div className="text-xs font-medium text-muted-foreground">Tool Parameters</div>
{assistantTimestamp && (
<div className="text-xs text-muted-foreground">{formatTimestamp(assistantTimestamp)}</div>
)}
</div>
<div className="rounded-md border bg-muted/50 p-3">
{parsedContent.content.startsWith('{') ? (
// If content looks like JSON, render it prettified
<pre className="text-xs overflow-auto whitespace-pre-wrap break-words">
{JSON.stringify(JSON.parse(parsedContent.content), null, 2)}
</pre>
) : (
// Otherwise render as Markdown
<Markdown className="text-xs prose prose-xs dark:prose-invert max-w-none">
{parsedContent.content}
</Markdown>
)}
</div>
</div>
<div className="rounded-md border bg-muted/50 p-3">
<pre className="text-xs overflow-auto whitespace-pre-wrap break-words">{assistantContent}</pre>
)}
{/* Show original assistant content if couldn't parse properly or for debugging */}
{assistantContent && !parsedContent && !isStreaming && (
<div className="space-y-1">
<div className="flex justify-between items-center">
<div className="text-xs font-medium text-muted-foreground">Assistant Message</div>
{assistantTimestamp && (
<div className="text-xs text-muted-foreground">{formatTimestamp(assistantTimestamp)}</div>
)}
</div>
<div className="rounded-md border bg-muted/50 p-3">
<pre className="text-xs overflow-auto whitespace-pre-wrap break-words">{assistantContent}</pre>
</div>
</div>
</div>
)}
{/* Tool Result */}
{toolContent && (
<div className="space-y-1">
<div className="flex justify-between items-center">
<div className="text-xs font-medium text-muted-foreground">Tool Result</div>
{toolTimestamp && (
<div className="text-xs font-medium text-muted-foreground">
{isStreaming ? "Tool Execution" : "Tool Result"}
</div>
{toolTimestamp && !isStreaming && (
<div className="text-xs text-muted-foreground">{formatTimestamp(toolTimestamp)}</div>
)}
</div>
<div className={`rounded-md border p-3 ${isSuccess ? 'bg-muted/50' : 'bg-red-50'}`}>
<pre className="text-xs overflow-auto whitespace-pre-wrap break-words">{toolContent}</pre>
<div className={`rounded-md border p-3 ${
isStreaming ? 'bg-blue-50/30 dark:bg-blue-900/20' :
(isSuccess ? 'bg-muted/50' : 'bg-red-50/30 dark:bg-red-900/20')
}`}>
{isStreaming ? (
<div className="flex items-center gap-2 text-xs text-blue-700 dark:text-blue-300">
<CircleDashed className="h-3 w-3 animate-spin" />
<span>Executing {toolName.toLowerCase()}...</span>
</div>
) : (
<pre className="text-xs overflow-auto whitespace-pre-wrap break-words">{toolContent}</pre>
)}
</div>
</div>
)}