'use client'; import React, { useCallback, useEffect, useRef, useState, useMemo, } from 'react'; import { useSearchParams } from 'next/navigation'; import { BillingError } from '@/lib/api'; import { toast } from 'sonner'; import { ChatInput } from '@/components/thread/chat-input/chat-input'; import { useSidebar } from '@/components/ui/sidebar'; import { useAgentStream } from '@/hooks/useAgentStream'; import { cn } from '@/lib/utils'; import { useIsMobile } from '@/hooks/use-mobile'; import { isLocalMode } from '@/lib/config'; import { ThreadContent } from '@/components/thread/content/ThreadContent'; import { ThreadSkeleton } from '@/components/thread/content/ThreadSkeleton'; import { useAddUserMessageMutation } from '@/hooks/react-query/threads/use-messages'; import { useStartAgentMutation, useStopAgentMutation } from '@/hooks/react-query/threads/use-agent-run'; import { useSubscription } from '@/hooks/react-query/subscriptions/use-subscriptions'; import { SubscriptionStatus } from '@/components/thread/chat-input/_use-model-selection'; import { UnifiedMessage, ApiMessageType, ToolCallInput, Project } from '../_types'; import { useThreadData, useToolCalls, useBilling, useKeyboardShortcuts } from '../_hooks'; import { ThreadError, UpgradeDialog, ThreadLayout } from '../_components'; import { useVncPreloader } from '@/hooks/useVncPreloader'; import { useThreadAgent } from '@/hooks/react-query/agents/use-agents'; export default function ThreadPage({ params, }: { params: Promise<{ projectId: string; threadId: string; }>; }) { const unwrappedParams = React.use(params); const { projectId, threadId } = unwrappedParams; const isMobile = useIsMobile(); const searchParams = useSearchParams(); // State const [newMessage, setNewMessage] = useState(''); const [isSending, setIsSending] = useState(false); const [fileViewerOpen, setFileViewerOpen] = useState(false); const [fileToView, setFileToView] = useState(null); const [filePathList, setFilePathList] = useState(undefined); const [showUpgradeDialog, setShowUpgradeDialog] = useState(false); const [debugMode, setDebugMode] = useState(false); const [initialPanelOpenAttempted, setInitialPanelOpenAttempted] = useState(false); const [selectedAgentId, setSelectedAgentId] = useState(undefined); // Refs 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 initialLayoutAppliedRef = useRef(false); // Sidebar const { state: leftSidebarState, setOpen: setLeftSidebarOpen } = useSidebar(); // Custom hooks const { messages, setMessages, project, sandboxId, projectName, agentRunId, setAgentRunId, agentStatus, setAgentStatus, isLoading, error, initialLoadCompleted, threadQuery, messagesQuery, projectQuery, agentRunsQuery, } = useThreadData(threadId, projectId); const { toolCalls, setToolCalls, currentToolIndex, setCurrentToolIndex, isSidePanelOpen, setIsSidePanelOpen, autoOpenedPanel, setAutoOpenedPanel, externalNavIndex, setExternalNavIndex, handleToolClick, handleStreamingToolCall, toggleSidePanel, handleSidePanelNavigate, userClosedPanelRef, } = useToolCalls(messages, setLeftSidebarOpen, agentStatus); const { showBillingAlert, setShowBillingAlert, billingData, setBillingData, checkBillingLimits, billingStatusQuery, } = useBilling(project?.account_id, agentStatus, initialLoadCompleted); // Keyboard shortcuts useKeyboardShortcuts({ isSidePanelOpen, setIsSidePanelOpen, leftSidebarState, setLeftSidebarOpen, userClosedPanelRef, }); const addUserMessageMutation = useAddUserMessageMutation(); const startAgentMutation = useStartAgentMutation(); const stopAgentMutation = useStopAgentMutation(); const { data: threadAgentData } = useThreadAgent(threadId); const agent = threadAgentData?.agent; const workflowId = threadQuery.data?.metadata?.workflow_id; // Set initial selected agent from thread data useEffect(() => { if (threadAgentData?.agent && !selectedAgentId) { setSelectedAgentId(threadAgentData.agent.agent_id); } }, [threadAgentData, selectedAgentId]); const { data: subscriptionData } = useSubscription(); const subscriptionStatus: SubscriptionStatus = subscriptionData?.status === 'active' ? 'active' : 'no_subscription'; // Memoize project for VNC preloader to prevent re-preloading on every render const memoizedProject = useMemo(() => project, [project?.id, project?.sandbox?.vnc_preview, project?.sandbox?.pass]); useVncPreloader(memoizedProject); const handleProjectRenamed = useCallback((newName: string) => { }, []); const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => { messagesEndRef.current?.scrollIntoView({ behavior }); }; const handleNewMessageFromStream = useCallback((message: UnifiedMessage) => { 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 (message.type === 'tool') { setAutoOpenedPanel(false); } }, [setMessages, setAutoOpenedPanel]); 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); setAutoOpenedPanel(false); if ( [ 'completed', 'stopped', 'agent_not_running', 'error', 'failed', ].includes(hookStatus) ) { scrollToBottom('smooth'); } break; case 'connecting': setAgentStatus('connecting'); break; case 'streaming': setAgentStatus('running'); break; } }, [setAgentStatus, setAgentRunId, setAutoOpenedPanel]); 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]); // Agent stream hook const { status: streamHookStatus, textContent: streamingTextContent, toolCall: streamingToolCall, error: streamError, agentRunId: currentHookRunId, startStreaming, stopStreaming, } = useAgentStream( { onMessage: handleNewMessageFromStream, onStatusChange: handleStreamStatusChange, onError: handleStreamError, onClose: handleStreamClose, }, threadId, setMessages, ); 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 messagePromise = addUserMessageMutation.mutateAsync({ threadId, message }); const agentPromise = startAgentMutation.mutateAsync({ threadId, options: { ...options, agent_id: selectedAgentId } }); const results = await Promise.allSettled([messagePromise, agentPromise]); 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}`); } if (results[1].status === 'rejected') { const error = results[1].reason; console.error("Failed to start agent:", error); if (error instanceof BillingError) { console.log("Caught BillingError:", error.detail); setBillingData({ currentUsage: error.detail.currentUsage as number | undefined, limit: error.detail.limit as number | undefined, message: error.detail.message || 'Monthly usage limit reached. Please upgrade.', accountId: project?.account_id || null }); setShowBillingAlert(true); setMessages(prev => prev.filter(m => m.message_id !== optimisticUserMessage.message_id)); return; } throw new Error(`Failed to start agent: ${error?.message || error}`); } const agentResult = results[1].value; setAgentRunId(agentResult.agent_run_id); messagesQuery.refetch(); agentRunsQuery.refetch(); } catch (err) { console.error('Error sending message or starting agent:', err); if (!(err instanceof BillingError)) { toast.error(err instanceof Error ? err.message : 'Operation failed'); } setMessages((prev) => prev.filter((m) => m.message_id !== optimisticUserMessage.message_id), ); } finally { setIsSending(false); } }, [threadId, project?.account_id, addUserMessageMutation, startAgentMutation, messagesQuery, agentRunsQuery, setMessages, setBillingData, setShowBillingAlert, setAgentRunId], ); const handleStopAgent = useCallback(async () => { console.log(`[PAGE] Requesting agent stop via hook.`); setAgentStatus('idle'); await stopStreaming(); if (agentRunId) { try { await stopAgentMutation.mutateAsync(agentRunId); agentRunsQuery.refetch(); } catch (error) { console.error('Error stopping agent:', error); } } }, [stopStreaming, agentRunId, stopAgentMutation, agentRunsQuery, setAgentStatus]); const handleOpenFileViewer = useCallback((filePath?: string, filePathList?: string[]) => { if (filePath) { setFileToView(filePath); } else { setFileToView(null); } setFilePathList(filePathList); setFileViewerOpen(true); }, []); const toolViewAssistant = useCallback( (assistantContent?: string, toolContent?: string) => { if (!assistantContent) return null; return (
Assistant Message
{assistantContent}
); }, [], ); const toolViewResult = useCallback( (toolContent?: string, isSuccess?: boolean) => { if (!toolContent) return null; return (
Tool Result
{isSuccess ? 'Success' : 'Failed'}
{toolContent}
); }, [], ); // Effects useEffect(() => { if (!initialLayoutAppliedRef.current) { setLeftSidebarOpen(false); initialLayoutAppliedRef.current = true; } }, [setLeftSidebarOpen]); useEffect(() => { if (initialLoadCompleted && !initialPanelOpenAttempted) { setInitialPanelOpenAttempted(true); if (toolCalls.length > 0) { setIsSidePanelOpen(true); setCurrentToolIndex(toolCalls.length - 1); } else { if (messages.length > 0) { setIsSidePanelOpen(true); } } } }, [initialPanelOpenAttempted, messages, toolCalls, initialLoadCompleted, setIsSidePanelOpen, setCurrentToolIndex]); useEffect(() => { if (agentRunId && agentRunId !== currentHookRunId) { console.log( `[PAGE] Target agentRunId set to ${agentRunId}, initiating stream...`, ); startStreaming(agentRunId); } }, [agentRunId, startStreaming, currentHookRunId]); 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 ((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, setAgentStatus, setAgentRunId, setAutoOpenedPanel]); // SEO title update useEffect(() => { if (projectName) { document.title = `${projectName} | Kortix Suna`; const metaDescription = document.querySelector( 'meta[name="description"]', ); if (metaDescription) { metaDescription.setAttribute( 'content', `${projectName} - Interactive agent conversation powered by Kortix Suna`, ); } 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]); useEffect(() => { const debugParam = searchParams.get('debug'); setDebugMode(debugParam === 'true'); }, [searchParams]); const hasCheckedUpgradeDialog = useRef(false); useEffect(() => { if (initialLoadCompleted && subscriptionData && !hasCheckedUpgradeDialog.current) { hasCheckedUpgradeDialog.current = true; const hasSeenUpgradeDialog = localStorage.getItem('suna_upgrade_dialog_displayed'); const isFreeTier = subscriptionStatus === 'no_subscription'; if (!hasSeenUpgradeDialog && isFreeTier && !isLocalMode()) { setShowUpgradeDialog(true); } } }, [subscriptionData, subscriptionStatus, initialLoadCompleted]); const handleDismissUpgradeDialog = () => { setShowUpgradeDialog(false); localStorage.setItem('suna_upgrade_dialog_displayed', 'true'); }; useEffect(() => { if (streamingToolCall) { handleStreamingToolCall(streamingToolCall); } }, [streamingToolCall, handleStreamingToolCall]); if (!initialLoadCompleted || isLoading) { return ; } if (error) { return ( { setIsSidePanelOpen(false); userClosedPanelRef.current = true; setAutoOpenedPanel(true); }} renderAssistantMessage={toolViewAssistant} renderToolResult={toolViewResult} isLoading={!initialLoadCompleted || isLoading} showBillingAlert={showBillingAlert} billingData={billingData} onDismissBilling={() => setShowBillingAlert(false)} debugMode={debugMode} isMobile={isMobile} initialLoadCompleted={initialLoadCompleted} agentName={agent && agent.name} > ); } return ( <> { setIsSidePanelOpen(false); userClosedPanelRef.current = true; setAutoOpenedPanel(true); }} renderAssistantMessage={toolViewAssistant} renderToolResult={toolViewResult} isLoading={!initialLoadCompleted || isLoading} showBillingAlert={showBillingAlert} billingData={billingData} onDismissBilling={() => setShowBillingAlert(false)} debugMode={debugMode} isMobile={isMobile} initialLoadCompleted={initialLoadCompleted} agentName={agent && agent.name} > {/* {workflowId && (
)} */}
0} onExpandToolPreview={() => { setIsSidePanelOpen(true); userClosedPanelRef.current = false; }} defaultShowSnackbar="tokens" />
); }