'use client'; import React, { useCallback, useEffect, useRef, useState, } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { AlertTriangle, } from 'lucide-react'; import { BillingError, Project, Message as BaseApiMessageType, } from '@/lib/api'; import { toast } from 'sonner'; import { ChatInput } from '@/components/thread/chat-input/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 { cn } from '@/lib/utils'; import { useIsMobile } from '@/hooks/use-mobile'; import { BillingErrorAlert } from '@/components/billing/usage-limit-alert'; import { isLocalMode } from '@/lib/config'; import { ThreadContent } from '@/components/thread/content/ThreadContent'; import { ThreadSkeleton } from '@/components/thread/content/ThreadSkeleton'; import { UnifiedMessage, ParsedMetadata, ThreadParams, } from '@/components/thread/types'; import { safeJsonParse, } from '@/components/thread/utils'; import { useThreadQuery } from '@/hooks/react-query/threads/use-threads'; import { useAddUserMessageMutation, useMessagesQuery } from '@/hooks/react-query/threads/use-messages'; import { useProjectQuery } from '@/hooks/react-query/threads/use-project'; import { useAgentRunsQuery, useStartAgentMutation, useStopAgentMutation } from '@/hooks/react-query/threads/use-agent-run'; import { useBillingStatusQuery } from '@/hooks/react-query/threads/use-billing-status'; // 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; } export default function ThreadPage({ params, }: { params: Promise; }) { const unwrappedParams = React.use(params); const threadId = unwrappedParams.threadId; const isMobile = useIsMobile(); const searchParams = useSearchParams(); 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 | null; }>({}); 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'); // Add debug mode state - check for debug=true in URL const [debugMode, setDebugMode] = useState(false); const threadQuery = useThreadQuery(threadId); const messagesQuery = useMessagesQuery(threadId); const projectId = threadQuery.data?.project_id || ''; const projectQuery = useProjectQuery(projectId); const agentRunsQuery = useAgentRunsQuery(threadId); const billingStatusQuery = useBillingStatusQuery(); const addUserMessageMutation = useAddUserMessageMutation(); const startAgentMutation = useStartAgentMutation(); const stopAgentMutation = useStopAgentMutation(); 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; } } } if (event.key === 'Escape' && isSidePanelOpen) { setIsSidePanelOpen(false); } }; 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]); const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape' && isSidePanelOpen) { setIsSidePanelOpen(false); } }; useEffect(() => { window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [isSidePanelOpen]); const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => { messagesEndRef.current?.scrollIntoView({ behavior }); }; // Effect to load initial data using React Query useEffect(() => { let isMounted = true; async function initializeData() { if (!initialLoadCompleted.current) setIsLoading(true); setError(null); try { if (!threadId) throw new Error('Thread ID is required'); // Check if we have thread data if (threadQuery.isError) { throw new Error('Failed to load thread data: ' + threadQuery.error); } if (!isMounted) return; // Process project data when available if (projectQuery.data) { // Set project data setProject(projectQuery.data); // Make sure sandbox ID is set correctly if (typeof projectQuery.data.sandbox === 'string') { setSandboxId(projectQuery.data.sandbox); } else if (projectQuery.data.sandbox?.id) { setSandboxId(projectQuery.data.sandbox.id); } setProjectName(projectQuery.data.name || ''); } // Process messages data when available if (messagesQuery.data && !messagesLoadedRef.current) { // Map API message type to UnifiedMessage type const unifiedMessages = (messagesQuery.data || []) .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); console.log('[PAGE] Loaded Messages (excluding status, keeping browser_state):', unifiedMessages.length); messagesLoadedRef.current = true; if (!hasInitiallyScrolled.current) { scrollToBottom('auto'); hasInitiallyScrolled.current = true; } } // Check for active agent runs if (agentRunsQuery.data && !agentRunsCheckedRef.current && isMounted) { console.log('[PAGE] Checking for active agent runs...'); agentRunsCheckedRef.current = true; const activeRun = agentRunsQuery.data.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'); } } // Mark initialization as complete when we have the core data if (threadQuery.data && messagesQuery.data && agentRunsQuery.data) { initialLoadCompleted.current = true; setIsLoading(false); } } 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); setIsLoading(false); } } } if (threadId) { initializeData(); } return () => { isMounted = false; }; }, [ threadId, threadQuery.data, threadQuery.isError, threadQuery.error, projectQuery.data, messagesQuery.data, agentRunsQuery.data ]); 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 { // Use React Query mutations instead of direct API calls const messagePromise = addUserMessageMutation.mutateAsync({ threadId, message }); const agentPromise = startAgentMutation.mutateAsync({ threadId, options }); const results = await Promise.allSettled([messagePromise, agentPromise]); // Handle failure to add the user message if (results[0].status === 'rejected') { const reason = results[0].reason; console.error("Failed to send message:", reason); throw new Error(`Failed to send message: ${reason?.message || reason}`); } // Handle failure to start the agent if (results[1].status === 'rejected') { const error = results[1].reason; console.error("Failed to start agent:", error); // Check if it's our custom BillingError (402) if (error instanceof BillingError) { console.log("Caught BillingError:", error.detail); // Extract billing details setBillingData({ // Note: currentUsage and limit might not be in the detail from the backend yet currentUsage: error.detail.currentUsage as number | undefined, limit: error.detail.limit as number | undefined, message: error.detail.message || 'Monthly usage limit reached. Please upgrade.', // Use message from error detail accountId: project?.account_id || null // Pass account ID }); setShowBillingAlert(true); // Remove the optimistic message since the agent couldn't start setMessages(prev => prev.filter(m => m.message_id !== optimisticUserMessage.message_id)); return; // Stop further execution in this case } // Handle other agent start errors throw new Error(`Failed to start agent: ${error?.message || error}`); } // If agent started successfully const agentResult = results[1].value; setAgentRunId(agentResult.agent_run_id); // Refresh queries after successful operations messagesQuery.refetch(); agentRunsQuery.refetch(); } catch (err) { // Catch errors from addUserMessage or non-BillingError agent start errors console.error('Error sending message or starting agent:', err); // Don't show billing alert here, only for specific BillingError if (!(err instanceof BillingError)) { toast.error(err instanceof Error ? err.message : 'Operation failed'); } // Ensure optimistic message is removed on any error during submit setMessages((prev) => prev.filter((m) => m.message_id !== optimisticUserMessage.message_id), ); } finally { setIsSending(false); } }, [threadId, project?.account_id, addUserMessageMutation, startAgentMutation, messagesQuery, agentRunsQuery], ); 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(); // Use React Query's stopAgentMutation if we have an agent run ID if (agentRunId) { try { await stopAgentMutation.mutateAsync(agentRunId); // Refresh agent runs after stopping agentRunsQuery.refetch(); } catch (error) { console.error('Error stopping agent:', error); } } }, [stopStreaming, agentRunId, stopAgentMutation, agentRunsQuery]); useEffect(() => { const lastMsg = messages[messages.length - 1]; const isNewUserMessage = lastMsg?.type === 'user'; if ((isNewUserMessage || agentStatus === 'running') && !userHasScrolled) { scrollToBottom('smooth'); } }, [messages, agentStatus, userHasScrolled]); 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]); 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); }, []); // 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}
); }, [], ); // 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]); // 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]); // Update messages when they change in the query useEffect(() => { if (messagesQuery.data && messagesQuery.status === 'success') { // Only update if we're not in initial loading and the agent isn't running if (!isLoading && agentStatus !== 'running' && agentStatus !== 'connecting') { // Map API message type to UnifiedMessage type const unifiedMessages = (messagesQuery.data || []) .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'); } } }, [messagesQuery.data, messagesQuery.status, isLoading, agentStatus, threadId]); // Check billing status and handle billing limit const checkBillingLimits = useCallback(async () => { // Skip billing checks in local development mode if (isLocalMode()) { console.log( 'Running in local development mode - billing checks are disabled', ); return false; } try { // Use React Query to get billing status await billingStatusQuery.refetch(); const result = billingStatusQuery.data; if (result && !result.can_run) { setBillingData({ currentUsage: result.subscription?.minutes_limit || 0, limit: result.subscription?.minutes_limit || 0, message: result.message || 'Usage limit reached', accountId: project?.account_id || null, }); setShowBillingAlert(true); return true; } return false; } catch (err) { console.error('Error checking billing status:', err); return false; } }, [project?.account_id, billingStatusQuery]); useEffect(() => { let timeoutId: NodeJS.Timeout; const shouldCheckBilling = project?.account_id && (initialLoadCompleted.current || (messagesLoadedRef.current && !isLoading) || (previousAgentStatus.current === 'running' && agentStatus === 'idle')); if (shouldCheckBilling) { timeoutId = setTimeout(() => { checkBillingLimits(); }, 500); } previousAgentStatus.current = agentStatus; return () => { if (timeoutId) { clearTimeout(timeoutId); } }; }, [ project?.account_id, isLoading, agentStatus, checkBillingLimits ]); // Check for debug mode in URL on initial load and when URL changes useEffect(() => { const debugParam = searchParams.get('debug'); setDebugMode(debugParam === 'true'); }, [searchParams]); // Main rendering function for the thread page if (!initialLoadCompleted.current || isLoading) { // Use the new ThreadSkeleton component instead of inline skeleton return ; } else if (error) { // Error state... return (

Thread Not Found

{error.includes( 'JSON object requested, multiple (or no) rows returned', ) ? 'This thread either does not exist or you do not have access to it.' : error }

{ 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} isLoading={!initialLoadCompleted.current || isLoading} /> { sandboxId && ( ) } {/* Billing Alert for usage limit */} setShowBillingAlert(false)} isOpen={showBillingAlert} />
); } else { return (
{/* Render debug mode indicator when active */} {debugMode && (
Debug Mode
)}
{/* Pass debugMode to ThreadContent component */}
{ 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} isLoading={!initialLoadCompleted.current || isLoading} /> {sandboxId && ( )} {/* Billing Alert for usage limit */} setShowBillingAlert(false)} isOpen={showBillingAlert} />
); } }