'use client'; import { Project } from '@/lib/api'; import { getToolIcon, getUserFriendlyToolName } from '@/components/thread/utils'; import React from 'react'; import { Slider } from '@/components/ui/slider'; import { Skeleton } from '@/components/ui/skeleton'; import { ApiMessageType } from '@/components/thread/types'; import { CircleDashed, X, ChevronLeft, ChevronRight, Computer, Radio, Maximize2, Minimize2, Copy, Check } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useIsMobile } from '@/hooks/use-mobile'; import { Button } from '@/components/ui/button'; import { ToolView } from './tool-views/wrapper'; import { motion, AnimatePresence } from 'framer-motion'; export interface ToolCallInput { assistantCall: { content?: string; name?: string; timestamp?: string; }; toolResult?: { content?: string; isSuccess?: boolean; timestamp?: string; }; messages?: ApiMessageType[]; } interface ToolCallSidePanelProps { isOpen: boolean; onClose: () => void; toolCalls: ToolCallInput[]; currentIndex: number; onNavigate: (newIndex: number) => void; externalNavigateToIndex?: number; messages?: ApiMessageType[]; agentStatus: string; project?: Project; renderAssistantMessage?: ( assistantContent?: string, toolContent?: string, ) => React.ReactNode; renderToolResult?: ( toolContent?: string, isSuccess?: boolean, ) => React.ReactNode; isLoading?: boolean; agentName?: string; onFileClick?: (filePath: string) => void; disableInitialAnimation?: boolean; } interface ToolCallSnapshot { id: string; toolCall: ToolCallInput; index: number; timestamp: number; } const FLOATING_LAYOUT_ID = 'tool-panel-float'; const CONTENT_LAYOUT_ID = 'tool-panel-content'; export function ToolCallSidePanel({ isOpen, onClose, toolCalls, currentIndex, onNavigate, messages, agentStatus, project, isLoading = false, externalNavigateToIndex, agentName, onFileClick, disableInitialAnimation, }: ToolCallSidePanelProps) { const [dots, setDots] = React.useState(''); const [internalIndex, setInternalIndex] = React.useState(0); const [navigationMode, setNavigationMode] = React.useState<'live' | 'manual'>('live'); const [toolCallSnapshots, setToolCallSnapshots] = React.useState([]); const [isInitialized, setIsInitialized] = React.useState(false); // Add copy functionality state const [isCopyingContent, setIsCopyingContent] = React.useState(false); const isMobile = useIsMobile(); const handleClose = React.useCallback(() => { onClose(); }, [onClose]); React.useEffect(() => { const newSnapshots = toolCalls.map((toolCall, index) => ({ id: `${index}-${toolCall.assistantCall.timestamp || Date.now()}`, toolCall, index, timestamp: Date.now(), })); const hadSnapshots = toolCallSnapshots.length > 0; const hasNewSnapshots = newSnapshots.length > toolCallSnapshots.length; setToolCallSnapshots(newSnapshots); if (!isInitialized && newSnapshots.length > 0) { const completedCount = newSnapshots.filter(s => s.toolCall.toolResult?.content && s.toolCall.toolResult.content !== 'STREAMING' ).length; if (completedCount > 0) { let lastCompletedIndex = -1; for (let i = newSnapshots.length - 1; i >= 0; i--) { const snapshot = newSnapshots[i]; if (snapshot.toolCall.toolResult?.content && snapshot.toolCall.toolResult.content !== 'STREAMING') { lastCompletedIndex = i; break; } } setInternalIndex(Math.max(0, lastCompletedIndex)); } else { setInternalIndex(Math.max(0, newSnapshots.length - 1)); } setIsInitialized(true); } else if (hasNewSnapshots && navigationMode === 'live') { const latestSnapshot = newSnapshots[newSnapshots.length - 1]; const isLatestStreaming = latestSnapshot?.toolCall.toolResult?.content === 'STREAMING'; if (isLatestStreaming) { let lastCompletedIndex = -1; for (let i = newSnapshots.length - 1; i >= 0; i--) { const snapshot = newSnapshots[i]; if (snapshot.toolCall.toolResult?.content && snapshot.toolCall.toolResult.content !== 'STREAMING') { lastCompletedIndex = i; break; } } if (lastCompletedIndex >= 0) { setInternalIndex(lastCompletedIndex); } else { setInternalIndex(newSnapshots.length - 1); } } else { setInternalIndex(newSnapshots.length - 1); } } else if (hasNewSnapshots && navigationMode === 'manual') { } }, [toolCalls, navigationMode, toolCallSnapshots.length, isInitialized]); React.useEffect(() => { if (isOpen && !isInitialized && toolCallSnapshots.length > 0) { setInternalIndex(Math.min(currentIndex, toolCallSnapshots.length - 1)); } }, [isOpen, currentIndex, isInitialized, toolCallSnapshots.length]); const safeInternalIndex = Math.min(internalIndex, Math.max(0, toolCallSnapshots.length - 1)); const currentSnapshot = toolCallSnapshots[safeInternalIndex]; const currentToolCall = currentSnapshot?.toolCall; const totalCalls = toolCallSnapshots.length; const completedToolCalls = toolCallSnapshots.filter(snapshot => snapshot.toolCall.toolResult?.content && snapshot.toolCall.toolResult.content !== 'STREAMING' ); const totalCompletedCalls = completedToolCalls.length; let displayToolCall = currentToolCall; let displayIndex = safeInternalIndex; let displayTotalCalls = totalCalls; const isCurrentToolStreaming = currentToolCall?.toolResult?.content === 'STREAMING'; if (isCurrentToolStreaming && totalCompletedCalls > 0) { const lastCompletedSnapshot = completedToolCalls[completedToolCalls.length - 1]; displayToolCall = lastCompletedSnapshot.toolCall; displayIndex = totalCompletedCalls - 1; displayTotalCalls = totalCompletedCalls; } else if (!isCurrentToolStreaming) { const completedIndex = completedToolCalls.findIndex(snapshot => snapshot.id === currentSnapshot?.id); if (completedIndex >= 0) { displayIndex = completedIndex; displayTotalCalls = totalCompletedCalls; } } const currentToolName = displayToolCall?.assistantCall?.name || 'Tool Call'; const CurrentToolIcon = getToolIcon( currentToolCall?.assistantCall?.name || 'unknown', ); const isStreaming = displayToolCall?.toolResult?.content === 'STREAMING'; // Extract actual success value from tool content with fallbacks const getActualSuccess = (toolCall: any): boolean => { const content = toolCall?.toolResult?.content; if (!content) return toolCall?.toolResult?.isSuccess ?? true; const safeParse = (data: any) => { try { return typeof data === 'string' ? JSON.parse(data) : data; } catch { return null; } }; const parsed = safeParse(content); if (!parsed) return toolCall?.toolResult?.isSuccess ?? true; if (parsed.content) { const inner = safeParse(parsed.content); if (inner?.tool_execution?.result?.success !== undefined) { return inner.tool_execution.result.success; } } const success = parsed.tool_execution?.result?.success ?? parsed.result?.success ?? parsed.success; return success !== undefined ? success : (toolCall?.toolResult?.isSuccess ?? true); }; const isSuccess = isStreaming ? true : getActualSuccess(displayToolCall); // Copy functions const copyToClipboard = React.useCallback(async (text: string) => { try { await navigator.clipboard.writeText(text); return true; } catch (err) { console.error('Failed to copy text: ', err); return false; } }, []); const handleCopyContent = React.useCallback(async () => { const toolContent = displayToolCall?.toolResult?.content; if (!toolContent || toolContent === 'STREAMING') return; // Try to extract file content from tool result let fileContent = ''; // If the tool result is JSON, try to extract file content try { const parsed = JSON.parse(toolContent); if (parsed.content && typeof parsed.content === 'string') { fileContent = parsed.content; } else if (parsed.file_content && typeof parsed.file_content === 'string') { fileContent = parsed.file_content; } else if (parsed.result && typeof parsed.result === 'string') { fileContent = parsed.result; } else if (parsed.toolOutput && typeof parsed.toolOutput === 'string') { fileContent = parsed.toolOutput; } else { // If no string content found, stringify the object fileContent = JSON.stringify(parsed, null, 2); } } catch (e) { // If it's not JSON, use the content as is fileContent = typeof toolContent === 'string' ? toolContent : JSON.stringify(toolContent, null, 2); } setIsCopyingContent(true); const success = await copyToClipboard(fileContent); if (success) { // Use toast if available, otherwise just log console.log('File content copied to clipboard'); } else { console.error('Failed to copy file content'); } setTimeout(() => setIsCopyingContent(false), 500); }, [displayToolCall?.toolResult?.content, copyToClipboard]); const internalNavigate = React.useCallback((newIndex: number, source: string = 'internal') => { if (newIndex < 0 || newIndex >= totalCalls) return; const isNavigatingToLatest = newIndex === totalCalls - 1; console.log(`[INTERNAL_NAV] ${source}: ${internalIndex} -> ${newIndex}, mode will be: ${isNavigatingToLatest ? 'live' : 'manual'}`); setInternalIndex(newIndex); if (isNavigatingToLatest) { setNavigationMode('live'); } else { setNavigationMode('manual'); } if (source === 'user_explicit') { onNavigate(newIndex); } }, [internalIndex, totalCalls, onNavigate]); const isLiveMode = navigationMode === 'live'; const showJumpToLive = navigationMode === 'manual' && agentStatus === 'running'; const showJumpToLatest = navigationMode === 'manual' && agentStatus !== 'running'; const navigateToPrevious = React.useCallback(() => { if (displayIndex > 0) { const targetCompletedIndex = displayIndex - 1; const targetSnapshot = completedToolCalls[targetCompletedIndex]; if (targetSnapshot) { const actualIndex = toolCallSnapshots.findIndex(s => s.id === targetSnapshot.id); if (actualIndex >= 0) { setNavigationMode('manual'); internalNavigate(actualIndex, 'user_explicit'); } } } }, [displayIndex, completedToolCalls, toolCallSnapshots, internalNavigate]); const navigateToNext = React.useCallback(() => { if (displayIndex < displayTotalCalls - 1) { const targetCompletedIndex = displayIndex + 1; const targetSnapshot = completedToolCalls[targetCompletedIndex]; if (targetSnapshot) { const actualIndex = toolCallSnapshots.findIndex(s => s.id === targetSnapshot.id); if (actualIndex >= 0) { const isLatestCompleted = targetCompletedIndex === completedToolCalls.length - 1; if (isLatestCompleted) { setNavigationMode('live'); } else { setNavigationMode('manual'); } internalNavigate(actualIndex, 'user_explicit'); } } } }, [displayIndex, displayTotalCalls, completedToolCalls, toolCallSnapshots, internalNavigate]); const jumpToLive = React.useCallback(() => { setNavigationMode('live'); internalNavigate(totalCalls - 1, 'user_explicit'); }, [totalCalls, internalNavigate]); const jumpToLatest = React.useCallback(() => { setNavigationMode('manual'); internalNavigate(totalCalls - 1, 'user_explicit'); }, [totalCalls, internalNavigate]); const renderStatusButton = React.useCallback(() => { const baseClasses = "flex items-center justify-center gap-1.5 px-2 py-0.5 rounded-full w-[116px]"; const dotClasses = "w-1.5 h-1.5 rounded-full"; const textClasses = "text-xs font-medium"; if (isLiveMode) { if (agentStatus === 'running') { return (
Live Updates
); } else { return (
Latest Tool
); } } else { if (agentStatus === 'running') { return (
Jump to Live
); } else { return (
Jump to Latest
); } } }, [isLiveMode, agentStatus, jumpToLive, jumpToLatest]); const handleSliderChange = React.useCallback(([newValue]: [number]) => { const targetSnapshot = completedToolCalls[newValue]; if (targetSnapshot) { const actualIndex = toolCallSnapshots.findIndex(s => s.id === targetSnapshot.id); if (actualIndex >= 0) { const isLatestCompleted = newValue === completedToolCalls.length - 1; if (isLatestCompleted) { setNavigationMode('live'); } else { setNavigationMode('manual'); } internalNavigate(actualIndex, 'user_explicit'); } } }, [completedToolCalls, toolCallSnapshots, internalNavigate]); React.useEffect(() => { if (!isOpen) return; const handleKeyDown = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key === 'i') { event.preventDefault(); handleClose(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [isOpen, handleClose]); React.useEffect(() => { if (!isOpen) return; const handleSidebarToggle = (event: CustomEvent) => { if (event.detail.expanded) { handleClose(); } }; window.addEventListener( 'sidebar-left-toggled', handleSidebarToggle as EventListener, ); return () => window.removeEventListener( 'sidebar-left-toggled', handleSidebarToggle as EventListener, ); }, [isOpen, handleClose]); React.useEffect(() => { if (externalNavigateToIndex !== undefined && externalNavigateToIndex >= 0 && externalNavigateToIndex < totalCalls) { internalNavigate(externalNavigateToIndex, 'external_click'); } }, [externalNavigateToIndex, totalCalls, internalNavigate]); React.useEffect(() => { if (!isStreaming) return; const interval = setInterval(() => { setDots((prev) => { if (prev === '...') return ''; return prev + '.'; }); }, 500); return () => clearInterval(interval); }, [isStreaming]); if (!isOpen) { return null; } if (isLoading) { return (

{agentName ? `${agentName}'s Computer` : 'Suna\'s Computer'}

); } const renderContent = () => { if (!displayToolCall && toolCallSnapshots.length === 0) { return (

{agentName ? `${agentName}'s Computer` : 'Suna\'s Computer'}

No tool activity

Tool calls and computer interactions will appear here when they're being executed.

); } if (!displayToolCall && toolCallSnapshots.length > 0) { const firstStreamingTool = toolCallSnapshots.find(s => s.toolCall.toolResult?.content === 'STREAMING'); if (firstStreamingTool && totalCompletedCalls === 0) { return (

{agentName ? `${agentName}'s Computer` : 'Suna\'s Computer'}

Running

Tool is running

{getUserFriendlyToolName(firstStreamingTool.toolCall.assistantCall.name || 'Tool')} is currently executing. Results will appear here when complete.

); } return (

{agentName ? `${agentName}'s Computer` : 'Suna\'s Computer'}

); } const toolView = ( ); return (

{agentName ? `${agentName}'s Computer` : 'Suna\'s Computer'}

{displayToolCall.toolResult?.content && !isStreaming && (
)} {isStreaming && (
Running
)} {!displayToolCall.toolResult?.content && !isStreaming && ( )}
{toolView}
); }; return ( {isOpen && (
{renderContent()}
{(displayTotalCalls > 1 || (isCurrentToolStreaming && totalCompletedCalls > 0)) && (
{isMobile ? (
{displayIndex + 1}/{displayTotalCalls} {renderStatusButton()}
) : (
{displayIndex + 1}/{displayTotalCalls}
{renderStatusButton()}
)}
)}
)}
); }