suna/frontend/src/app/(dashboard)/agents/[threadId]/page.tsx

1236 lines
43 KiB
TypeScript

'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<ThreadParams>;
}) {
const unwrappedParams = React.use(params);
const threadId = unwrappedParams.threadId;
const isMobile = useIsMobile();
const searchParams = useSearchParams();
const router = useRouter();
const [messages, setMessages] = useState<UnifiedMessage[]>([]);
const [newMessage, setNewMessage] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isSending, setIsSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [agentRunId, setAgentRunId] = useState<string | null>(null);
const [agentStatus, setAgentStatus] = useState<
'idle' | 'running' | 'connecting' | 'error'
>('idle');
const [isSidePanelOpen, setIsSidePanelOpen] = useState(false);
const [toolCalls, setToolCalls] = useState<ToolCallInput[]>([]);
const [currentToolIndex, setCurrentToolIndex] = useState<number>(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<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const latestMessageRef = useRef<HTMLDivElement>(null);
const [showScrollButton, setShowScrollButton] = useState(false);
const [userHasScrolled, setUserHasScrolled] = useState(false);
const hasInitiallyScrolled = useRef<boolean>(false);
const [project, setProject] = useState<Project | null>(null);
const [sandboxId, setSandboxId] = useState<string | null>(null);
const [fileViewerOpen, setFileViewerOpen] = useState(false);
const [projectName, setProjectName] = useState<string>('');
const [fileToView, setFileToView] = useState<string | null>(null);
const initialLoadCompleted = useRef<boolean>(false);
const messagesLoadedRef = useRef(false);
const agentRunsCheckedRef = useRef(false);
const previousAgentStatus = useRef<typeof agentStatus>('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 (
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground">
Assistant Message
</div>
<div className="rounded-md border bg-muted/50 p-3">
<div className="text-xs prose prose-xs dark:prose-invert chat-markdown max-w-none">{assistantContent}</div>
</div>
</div>
);
},
[],
);
// Process the tool result data
const toolViewResult = useCallback(
(toolContent?: string, isSuccess?: boolean) => {
if (!toolContent) return null;
return (
<div className="space-y-1">
<div className="flex justify-between items-center">
<div className="text-xs font-medium text-muted-foreground">
Tool Result
</div>
<div
className={`px-2 py-0.5 rounded-full text-xs ${isSuccess
? 'bg-green-50 text-green-700 dark:bg-green-900 dark:text-green-300'
: 'bg-red-50 text-red-700 dark:bg-red-900 dark:text-red-300'
}`}
>
{isSuccess ? 'Success' : 'Failed'}
</div>
</div>
<div className="rounded-md border bg-muted/50 p-3">
<div className="text-xs prose prose-xs dark:prose-invert chat-markdown max-w-none">{toolContent}</div>
</div>
</div>
);
},
[],
);
// 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 <ask> 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<ParsedMetadata>(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 <ask> 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('<execute-command>')
) {
formattedContent = `<execute-command>${toolArguments}</execute-command>`;
} else if (
toolName.toLowerCase().includes('file') &&
!toolArguments.includes('<create-file>')
) {
// 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}</${matchingTag}>`;
}
}
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 <ThreadSkeleton isSidePanelOpen={isSidePanelOpen} />;
} else if (error) {
// Error state...
return (
<div className="flex h-screen">
<div
className={`flex flex-col flex-1 overflow-hidden transition-all duration-200 ease-in-out ${(isSidePanelOpen && initialLoadCompleted.current) ? 'mr-[90%] sm:mr-[450px] md:mr-[500px] lg:mr-[550px] xl:mr-[650px]' : ''}`}
>
<SiteHeader
threadId={threadId}
projectName={projectName}
projectId={project?.id || ''}
onViewFiles={handleOpenFileViewer}
onToggleSidePanel={toggleSidePanel}
isMobileView={isMobile}
debugMode={debugMode}
/>
<div className="flex flex-1 items-center justify-center p-4">
<div className="flex w-full max-w-md flex-col items-center gap-4 rounded-lg border bg-card p-6 text-center">
<div className="rounded-full bg-destructive/10 p-3">
<AlertTriangle className="h-6 w-6 text-destructive" />
</div>
<h2 className="text-lg font-semibold text-destructive">
Thread Not Found
</h2>
<p className="text-sm text-muted-foreground">
{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
}
</p>
</div>
</div>
</div>
<ToolCallSidePanel
isOpen={isSidePanelOpen && initialLoadCompleted.current}
onClose={() => {
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 && (
<FileViewerModal
open={fileViewerOpen}
onOpenChange={setFileViewerOpen}
sandboxId={sandboxId}
initialFilePath={fileToView}
project={project || undefined}
/>
)
}
{/* Billing Alert for usage limit */}
<BillingErrorAlert
message={billingData.message}
currentUsage={billingData.currentUsage}
limit={billingData.limit}
accountId={billingData.accountId}
onDismiss={() => setShowBillingAlert(false)}
isOpen={showBillingAlert}
/>
</div>
);
} else {
return (
<div className="flex h-screen">
{/* Render debug mode indicator when active */}
{debugMode && (
<div className="fixed top-16 right-4 bg-amber-500 text-black text-xs px-2 py-1 rounded-md shadow-md z-50">
Debug Mode
</div>
)}
<div
className={`flex flex-col flex-1 overflow-hidden transition-all duration-200 ease-in-out ${(!initialLoadCompleted.current || isSidePanelOpen) ? 'mr-[90%] sm:mr-[450px] md:mr-[500px] lg:mr-[550px] xl:mr-[650px]' : ''}`}
>
<SiteHeader
threadId={threadId}
projectName={projectName}
projectId={project?.id || ''}
onViewFiles={handleOpenFileViewer}
onToggleSidePanel={toggleSidePanel}
onProjectRenamed={handleProjectRenamed}
isMobileView={isMobile}
debugMode={debugMode}
/>
{/* Pass debugMode to ThreadContent component */}
<ThreadContent
messages={messages}
streamingTextContent={streamingTextContent}
streamingToolCall={streamingToolCall}
agentStatus={agentStatus}
handleToolClick={handleToolClick}
handleOpenFileViewer={handleOpenFileViewer}
readOnly={false}
streamHookStatus={streamHookStatus}
sandboxId={sandboxId}
project={project}
debugMode={debugMode}
/>
<div
className={cn(
"fixed bottom-0 z-10 bg-gradient-to-t from-background via-background/90 to-transparent px-4 pt-8 transition-all duration-200 ease-in-out",
leftSidebarState === 'expanded' ? 'left-[72px] lg:left-[256px]' : 'left-[72px]',
isSidePanelOpen ? 'right-[90%] sm:right-[450px] md:right-[500px] lg:right-[550px] xl:right-[650px]' : 'right-0',
isMobile ? 'left-0 right-0' : ''
)}>
<div className={cn(
"mx-auto",
isMobile ? "w-full px-4" : "max-w-3xl"
)}>
<ChatInput
value={newMessage}
onChange={setNewMessage}
onSubmit={handleSubmitMessage}
placeholder="Ask Suna anything..."
loading={isSending}
disabled={isSending || agentStatus === 'running' || agentStatus === 'connecting'}
isAgentRunning={agentStatus === 'running' || agentStatus === 'connecting'}
onStopAgent={handleStopAgent}
autoFocus={!isLoading}
onFileBrowse={handleOpenFileViewer}
sandboxId={sandboxId || undefined}
/>
</div>
</div>
</div>
<ToolCallSidePanel
isOpen={isSidePanelOpen && initialLoadCompleted.current}
onClose={() => {
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 && (
<FileViewerModal
open={fileViewerOpen}
onOpenChange={setFileViewerOpen}
sandboxId={sandboxId}
initialFilePath={fileToView}
project={project || undefined}
/>
)}
{/* Billing Alert for usage limit */}
<BillingErrorAlert
message={billingData.message}
currentUsage={billingData.currentUsage}
limit={billingData.limit}
accountId={billingData.accountId}
onDismiss={() => setShowBillingAlert(false)}
isOpen={showBillingAlert}
/>
</div>
);
}
}