From f6cd8779eda33c6d813fa9bd18c00c7054a05191 Mon Sep 17 00:00:00 2001 From: Saumya Date: Fri, 4 Jul 2025 00:02:56 +0530 Subject: [PATCH] feat: tool-panel animation --- .../[projectId]/thread/[threadId]/page.tsx | 7 + .../thread/_components/ThreadLayout.tsx | 1 - frontend/src/app/share/[threadId]/page.tsx | 9 - .../thread/chat-input/chat-input.tsx | 19 +- .../chat-input/floating-tool-preview.tsx | 176 +++++++++++++ .../thread/tool-call-side-panel.tsx | 240 ++++-------------- .../src/components/thread/tool-views/utils.ts | 26 +- 7 files changed, 268 insertions(+), 210 deletions(-) create mode 100644 frontend/src/components/thread/chat-input/floating-tool-preview.tsx diff --git a/frontend/src/app/(dashboard)/projects/[projectId]/thread/[threadId]/page.tsx b/frontend/src/app/(dashboard)/projects/[projectId]/thread/[threadId]/page.tsx index 28cc43dd..c8d4de47 100644 --- a/frontend/src/app/(dashboard)/projects/[projectId]/thread/[threadId]/page.tsx +++ b/frontend/src/app/(dashboard)/projects/[projectId]/thread/[threadId]/page.tsx @@ -664,6 +664,13 @@ export default function ThreadPage({ agentName={agent && agent.name} selectedAgentId={selectedAgentId} onAgentSelect={setSelectedAgentId} + toolCalls={toolCalls} + toolCallIndex={currentToolIndex} + showToolPreview={!isSidePanelOpen && toolCalls.length > 0} + onExpandToolPreview={() => { + setIsSidePanelOpen(true); + userClosedPanelRef.current = false; + }} /> diff --git a/frontend/src/app/(dashboard)/projects/[projectId]/thread/_components/ThreadLayout.tsx b/frontend/src/app/(dashboard)/projects/[projectId]/thread/_components/ThreadLayout.tsx index d9093996..5d42893f 100644 --- a/frontend/src/app/(dashboard)/projects/[projectId]/thread/_components/ThreadLayout.tsx +++ b/frontend/src/app/(dashboard)/projects/[projectId]/thread/_components/ThreadLayout.tsx @@ -117,7 +117,6 @@ export function ThreadLayout({ isLoading={!initialLoadCompleted || isLoading} onFileClick={onViewFiles} agentName={agentName} - onToggleFloatingPreview={() => onToggleSidePanel()} /> {sandboxId && ( diff --git a/frontend/src/app/share/[threadId]/page.tsx b/frontend/src/app/share/[threadId]/page.tsx index a7782a08..b19f7a96 100644 --- a/frontend/src/app/share/[threadId]/page.tsx +++ b/frontend/src/app/share/[threadId]/page.tsx @@ -513,14 +513,6 @@ export default function ThreadPage({ }; }, [threadId]); - const handleScroll = () => { - if (!messagesContainerRef.current) return; - const { scrollTop, scrollHeight, clientHeight } = - messagesContainerRef.current; - const isScrolledUp = scrollHeight - scrollTop - clientHeight > 100; - setShowScrollButton(isScrolledUp); - setUserHasScrolled(isScrolledUp); - }; const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => { messagesEndRef.current?.scrollIntoView({ behavior }); @@ -806,7 +798,6 @@ export default function ThreadPage({ externalNavigateToIndex={externalNavIndex} project={project} onFileClick={handleOpenFileViewer} - onToggleFloatingPreview={() => setIsSidePanelOpen(true)} /> File[]; @@ -44,6 +45,10 @@ export interface ChatInputProps { agentName?: string; messages?: any[]; bgColor?: string; + toolCalls?: ToolCallInput[]; + toolCallIndex?: number; + showToolPreview?: boolean; + onExpandToolPreview?: () => void; } export interface UploadedFile { @@ -74,6 +79,10 @@ export const ChatInput = forwardRef( agentName, messages = [], bgColor = 'bg-sidebar', + toolCalls = [], + toolCallIndex = 0, + showToolPreview = false, + onExpandToolPreview, }, ref, ) => { @@ -226,15 +235,21 @@ export const ChatInput = forwardRef( return (
+ {})} + agentName={agentName} + isVisible={showToolPreview} + /> { e.preventDefault(); e.stopPropagation(); setIsDraggingOver(false); - if (fileInputRef.current && e.dataTransfer.files.length > 0) { const files = Array.from(e.dataTransfer.files); handleFiles( diff --git a/frontend/src/components/thread/chat-input/floating-tool-preview.tsx b/frontend/src/components/thread/chat-input/floating-tool-preview.tsx new file mode 100644 index 00000000..70308a53 --- /dev/null +++ b/frontend/src/components/thread/chat-input/floating-tool-preview.tsx @@ -0,0 +1,176 @@ +import React from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { CircleDashed, Maximize2 } from 'lucide-react'; +import { getToolIcon, getUserFriendlyToolName } from '@/components/thread/utils'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; + +export interface ToolCallInput { + assistantCall: { + content?: string; + name?: string; + timestamp?: string; + }; + toolResult?: { + content?: string; + isSuccess?: boolean; + timestamp?: string; + }; + messages?: any[]; +} + +interface FloatingToolPreviewProps { + toolCalls: ToolCallInput[]; + currentIndex: number; + onExpand: () => void; + agentName?: string; + isVisible: boolean; +} + +const FLOATING_LAYOUT_ID = 'tool-panel-float'; +const CONTENT_LAYOUT_ID = 'tool-panel-content'; + +const getToolResultStatus = (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); +}; + +export const FloatingToolPreview: React.FC = ({ + toolCalls, + currentIndex, + onExpand, + agentName, + isVisible, +}) => { + const [isExpanding, setIsExpanding] = React.useState(false); + const currentToolCall = toolCalls[currentIndex]; + const totalCalls = toolCalls.length; + + React.useEffect(() => { + if (isVisible) { + setIsExpanding(false); + } + }, [isVisible]); + + if (!currentToolCall || totalCalls === 0) return null; + + const toolName = currentToolCall.assistantCall?.name || 'Tool Call'; + const CurrentToolIcon = getToolIcon(toolName); + const isStreaming = currentToolCall.toolResult?.content === 'STREAMING'; + const isSuccess = isStreaming ? true : getToolResultStatus(currentToolCall); + + const handleClick = () => { + setIsExpanding(true); + requestAnimationFrame(() => { + onExpand(); + }); + }; + + return ( + + {isVisible && ( + + +
+
+ + {isStreaming ? ( + + ) : ( + + )} + +
+ +
+ +

+ {getUserFriendlyToolName(toolName)} +

+
+ + {currentIndex + 1}/{totalCalls} + +
+
+ + +
+ + {isStreaming + ? `${agentName || 'Suna'} is working...` + : isSuccess + ? "Success" + : "Failed" + } + + +
+ + +
+ + + )} + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/thread/tool-call-side-panel.tsx b/frontend/src/components/thread/tool-call-side-panel.tsx index 07b51809..acfd9e8b 100644 --- a/frontend/src/components/thread/tool-call-side-panel.tsx +++ b/frontend/src/components/thread/tool-call-side-panel.tsx @@ -48,16 +48,6 @@ interface ToolCallSidePanelProps { isLoading?: boolean; agentName?: string; onFileClick?: (filePath: string) => void; - showFloatingPreview?: boolean; - onToggleFloatingPreview?: () => void; -} - -interface FloatingPreviewProps { - toolCalls: ToolCallInput[]; - currentIndex: number; - onExpand: () => void; - agentName?: string; - isVisible: boolean; } interface ToolCallSnapshot { @@ -70,110 +60,6 @@ interface ToolCallSnapshot { const FLOATING_LAYOUT_ID = 'tool-panel-float'; const CONTENT_LAYOUT_ID = 'tool-panel-content'; -const FloatingPreview: React.FC = ({ - toolCalls, - currentIndex, - onExpand, - agentName, - isVisible, -}) => { - const currentToolCall = toolCalls[currentIndex]; - const totalCalls = toolCalls.length; - - if (!currentToolCall || totalCalls === 0) return null; - - const toolName = currentToolCall.assistantCall?.name || 'Tool Call'; - const CurrentToolIcon = getToolIcon(toolName); - const isStreaming = currentToolCall.toolResult?.content === 'STREAMING'; - const isSuccess = currentToolCall.toolResult?.isSuccess ?? true; - - return ( - - {isVisible && ( - - -
-
- - {isStreaming ? ( - - ) : ( - - )} - -
- -
- -

- {getUserFriendlyToolName(toolName)} -

-
- - {currentIndex + 1}/{totalCalls} - -
-
- - -
- - {isStreaming - ? `${agentName || 'Suna'} is working...` - : isSuccess - ? "Completed successfully" - : "Failed to execute" - } - - -
- -
- -
-
- - - )} - - ); -}; - export function ToolCallSidePanel({ isOpen, onClose, @@ -187,25 +73,17 @@ export function ToolCallSidePanel({ externalNavigateToIndex, agentName, onFileClick, - showFloatingPreview = true, - onToggleFloatingPreview, }: 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); - const [showFloatingPreviewState, setShowFloatingPreviewState] = React.useState(false); - const [isExiting, setIsExiting] = React.useState(false); const isMobile = useIsMobile(); const handleClose = React.useCallback(() => { - setIsExiting(true); - setTimeout(() => { - onClose(); - setIsExiting(false); - }, 400); + onClose(); }, [onClose]); React.useEffect(() => { @@ -523,20 +401,6 @@ export function ToolCallSidePanel({ } }, [externalNavigateToIndex, totalCalls, internalNavigate]); - // Show floating preview when side panel is closed and there are tool calls - React.useEffect(() => { - const hasToolCalls = toolCallSnapshots.length > 0; - const shouldShowFloating = !isOpen && hasToolCalls && showFloatingPreview && !isExiting; - setShowFloatingPreviewState(shouldShowFloating); - }, [isOpen, toolCallSnapshots.length, showFloatingPreview, isExiting]); - - const handleExpandFromFloating = React.useCallback(() => { - // This will be handled by the parent component through onClose callback - if (onToggleFloatingPreview) { - onToggleFloatingPreview(); - } - }, [onToggleFloatingPreview]); - React.useEffect(() => { if (!isStreaming) return; const interval = setInterval(() => { @@ -549,57 +413,51 @@ export function ToolCallSidePanel({ return () => clearInterval(interval); }, [isStreaming]); - // Render floating preview when side panel is closed - if (!isOpen && !isExiting) { - return ( - - ); + if (!isOpen) { + return null; } if (isLoading) { return ( -
-
-
-
-
-
- -

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

+
+
+
+
+
+
+
+
+ +

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

+
+ +
+
+
+
+ + + + +
- -
-
-
-
- - - -
@@ -829,20 +687,24 @@ export function ToolCallSidePanel({ animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ - opacity: { duration: 0.2 } + opacity: { duration: 0.15 }, + layout: { + type: "spring", + stiffness: 400, + damping: 35 + } }} className={cn( - 'fixed inset-y-0 right-0 border-l flex flex-col z-30 h-screen', + 'fixed top-2 right-2 bottom-4 border rounded-2xl flex flex-col z-30', isMobile - ? 'w-full' - : 'w-[40vw] sm:w-[450px] md:w-[500px] lg:w-[550px] xl:w-[650px]', + ? 'left-2' + : 'w-[40vw] sm:w-[450px] md:w-[500px] lg:w-[550px] xl:w-[645px]', )} style={{ overflow: 'hidden', - boxShadow: '0 8px 32px rgba(0,0,0,0.18)' }} > -
+
{renderContent()}
{(displayTotalCalls > 1 || (isCurrentToolStreaming && totalCompletedCalls > 0)) && ( diff --git a/frontend/src/components/thread/tool-views/utils.ts b/frontend/src/components/thread/tool-views/utils.ts index 8986c71f..72647419 100644 --- a/frontend/src/components/thread/tool-views/utils.ts +++ b/frontend/src/components/thread/tool-views/utils.ts @@ -507,18 +507,26 @@ export function extractFileContent( return null; } -// Helper to process and clean file content function processFileContent(content: string): string { if (!content) return content; - - // Handle escaped characters + const trimmedContent = content.trim(); + const isLikelyJson = (trimmedContent.startsWith('{') && trimmedContent.endsWith('}')) || + (trimmedContent.startsWith('[') && trimmedContent.endsWith(']')); + + if (isLikelyJson) { + try { + const parsed = JSON.parse(content); + return JSON.stringify(parsed, null, 2); + } catch (e) { + } + } return content - .replace(/\\n/g, '\n') // Replace \n with actual newlines - .replace(/\\t/g, '\t') // Replace \t with actual tabs - .replace(/\\r/g, '') // Remove \r - .replace(/\\\\/g, '\\') // Replace \\ with \ - .replace(/\\"/g, '"') // Replace \" with " - .replace(/\\'/g, "'"); // Replace \' with ' + .replace(/\\n/g, '\n') + .replace(/\\t/g, '\t') + .replace(/\\r/g, '') + .replace(/\\\\/g, '\\') + .replace(/\\"/g, '"') + .replace(/\\'/g, "'"); } // Helper to determine file type (for syntax highlighting)