'use client'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; import { Button } from '@/components/ui/button'; import { ArrowDown, CheckCircle, CircleDashed, AlertTriangle, Info, File, ChevronRight } from 'lucide-react'; import { addUserMessage, getMessages, startAgent, stopAgent, getAgentRuns, getProject, getThread, updateProject, Project, Message as BaseApiMessageType } from '@/lib/api'; import { toast } from 'sonner'; import { Skeleton } from "@/components/ui/skeleton"; import { ChatInput } from '@/components/thread/chat-input'; import { FileViewerModal } from '@/components/thread/file-viewer-modal'; import { SiteHeader } from "@/components/thread/thread-site-header" import { ToolCallSidePanel, ToolCallInput } from "@/components/thread/tool-call-side-panel"; import { useSidebar } from "@/components/ui/sidebar"; import { useAgentStream } from '@/hooks/useAgentStream'; import { Markdown } from '@/components/ui/markdown'; import { cn } from "@/lib/utils"; import { useIsMobile } from "@/hooks/use-mobile"; import { BillingErrorAlert } from '@/components/billing/BillingErrorAlert'; import { SUBSCRIPTION_PLANS } from '@/components/billing/PlanComparison'; import { createClient } from '@/lib/supabase/client'; import { UnifiedMessage, ParsedContent, ParsedMetadata, ThreadParams } from '@/components/thread/types'; import { getToolIcon, extractPrimaryParam, safeJsonParse } from '@/components/thread/utils'; // Define the set of tags whose raw XML should be hidden during streaming const HIDE_STREAMING_XML_TAGS = new Set([ 'execute-command', 'create-file', 'delete-file', 'full-file-rewrite', 'str-replace', 'browser-click-element', 'browser-close-tab', 'browser-drag-drop', 'browser-get-dropdown-options', 'browser-go-back', 'browser-input-text', 'browser-navigate-to', 'browser-scroll-down', 'browser-scroll-to-text', 'browser-scroll-up', 'browser-select-dropdown-option', 'browser-send-keys', 'browser-switch-tab', 'browser-wait', 'deploy', 'ask', 'complete', 'crawl-webpage', 'web-search' ]); // Extend the base Message type with the expected database fields interface ApiMessageType extends BaseApiMessageType { message_id?: string; thread_id?: string; is_llm_message?: boolean; metadata?: string; created_at?: string; updated_at?: string; } // Add a simple interface for streaming tool calls interface StreamingToolCall { id?: string; name?: string; arguments?: string; index?: number; xml_tag_name?: string; } // Render Markdown content while preserving XML tags that should be displayed as tool calls function renderMarkdownContent(content: string, handleToolClick: (assistantMessageId: string | null, toolName: string) => void, messageId: string | null, fileViewerHandler?: (filePath?: string) => void) { const xmlRegex = /<(?!inform\b)([a-zA-Z\-_]+)(?:\s+[^>]*)?>(?:[\s\S]*?)<\/\1>|<(?!inform\b)([a-zA-Z\-_]+)(?:\s+[^>]*)?\/>/g; let lastIndex = 0; const contentParts: React.ReactNode[] = []; let match; // If no XML tags found, just return the full content as markdown if (!content.match(xmlRegex)) { return {content}; } while ((match = xmlRegex.exec(content)) !== null) { // Add text before the tag as markdown if (match.index > lastIndex) { const textBeforeTag = content.substring(lastIndex, match.index); contentParts.push( {textBeforeTag} ); } const rawXml = match[0]; const toolName = match[1] || match[2]; const IconComponent = getToolIcon(toolName); const paramDisplay = extractPrimaryParam(toolName, rawXml); const toolCallKey = `tool-${match.index}`; if (toolName === 'ask') { // Extract attachments from the XML attributes const attachmentsMatch = rawXml.match(/attachments=["']([^"']*)["']/i); const attachments = attachmentsMatch ? attachmentsMatch[1].split(',').map(a => a.trim()) : []; // Extract content from the ask tag const contentMatch = rawXml.match(/]*>([\s\S]*?)<\/ask>/i); const askContent = contentMatch ? contentMatch[1] : ''; // Render tag content with attachment UI (using the helper) contentParts.push(
{askContent} {renderAttachments(attachments, fileViewerHandler)}
); } else { // Render tool button as a clickable element contentParts.push( ); } lastIndex = xmlRegex.lastIndex; } // Add text after the last tag if (lastIndex < content.length) { contentParts.push( {content.substring(lastIndex)} ); } return contentParts; } // Helper function to render attachments (now deduplicated) function renderAttachments(attachments: string[], fileViewerHandler?: (filePath?: string) => void) { if (!attachments || attachments.length === 0) return null; // Deduplicate attachments using Set for simplicity const uniqueAttachments = [...new Set(attachments)]; return (
{uniqueAttachments.map((attachment, idx) => { const extension = attachment.split('.').pop()?.toLowerCase(); const filename = attachment.split('/').pop() || 'file'; // Define file size (placeholder logic) const fileSize = extension === 'html' ? '52.68 KB' : attachment.includes('itinerary') ? '4.14 KB' : attachment.includes('proposal') ? '6.20 KB' : attachment.includes('todo') ? '1.89 KB' : attachment.includes('research') ? '3.75 KB' : `${(attachment.length % 5 + 1).toFixed(2)} KB`; // Use length for pseudo-stable size // Get file type display const fileType = extension === 'html' ? 'Code' : 'Text'; return ( ); })}
); } export default function ThreadPage({ params }: { params: Promise }) { const unwrappedParams = React.use(params); const threadId = unwrappedParams.threadId; const isMobile = useIsMobile(); const router = useRouter(); const [messages, setMessages] = useState([]); const [newMessage, setNewMessage] = useState(''); const [isLoading, setIsLoading] = useState(true); const [isSending, setIsSending] = useState(false); const [error, setError] = useState(null); const [agentRunId, setAgentRunId] = useState(null); const [agentStatus, setAgentStatus] = useState<'idle' | 'running' | 'connecting' | 'error'>('idle'); const [isSidePanelOpen, setIsSidePanelOpen] = useState(false); const [toolCalls, setToolCalls] = useState([]); const [currentToolIndex, setCurrentToolIndex] = useState(0); const [autoOpenedPanel, setAutoOpenedPanel] = useState(false); const [initialPanelOpenAttempted, setInitialPanelOpenAttempted] = useState(false); // Billing alert state const [showBillingAlert, setShowBillingAlert] = useState(false); const [billingData, setBillingData] = useState<{ currentUsage?: number; limit?: number; message?: string; accountId?: string; }>({}); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const latestMessageRef = useRef(null); const [showScrollButton, setShowScrollButton] = useState(false); const [userHasScrolled, setUserHasScrolled] = useState(false); const hasInitiallyScrolled = useRef(false); const [project, setProject] = useState(null); const [sandboxId, setSandboxId] = useState(null); const [fileViewerOpen, setFileViewerOpen] = useState(false); const [projectName, setProjectName] = useState(''); const [fileToView, setFileToView] = useState(null); const initialLoadCompleted = useRef(false); const messagesLoadedRef = useRef(false); const agentRunsCheckedRef = useRef(false); const previousAgentStatus = useRef('idle'); const pollingIntervalRef = useRef(null); // POLLING FOR MESSAGES const handleProjectRenamed = useCallback((newName: string) => { setProjectName(newName); }, []); const { state: leftSidebarState, setOpen: setLeftSidebarOpen } = useSidebar(); const initialLayoutAppliedRef = useRef(false); const userClosedPanelRef = useRef(false); // Replace both useEffect hooks with a single one that respects user closing useEffect(() => { if (initialLoadCompleted.current && !initialPanelOpenAttempted) { // Only attempt to open panel once on initial load setInitialPanelOpenAttempted(true); // Open the panel with tool calls if available if (toolCalls.length > 0) { setIsSidePanelOpen(true); setCurrentToolIndex(toolCalls.length - 1); } else { // Only if there are messages but no tool calls yet if (messages.length > 0) { setIsSidePanelOpen(true); } } } }, [initialPanelOpenAttempted, messages, toolCalls]); const toggleSidePanel = useCallback(() => { setIsSidePanelOpen(prevIsOpen => { const newState = !prevIsOpen; if (!newState) { userClosedPanelRef.current = true; } if (newState) { // Close left sidebar when opening side panel setLeftSidebarOpen(false); } return newState; }); }, [setLeftSidebarOpen]); const handleSidePanelNavigate = useCallback((newIndex: number) => { setCurrentToolIndex(newIndex); }, []); useEffect(() => { if (!initialLayoutAppliedRef.current) { setLeftSidebarOpen(false); initialLayoutAppliedRef.current = true; } }, [setLeftSidebarOpen]); // Update keyboard shortcut handlers to manage both panels useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { // CMD+I for ToolCall SidePanel if ((event.metaKey || event.ctrlKey) && event.key === 'i') { event.preventDefault(); // If side panel is already open, just close it if (isSidePanelOpen) { setIsSidePanelOpen(false); userClosedPanelRef.current = true; } else { // Open side panel and ensure left sidebar is closed setIsSidePanelOpen(true); setLeftSidebarOpen(false); } } // CMD+B for Left Sidebar if ((event.metaKey || event.ctrlKey) && event.key === 'b') { event.preventDefault(); // If left sidebar is expanded, collapse it if (leftSidebarState === 'expanded') { setLeftSidebarOpen(false); } else { // Otherwise expand the left sidebar and close the side panel setLeftSidebarOpen(true); if (isSidePanelOpen) { setIsSidePanelOpen(false); userClosedPanelRef.current = true; } } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [toggleSidePanel, isSidePanelOpen, leftSidebarState, setLeftSidebarOpen]); const handleNewMessageFromStream = useCallback((message: UnifiedMessage) => { // Log the ID of the message received from the stream console.log(`[STREAM HANDLER] Received message: ID=${message.message_id}, Type=${message.type}`); if (!message.message_id) { console.warn(`[STREAM HANDLER] Received message is missing ID: Type=${message.type}, Content=${message.content?.substring(0, 50)}...`); } setMessages(prev => { const messageExists = prev.some(m => m.message_id === message.message_id); if (messageExists) { return prev.map(m => m.message_id === message.message_id ? message : m); } else { return [...prev, message]; } }); // If we received a tool message, refresh the tool panel if (message.type === 'tool') { setAutoOpenedPanel(false); } }, []); const handleStreamStatusChange = useCallback((hookStatus: string) => { console.log(`[PAGE] Hook status changed: ${hookStatus}`); switch(hookStatus) { case 'idle': case 'completed': case 'stopped': case 'agent_not_running': case 'error': case 'failed': setAgentStatus('idle'); setAgentRunId(null); // Reset auto-opened state when agent completes to trigger tool detection setAutoOpenedPanel(false); // After terminal states, we should scroll to bottom to show latest messages // The hook will already have refetched messages by this point if (['completed', 'stopped', 'agent_not_running', 'error', 'failed'].includes(hookStatus)) { scrollToBottom('smooth'); } break; case 'connecting': setAgentStatus('connecting'); break; case 'streaming': setAgentStatus('running'); break; } }, []); const handleStreamError = useCallback((errorMessage: string) => { console.error(`[PAGE] Stream hook error: ${errorMessage}`); if (!errorMessage.toLowerCase().includes('not found') && !errorMessage.toLowerCase().includes('agent run is not running')) { toast.error(`Stream Error: ${errorMessage}`); } }, []); const handleStreamClose = useCallback(() => { console.log(`[PAGE] Stream hook closed with final status: ${agentStatus}`); }, [agentStatus]); const { status: streamHookStatus, textContent: streamingTextContent, toolCall: streamingToolCall, error: streamError, agentRunId: currentHookRunId, startStreaming, stopStreaming, } = useAgentStream({ onMessage: handleNewMessageFromStream, onStatusChange: handleStreamStatusChange, onError: handleStreamError, onClose: handleStreamClose, }, threadId, setMessages); useEffect(() => { if (agentRunId && agentRunId !== currentHookRunId) { console.log(`[PAGE] Target agentRunId set to ${agentRunId}, initiating stream...`); startStreaming(agentRunId); } }, [agentRunId, startStreaming, currentHookRunId]); useEffect(() => { let isMounted = true; async function loadData() { if (!initialLoadCompleted.current) setIsLoading(true); setError(null); try { if (!threadId) throw new Error('Thread ID is required'); const threadData = await getThread(threadId).catch(err => { throw new Error('Failed to load thread data: ' + err.message); }); if (!isMounted) return; console.log('[PAGE] Thread data loaded:', threadData); if (threadData?.project_id) { console.log('[PAGE] Getting project data for project_id:', threadData.project_id); const projectData = await getProject(threadData.project_id); if (isMounted && projectData) { console.log('[PAGE] Project data loaded:', projectData); console.log('[PAGE] Project ID:', projectData.id); console.log('[PAGE] Project sandbox data:', projectData.sandbox); // Set project data setProject(projectData); // Make sure sandbox ID is set correctly if (typeof projectData.sandbox === 'string') { setSandboxId(projectData.sandbox); } else if (projectData.sandbox?.id) { setSandboxId(projectData.sandbox.id); } setProjectName(projectData.name || ''); } } if (!messagesLoadedRef.current) { const messagesData = await getMessages(threadId); if (isMounted) { // Log raw messages fetched from API console.log('[PAGE] Raw messages fetched:', messagesData); // Map API message type to UnifiedMessage type const unifiedMessages = (messagesData || []) .filter(msg => msg.type !== 'status') .map((msg: ApiMessageType, index: number) => { console.log(`[MAP ${index}] Processing raw message:`, msg); const messageId = msg.message_id; console.log(`[MAP ${index}] Accessed msg.message_id:`, messageId); if (!messageId && msg.type !== 'status') { console.warn(`[MAP ${index}] Non-status message fetched from API is missing ID: Type=${msg.type}`); } const threadIdMapped = msg.thread_id || threadId; console.log(`[MAP ${index}] Accessed msg.thread_id (using fallback):`, threadIdMapped); const typeMapped = (msg.type || 'system') as UnifiedMessage['type']; console.log(`[MAP ${index}] Accessed msg.type (using fallback):`, typeMapped); const isLlmMessageMapped = Boolean(msg.is_llm_message); console.log(`[MAP ${index}] Accessed msg.is_llm_message:`, isLlmMessageMapped); const contentMapped = msg.content || ''; console.log(`[MAP ${index}] Accessed msg.content (using fallback):`, contentMapped.substring(0, 50) + '...'); const metadataMapped = msg.metadata || '{}'; console.log(`[MAP ${index}] Accessed msg.metadata (using fallback):`, metadataMapped); const createdAtMapped = msg.created_at || new Date().toISOString(); console.log(`[MAP ${index}] Accessed msg.created_at (using fallback):`, createdAtMapped); const updatedAtMapped = msg.updated_at || new Date().toISOString(); console.log(`[MAP ${index}] Accessed msg.updated_at (using fallback):`, updatedAtMapped); return { message_id: messageId || null, thread_id: threadIdMapped, type: typeMapped, is_llm_message: isLlmMessageMapped, content: contentMapped, metadata: metadataMapped, created_at: createdAtMapped, updated_at: updatedAtMapped }; }); setMessages(unifiedMessages); // Set the filtered and mapped messages console.log('[PAGE] Loaded Messages (excluding status, keeping browser_state):', unifiedMessages.length) // Debug loaded messages const assistantMessages = unifiedMessages.filter(m => m.type === 'assistant'); const toolMessages = unifiedMessages.filter(m => m.type === 'tool'); console.log('[PAGE] Assistant messages:', assistantMessages.length); console.log('[PAGE] Tool messages:', toolMessages.length); // Check if tool messages have associated assistant messages toolMessages.forEach(toolMsg => { try { const metadata = JSON.parse(toolMsg.metadata); if (metadata.assistant_message_id) { const hasAssociated = assistantMessages.some( assMsg => assMsg.message_id === metadata.assistant_message_id ); console.log(`[PAGE] Tool message ${toolMsg.message_id} references assistant ${metadata.assistant_message_id} - found: ${hasAssociated}`); } } catch (e) { console.error("Error parsing tool message metadata:", e); } }); messagesLoadedRef.current = true; if (!hasInitiallyScrolled.current) { scrollToBottom('auto'); hasInitiallyScrolled.current = true; } } } if (!agentRunsCheckedRef.current && isMounted) { try { console.log('[PAGE] Checking for active agent runs...'); const agentRuns = await getAgentRuns(threadId); agentRunsCheckedRef.current = true; const activeRun = agentRuns.find(run => run.status === 'running'); if (activeRun && isMounted) { console.log('[PAGE] Found active run on load:', activeRun.id); setAgentRunId(activeRun.id); } else { console.log('[PAGE] No active agent runs found'); if (isMounted) setAgentStatus('idle'); } } catch (err) { console.error('[PAGE] Error checking for active runs:', err); agentRunsCheckedRef.current = true; if (isMounted) setAgentStatus('idle'); } } initialLoadCompleted.current = true; } catch (err) { console.error('Error loading thread data:', err); if (isMounted) { const errorMessage = err instanceof Error ? err.message : 'Failed to load thread'; setError(errorMessage); toast.error(errorMessage); } } finally { if (isMounted) setIsLoading(false); } } loadData(); return () => { isMounted = false; }; }, [threadId]); const handleSubmitMessage = useCallback(async (message: string, options?: { model_name?: string; enable_thinking?: boolean }) => { if (!message.trim()) return; setIsSending(true); const optimisticUserMessage: UnifiedMessage = { message_id: `temp-${Date.now()}`, thread_id: threadId, type: 'user', is_llm_message: false, content: message, metadata: '{}', created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }; setMessages(prev => [...prev, optimisticUserMessage]); setNewMessage(''); scrollToBottom('smooth'); try { const results = await Promise.allSettled([ addUserMessage(threadId, message), startAgent(threadId, options) ]); if (results[0].status === 'rejected') { console.error("Failed to send message:", results[0].reason); throw new Error(`Failed to send message: ${results[0].reason?.message || results[0].reason}`); } if (results[1].status === 'rejected') { console.error("Failed to start agent:", results[1].reason); throw new Error(`Failed to start agent: ${results[1].reason?.message || results[1].reason}`); } const agentResult = results[1].value; setAgentRunId(agentResult.agent_run_id); } catch (err) { console.error('Error sending message or starting agent:', err); toast.error(err instanceof Error ? err.message : 'Operation failed'); setMessages(prev => prev.filter(m => m.message_id !== optimisticUserMessage.message_id)); } finally { setIsSending(false); } }, [threadId]); const handleStopAgent = useCallback(async () => { console.log(`[PAGE] Requesting agent stop via hook.`); setAgentStatus('idle'); // First stop the streaming and let the hook handle refetching await stopStreaming(); // We don't need to refetch messages here since the hook will do that // The centralizing of refetching in the hook simplifies this logic }, [stopStreaming]); 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 }); }; useEffect(() => { const lastMsg = messages[messages.length - 1]; const isNewUserMessage = lastMsg?.type === 'user'; if ((isNewUserMessage || agentStatus === 'running') && !userHasScrolled) { scrollToBottom('smooth'); } }, [messages, agentStatus, userHasScrolled, scrollToBottom]); useEffect(() => { if (!latestMessageRef.current || messages.length === 0) return; const observer = new IntersectionObserver( ([entry]) => setShowScrollButton(!entry?.isIntersecting), { root: messagesContainerRef.current, threshold: 0.1 } ); observer.observe(latestMessageRef.current); return () => observer.disconnect(); }, [messages, streamingTextContent, streamingToolCall, setShowScrollButton]); const handleScrollButtonClick = () => { scrollToBottom('smooth'); setUserHasScrolled(false); }; useEffect(() => { console.log(`[PAGE] ๐Ÿ”„ Page AgentStatus: ${agentStatus}, Hook Status: ${streamHookStatus}, Target RunID: ${agentRunId || 'none'}, Hook RunID: ${currentHookRunId || 'none'}`); // If the stream hook reports completion/stopping but our UI hasn't updated if ((streamHookStatus === 'completed' || streamHookStatus === 'stopped' || streamHookStatus === 'agent_not_running' || streamHookStatus === 'error') && (agentStatus === 'running' || agentStatus === 'connecting')) { console.log('[PAGE] Detected hook completed but UI still shows running, updating status'); setAgentStatus('idle'); setAgentRunId(null); setAutoOpenedPanel(false); } }, [agentStatus, streamHookStatus, agentRunId, currentHookRunId]); const handleOpenFileViewer = useCallback((filePath?: string) => { if (filePath) { setFileToView(filePath); } else { setFileToView(null); } setFileViewerOpen(true); }, []); // Automatically detect and populate tool calls from messages useEffect(() => { // Calculate historical tool pairs regardless of panel state const historicalToolPairs: ToolCallInput[] = []; const assistantMessages = messages.filter(m => m.type === 'assistant' && m.message_id); 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 {} // Skip adding tags to the tool calls if (toolName === 'ask' || toolName === 'complete') { return; } 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 } }); } }); // Always update the toolCalls state setToolCalls(historicalToolPairs); // Logic to open/update the panel index if (historicalToolPairs.length > 0) { // If the panel is open (or was just auto-opened) and the user didn't close it if (isSidePanelOpen && !userClosedPanelRef.current) { // Always jump to the latest tool call index setCurrentToolIndex(historicalToolPairs.length - 1); } else if (!isSidePanelOpen && !autoOpenedPanel && !userClosedPanelRef.current) { // Auto-open the panel only the first time tools are detected setCurrentToolIndex(historicalToolPairs.length - 1); setIsSidePanelOpen(true); setAutoOpenedPanel(true); } } }, [messages, isSidePanelOpen, autoOpenedPanel]); // Rerun when messages or panel state changes // Reset auto-opened state when panel is closed useEffect(() => { if (!isSidePanelOpen) { setAutoOpenedPanel(false); } }, [isSidePanelOpen]); // Process the assistant call data const toolViewAssistant = useCallback((assistantContent?: string, toolContent?: string) => { // This needs to stay simple as it's meant for the side panel tool call view if (!assistantContent) return null; return (
Assistant Message
{assistantContent}
); }, []); // Process the tool result data const toolViewResult = useCallback((toolContent?: string, isSuccess?: boolean) => { if (!toolContent) return null; return (
Tool Result
{isSuccess ? 'Success' : 'Failed'}
{toolContent}
); }, []); // Update handleToolClick to respect user closing preference and navigate correctly const handleToolClick = useCallback((clickedAssistantMessageId: string | null, clickedToolName: string) => { // Explicitly ignore ask tags from opening the side panel if (clickedToolName === 'ask') { return; } if (!clickedAssistantMessageId) { console.warn("Clicked assistant message ID is null. Cannot open side panel."); toast.warning("Cannot view details: Assistant message ID is missing."); return; } // Reset user closed state when explicitly clicking a tool userClosedPanelRef.current = false; console.log("[PAGE] Tool Click Triggered. Assistant Message ID:", clickedAssistantMessageId, "Tool Name:", clickedToolName); // Find the index of the tool call associated with the clicked assistant message const toolIndex = toolCalls.findIndex(tc => { // Check if the assistant message ID matches the one stored in the tool result's metadata if (!tc.toolResult?.content || tc.toolResult.content === "STREAMING") return false; // Skip streaming or incomplete calls // Directly compare assistant message IDs if available in the structure // Find the original assistant message based on the ID const assistantMessage = messages.find(m => m.message_id === clickedAssistantMessageId && m.type === 'assistant'); if (!assistantMessage) return false; // Find the corresponding tool message using metadata const toolMessage = messages.find(m => { if (m.type !== 'tool' || !m.metadata) return false; try { const metadata = safeJsonParse(m.metadata, {}); return metadata.assistant_message_id === assistantMessage.message_id; } catch { return false; } }); // Check if the current toolCall 'tc' corresponds to this assistant/tool message pair return tc.assistantCall?.content === assistantMessage.content && tc.toolResult?.content === toolMessage?.content; }); if (toolIndex !== -1) { console.log(`[PAGE] Found tool call at index ${toolIndex} for assistant message ${clickedAssistantMessageId}`); setCurrentToolIndex(toolIndex); setIsSidePanelOpen(true); // Explicitly open the panel } else { console.warn(`[PAGE] Could not find matching tool call in toolCalls array for assistant message ID: ${clickedAssistantMessageId}`); toast.info("Could not find details for this tool call."); // Optionally, still open the panel but maybe at the last index or show a message? // setIsSidePanelOpen(true); } }, [messages, toolCalls]); // Add toolCalls as a dependency // Handle streaming tool calls const handleStreamingToolCall = useCallback((toolCall: StreamingToolCall | null) => { if (!toolCall) return; const toolName = toolCall.name || toolCall.xml_tag_name || 'Unknown Tool'; // Skip tags from showing in the side panel during streaming if (toolName === 'ask' || toolName === 'complete') { return; } console.log("[STREAM] Received tool call:", toolName); // If user explicitly closed the panel, don't reopen it for streaming calls if (userClosedPanelRef.current) return; // Create a properly formatted tool call input for the streaming tool // that matches the format of historical tool calls 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: 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() } }; // 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); }, []); // SEO title update useEffect(() => { if (projectName) { // Update document title when project name changes document.title = `${projectName} | Kortix Suna`; // Update meta tags for SEO const metaDescription = document.querySelector('meta[name="description"]'); if (metaDescription) { metaDescription.setAttribute('content', `${projectName} - Interactive agent conversation powered by Kortix Suna`); } // Update OpenGraph tags if they exist const ogTitle = document.querySelector('meta[property="og:title"]'); if (ogTitle) { ogTitle.setAttribute('content', `${projectName} | Kortix Suna`); } const ogDescription = document.querySelector('meta[property="og:description"]'); if (ogDescription) { ogDescription.setAttribute('content', `Interactive AI conversation for ${projectName}`); } } }, [projectName]); // POLLING FOR MESSAGES // Set up polling for messages useEffect(() => { // Function to fetch messages const fetchMessages = async () => { if (!threadId) return; try { console.log('[POLLING] Refetching messages...'); const messagesData = await getMessages(threadId); if (messagesData) { console.log(`[POLLING] Refetch completed with ${messagesData.length} messages`); // Map API message type to UnifiedMessage type const unifiedMessages = (messagesData || []) .filter(msg => msg.type !== 'status') .map((msg: ApiMessageType) => ({ message_id: msg.message_id || null, thread_id: msg.thread_id || threadId, type: (msg.type || 'system') as UnifiedMessage['type'], is_llm_message: Boolean(msg.is_llm_message), content: msg.content || '', metadata: msg.metadata || '{}', created_at: msg.created_at || new Date().toISOString(), updated_at: msg.updated_at || new Date().toISOString() })); setMessages(unifiedMessages); // Only auto-scroll if not manually scrolled up if (!userHasScrolled) { scrollToBottom('smooth'); } } } catch (error) { console.error('[POLLING] Error fetching messages:', error); } }; // Start polling once initial load is complete if (initialLoadCompleted.current && !pollingIntervalRef.current) { // Initial fetch fetchMessages(); // Set up interval (every 2 seconds) pollingIntervalRef.current = setInterval(fetchMessages, 2000); } // Clean up interval when component unmounts return () => { if (pollingIntervalRef.current) { clearInterval(pollingIntervalRef.current); pollingIntervalRef.current = null; } }; }, [threadId, userHasScrolled, initialLoadCompleted]); // POLLING FOR MESSAGES // Add another useEffect to ensure messages are refreshed when agent status changes to idle useEffect(() => { if (agentStatus === 'idle' && streamHookStatus !== 'streaming' && streamHookStatus !== 'connecting') { console.log('[PAGE] Agent status changed to idle, ensuring messages are up to date'); // Only do this if we're not in the initial loading state if (!isLoading && initialLoadCompleted.current) { // Double-check messages after a short delay to ensure we have latest content const timer = setTimeout(() => { getMessages(threadId).then(messagesData => { if (messagesData) { console.log(`[PAGE] Backup refetch completed with ${messagesData.length} messages`); // Map API message type to UnifiedMessage type const unifiedMessages = (messagesData || []) .filter(msg => msg.type !== 'status') .map((msg: ApiMessageType) => ({ message_id: msg.message_id || null, thread_id: msg.thread_id || threadId, type: (msg.type || 'system') as UnifiedMessage['type'], is_llm_message: Boolean(msg.is_llm_message), content: msg.content || '', metadata: msg.metadata || '{}', created_at: msg.created_at || new Date().toISOString(), updated_at: msg.updated_at || new Date().toISOString() })); setMessages(unifiedMessages); // Reset auto-opened panel to allow tool detection with fresh messages setAutoOpenedPanel(false); scrollToBottom('smooth'); } }).catch(err => { console.error('Error in backup message refetch:', err); }); }, 1000); return () => clearTimeout(timer); } } }, [agentStatus, threadId, isLoading, streamHookStatus]); // Check billing status when agent completes const checkBillingStatus = useCallback(async () => { if (!project?.account_id) return; const supabase = createClient(); try { // Check subscription status const { data: subscriptionData } = await supabase .schema('basejump') .from('billing_subscriptions') .select('price_id') .eq('account_id', project.account_id) .eq('status', 'active') .single(); const currentPlanId = subscriptionData?.price_id || SUBSCRIPTION_PLANS.FREE; // Only check usage limits for free tier users if (currentPlanId === SUBSCRIPTION_PLANS.FREE) { // Calculate usage const startOfMonth = new Date(); startOfMonth.setDate(1); startOfMonth.setHours(0, 0, 0, 0); // Get threads for this account const { data: threadsData } = await supabase .from('threads') .select('thread_id') .eq('account_id', project.account_id); const threadIds = threadsData?.map(t => t.thread_id) || []; // Get agent runs for those threads const { data: agentRunData } = await supabase .from('agent_runs') .select('started_at, completed_at') .in('thread_id', threadIds) .gte('started_at', startOfMonth.toISOString()); let totalSeconds = 0; if (agentRunData) { totalSeconds = agentRunData.reduce((acc, run) => { const start = new Date(run.started_at); const end = run.completed_at ? new Date(run.completed_at) : new Date(); const seconds = (end.getTime() - start.getTime()) / 1000; return acc + seconds; }, 0); } // Convert to hours for display const hours = totalSeconds / 3600; const minutesUsed = totalSeconds / 60; // The free plan has a 10 minute limit as defined in backend/utils/billing.py const FREE_PLAN_LIMIT_MINUTES = 10; const FREE_PLAN_LIMIT_HOURS = FREE_PLAN_LIMIT_MINUTES / 60; // Show alert if over limit if (minutesUsed > FREE_PLAN_LIMIT_MINUTES) { console.log("Usage limit exceeded:", { minutesUsed, hoursUsed: hours, limit: FREE_PLAN_LIMIT_MINUTES }); setBillingData({ currentUsage: Number(hours.toFixed(2)), limit: FREE_PLAN_LIMIT_HOURS, message: `You've used ${Math.floor(minutesUsed)} minutes on the Free plan. The limit is ${FREE_PLAN_LIMIT_MINUTES} minutes per month.`, accountId: project.account_id }); setShowBillingAlert(true); return true; // Return true if over limit } } return false; // Return false if not over limit } catch (err) { console.error('Error checking billing status:', err); return false; } }, [project?.account_id]); // Update useEffect to check billing when agent completes useEffect(() => { const previousStatus = previousAgentStatus.current; // Check if agent just completed (status changed from running to idle) if (previousStatus === 'running' && agentStatus === 'idle') { checkBillingStatus(); } // Store current status for next comparison previousAgentStatus.current = agentStatus; }, [agentStatus, checkBillingStatus]); // Add new useEffect to check billing limits when page first loads or project changes useEffect(() => { if (project?.account_id && initialLoadCompleted.current) { console.log("Checking billing status on page load"); checkBillingStatus(); } }, [project?.account_id, checkBillingStatus, initialLoadCompleted]); // Also check after messages are loaded to ensure we have the complete state useEffect(() => { if (messagesLoadedRef.current && project?.account_id && !isLoading) { console.log("Checking billing status after messages loaded"); checkBillingStatus(); } }, [messagesLoadedRef.current, checkBillingStatus, project?.account_id, isLoading]); if (isLoading && !initialLoadCompleted.current) { return (
{/* Skeleton Header */}
{/* Skeleton Chat Messages */}
{/* User message */}
{/* Assistant response with tool usage */}
{/* Tool call button skeleton */}
{/* User message */}
{/* Assistant thinking state */}
{/* Skeleton Chat Input */}
{/* Skeleton Side Panel (closed state) */}
); } if (error) { return (

Error

{error}

setIsSidePanelOpen(false)} toolCalls={[]} currentIndex={0} onNavigate={handleSidePanelNavigate} project={project || undefined} agentStatus="error" />
); } return (
{messages.length === 0 && !streamingTextContent && !streamingToolCall && agentStatus === 'idle' ? (
Send a message to start.
) : (
{(() => { // Group messages logic type MessageGroup = { type: 'user' | 'assistant_group'; messages: UnifiedMessage[]; key: string; }; const groupedMessages: MessageGroup[] = []; let currentGroup: MessageGroup | null = null; messages.forEach((message, index) => { const messageType = message.type; const key = message.message_id || `msg-${index}`; if (messageType === 'user') { if (currentGroup) { groupedMessages.push(currentGroup); } groupedMessages.push({ type: 'user', messages: [message], key }); currentGroup = null; } else if (messageType === 'assistant' || messageType === 'tool' || messageType === 'browser_state') { if (currentGroup && currentGroup.type === 'assistant_group') { currentGroup.messages.push(message); } else { if (currentGroup) { groupedMessages.push(currentGroup); } currentGroup = { type: 'assistant_group', messages: [message], key }; } } else if (messageType !== 'status') { if (currentGroup) { groupedMessages.push(currentGroup); currentGroup = null; } } }); if (currentGroup) { groupedMessages.push(currentGroup); } return groupedMessages.map((group, groupIndex) => { if (group.type === 'user') { const message = group.messages[0]; const messageContent = (() => { try { const parsed = safeJsonParse(message.content, { content: message.content }); return parsed.content || message.content; } catch { return message.content; } })(); // Extract attachments from the message content const attachmentsMatch = messageContent.match(/\[Uploaded File: (.*?)\]/g); const attachments = attachmentsMatch ? attachmentsMatch.map(match => { const pathMatch = match.match(/\[Uploaded File: (.*?)\]/); return pathMatch ? pathMatch[1] : null; }).filter(Boolean) : []; // Remove attachment info from the message content const cleanContent = messageContent.replace(/\[Uploaded File: .*?\]/g, '').trim(); return (
{cleanContent && ( {cleanContent} )} {/* Use the helper function to render user attachments */} {renderAttachments(attachments as string[], handleOpenFileViewer)}
); } else if (group.type === 'assistant_group') { return (
Kortix
{(() => { const toolResultsMap = new Map(); group.messages.forEach(msg => { if (msg.type === 'tool') { const meta = safeJsonParse(msg.metadata, {}); const assistantId = meta.assistant_message_id || null; if (!toolResultsMap.has(assistantId)) { toolResultsMap.set(assistantId, []); } toolResultsMap.get(assistantId)?.push(msg); } }); const renderedToolResultIds = new Set(); const elements: React.ReactNode[] = []; group.messages.forEach((message, msgIndex) => { if (message.type === 'assistant') { const parsedContent = safeJsonParse(message.content, {}); const msgKey = message.message_id || `submsg-assistant-${msgIndex}`; if (!parsedContent.content) return; const renderedContent = renderMarkdownContent( parsedContent.content, handleToolClick, message.message_id, handleOpenFileViewer ); elements.push(
0 ? "mt-2" : ""}>
{renderedContent}
); } }); return elements; })()} {groupIndex === groupedMessages.length - 1 && (streamHookStatus === 'streaming' || streamHookStatus === 'connecting') && (
{(() => { let detectedTag: string | null = null; let tagStartIndex = -1; if (streamingTextContent) { for (const tag of HIDE_STREAMING_XML_TAGS) { const openingTagPattern = `<${tag}`; const index = streamingTextContent.indexOf(openingTagPattern); if (index !== -1) { detectedTag = tag; tagStartIndex = index; break; } } } const textToRender = streamingTextContent || ''; const textBeforeTag = detectedTag ? textToRender.substring(0, tagStartIndex) : textToRender; const showCursor = (streamHookStatus === 'streaming' || streamHookStatus === 'connecting') && !detectedTag; return ( <> {textBeforeTag && ( {textBeforeTag} )} {showCursor && ( )} {detectedTag && (
)} {streamingToolCall && !detectedTag && (
{(() => { const toolName = streamingToolCall.name || streamingToolCall.xml_tag_name || 'Tool'; const IconComponent = getToolIcon(toolName); const paramDisplay = extractPrimaryParam(toolName, streamingToolCall.arguments || ''); return ( ); })()}
)} ); })()}
)}
); } return null; }); })()} {(agentStatus === 'running' || agentStatus === 'connecting') && (messages.length === 0 || messages[messages.length - 1].type === 'user') && (
Suna
)}
)}
{ setIsSidePanelOpen(false); userClosedPanelRef.current = true; setAutoOpenedPanel(true); }} toolCalls={toolCalls} messages={messages as ApiMessageType[]} agentStatus={agentStatus} currentIndex={currentToolIndex} onNavigate={handleSidePanelNavigate} project={project || undefined} renderAssistantMessage={toolViewAssistant} renderToolResult={toolViewResult} /> {sandboxId && ( )} {/* Billing Alert for usage limit */} setShowBillingAlert(false)} isOpen={showBillingAlert} />
); }