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

1623 lines
70 KiB
TypeScript

'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 <Markdown className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none break-words">{content}</Markdown>;
}
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(
<Markdown key={`md-${lastIndex}`} className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none inline-block mr-1 break-words">{textBeforeTag}</Markdown>
);
}
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(/<ask[^>]*>([\s\S]*?)<\/ask>/i);
const askContent = contentMatch ? contentMatch[1] : '';
// Render <ask> tag content with attachment UI
contentParts.push(
<div key={`ask-${match.index}`} className="space-y-3">
<Markdown className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none break-words [&>:first-child]:mt-0 prose-headings:mt-3">{askContent}</Markdown>
{attachments.length > 0 && (
<div className="mt-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{attachments.map((attachment, idx) => {
const extension = attachment.split('.').pop()?.toLowerCase();
const filename = attachment.split('/').pop() || 'file';
// Define file size (in a real app, this would come from the backend)
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' :
`${(Math.random() * 5 + 1).toFixed(2)} KB`;
// Get file type display
const fileType = extension === 'html' ? 'Code' : 'Text';
return (
<button
key={`attachment-${idx}`}
onClick={() => fileViewerHandler?.(attachment)}
className="group flex items-center gap-3 p-4 rounded-md bg-muted/10 hover:bg-muted/20 transition-colors"
>
<div className="flex items-center justify-center">
<File className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0 text-left">
<div className="text-sm font-medium text-foreground truncate">
{filename}
</div>
<div className="text-xs text-muted-foreground flex items-center gap-1">
<span>{fileType}</span>
<span>·</span>
<span>{fileSize}</span>
</div>
</div>
</button>
);
})}
</div>
</div>
)}
</div>
);
} else {
// Render tool button as a clickable element
contentParts.push(
<button
key={toolCallKey}
onClick={() => handleToolClick(messageId, toolName)}
className="inline-flex items-center gap-1.5 py-1 px-2.5 my-1 text-xs text-muted-foreground bg-muted hover:bg-muted/80 rounded-md transition-colors cursor-pointer border border-border"
>
<IconComponent className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
<span className="font-mono text-xs text-foreground">{toolName}</span>
{paramDisplay && <span className="ml-1 text-muted-foreground truncate max-w-[200px]" title={paramDisplay}>{paramDisplay}</span>}
</button>
);
}
lastIndex = xmlRegex.lastIndex;
}
// Add text after the last tag
if (lastIndex < content.length) {
contentParts.push(
<Markdown key={`md-${lastIndex}`} className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none break-words">{content.substring(lastIndex)}</Markdown>
);
}
return contentParts;
}
export default function ThreadPage({ params }: { params: Promise<ThreadParams> }) {
const unwrappedParams = React.use(params);
const threadId = unwrappedParams.threadId;
const isMobile = useIsMobile();
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;
}>({});
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');
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) => {
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)
]);
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 <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]);
// 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">
<Markdown className="text-xs prose prose-xs dark:prose-invert chat-markdown max-w-none">{assistantContent}</Markdown>
</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">
<Markdown className="text-xs prose prose-xs dark:prose-invert chat-markdown max-w-none">{toolContent}</Markdown>
</div>
</div>
);
}, []);
// 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]);
// 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 (
<div className="flex h-screen">
<div className={`flex flex-col flex-1 overflow-hidden transition-all duration-200 ease-in-out ${isSidePanelOpen ? 'mr-[90%] sm:mr-[450px] md:mr-[500px] lg:mr-[550px] xl:mr-[650px]' : ''}`}>
{/* Skeleton Header */}
<div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex h-14 items-center gap-4 px-4">
<div className="flex-1">
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-6 rounded-full" />
<Skeleton className="h-5 w-40" />
</div>
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-8 w-8 rounded-full" />
</div>
</div>
</div>
{/* Skeleton Chat Messages */}
<div className="flex-1 overflow-y-auto px-6 py-4 pb-[5.5rem]">
<div className="mx-auto max-w-3xl space-y-6">
{/* User message */}
<div className="flex justify-end">
<div className="max-w-[85%] rounded-lg bg-primary/10 px-4 py-3">
<div className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</div>
</div>
{/* Assistant response with tool usage */}
<div>
<div className="flex items-start gap-3">
<Skeleton className="flex-shrink-0 w-5 h-5 mt-2 rounded-full" />
<div className="flex-1 space-y-2">
<div className="max-w-[90%] w-full rounded-lg bg-muted px-4 py-3">
<div className="space-y-3">
<div>
<Skeleton className="h-4 w-full max-w-[360px] mb-2" />
<Skeleton className="h-4 w-full max-w-[320px] mb-2" />
<Skeleton className="h-4 w-full max-w-[290px]" />
</div>
{/* Tool call button skeleton */}
<div className="py-1">
<Skeleton className="h-6 w-32 rounded-md" />
</div>
<div>
<Skeleton className="h-4 w-full max-w-[340px] mb-2" />
<Skeleton className="h-4 w-full max-w-[280px]" />
</div>
</div>
</div>
</div>
</div>
</div>
{/* User message */}
<div className="flex justify-end">
<div className="max-w-[85%] rounded-lg bg-primary/10 px-4 py-3">
<Skeleton className="h-4 w-36" />
</div>
</div>
{/* Assistant thinking state */}
<div>
<div className="flex items-start gap-3">
<Skeleton className="flex-shrink-0 w-5 h-5 mt-2 rounded-full" />
<div className="flex-1 space-y-2">
<div className="flex items-center gap-1.5 py-1">
<div className="h-1.5 w-1.5 rounded-full bg-gray-400/50 animate-pulse" />
<div className="h-1.5 w-1.5 rounded-full bg-gray-400/50 animate-pulse delay-150" />
<div className="h-1.5 w-1.5 rounded-full bg-gray-400/50 animate-pulse delay-300" />
</div>
</div>
</div>
</div>
</div>
</div>
{/* Skeleton Chat Input */}
<div className="border-t p-4">
<div className="mx-auto max-w-3xl">
<div className="relative">
<Skeleton className="h-10 w-full rounded-md" />
<div className="absolute right-2 top-2">
<Skeleton className="h-6 w-6 rounded-full" />
</div>
</div>
</div>
</div>
</div>
{/* Skeleton Side Panel (closed state) */}
<div className={`hidden ${isSidePanelOpen ? 'block' : ''}`}>
<div className="h-screen w-[450px] border-l">
<div className="p-4">
<Skeleton className="h-8 w-32 mb-4" />
<Skeleton className="h-20 w-full rounded-md mb-4" />
<Skeleton className="h-40 w-full rounded-md" />
</div>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="flex h-screen">
<div className={`flex flex-col flex-1 overflow-hidden transition-all duration-200 ease-in-out ${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}
isMobileView={isMobile}
/>
<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">
<h2 className="text-lg font-semibold text-destructive">Error</h2>
<p className="text-sm text-muted-foreground">{error}</p>
<Button variant="outline" onClick={() => router.push(`/projects/${project?.id || ''}`)}>
Back to Project
</Button>
</div>
</div>
</div>
<ToolCallSidePanel
isOpen={isSidePanelOpen}
onClose={() => setIsSidePanelOpen(false)}
toolCalls={[]}
currentIndex={0}
onNavigate={handleSidePanelNavigate}
project={project || undefined}
agentStatus="error"
/>
</div>
);
}
return (
<div className="flex h-screen">
<div className={`flex flex-col flex-1 overflow-hidden transition-all duration-200 ease-in-out ${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}
/>
<div
ref={messagesContainerRef}
className="flex-1 overflow-y-auto px-6 py-4 pb-24 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
onScroll={handleScroll}
>
<div className="mx-auto max-w-3xl">
{messages.length === 0 && !streamingTextContent && !streamingToolCall && agentStatus === 'idle' ? (
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">Send a message to start.</div>
</div>
) : (
<div className="space-y-8">
{(() => {
// 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<ParsedContent>(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 (
<div key={group.key} className="flex justify-end">
<div className="inline-flex max-w-[85%] rounded-lg bg-primary/10 px-4 py-3">
<div className="space-y-3">
{cleanContent && (
<Markdown className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none [&>:first-child]:mt-0 prose-headings:mt-3">{cleanContent}</Markdown>
)}
{attachments.length > 0 && (
<div className="mt-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{attachments.map((attachment, idx) => {
const extension = attachment.split('.').pop()?.toLowerCase();
const filename = attachment.split('/').pop() || 'file';
// Define file size (in a real app, this would come from the backend)
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' :
`${(Math.random() * 5 + 1).toFixed(2)} KB`;
// Get file type display
const fileType = extension === 'html' ? 'Code' : 'Text';
return (
<button
key={`attachment-${idx}`}
onClick={() => handleOpenFileViewer(attachment)}
className="group flex items-center gap-3 p-4 rounded-md bg-muted/10 hover:bg-muted/20 transition-colors"
>
<div className="flex items-center justify-center">
<File className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0 text-left">
<div className="text-sm font-medium text-foreground truncate">
{filename}
</div>
<div className="text-xs text-muted-foreground flex items-center gap-1">
<span>{fileType}</span>
<span>·</span>
<span>{fileSize}</span>
</div>
</div>
</button>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
);
} else if (group.type === 'assistant_group') {
return (
<div key={group.key} ref={groupIndex === groupedMessages.length - 1 ? latestMessageRef : null}>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-5 h-5 mt-2 rounded-md flex items-center justify-center overflow-hidden ml-auto mr-2">
<Image src="/kortix-symbol.svg" alt="Kortix" width={14} height={14} className="object-contain invert dark:invert-0 opacity-70" />
</div>
<div className="flex-1">
<div className="inline-flex max-w-[90%] rounded-lg bg-muted/5 px-4 py-3 text-sm">
<div className="space-y-2">
{(() => {
const toolResultsMap = new Map<string | null, UnifiedMessage[]>();
group.messages.forEach(msg => {
if (msg.type === 'tool') {
const meta = safeJsonParse<ParsedMetadata>(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<string>();
const elements: React.ReactNode[] = [];
group.messages.forEach((message, msgIndex) => {
if (message.type === 'assistant') {
const parsedContent = safeJsonParse<ParsedContent>(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(
<div key={msgKey} className={msgIndex > 0 ? "mt-2" : ""}>
<div className="prose prose-sm dark:prose-invert chat-markdown max-w-none [&>:first-child]:mt-0 prose-headings:mt-3">
{renderedContent}
</div>
</div>
);
}
});
return elements;
})()}
{groupIndex === groupedMessages.length - 1 && (streamHookStatus === 'streaming' || streamHookStatus === 'connecting') && (
<div className="mt-2">
{(() => {
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 && (
<Markdown className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none [&>:first-child]:mt-0 prose-headings:mt-3">{textBeforeTag}</Markdown>
)}
{showCursor && (
<span className="inline-block h-4 w-0.5 bg-primary ml-0.5 -mb-1 animate-pulse" />
)}
{detectedTag && (
<div className="mt-2 mb-1">
<button
className="inline-flex items-center gap-1.5 py-1 px-2.5 text-xs font-medium text-primary bg-primary/10 hover:bg-primary/20 rounded-md transition-colors cursor-pointer border border-primary/20"
>
<CircleDashed className="h-3.5 w-3.5 text-primary flex-shrink-0 animate-spin animation-duration-2000" />
<span className="font-mono text-xs text-primary">{detectedTag}</span>
</button>
</div>
)}
{streamingToolCall && !detectedTag && (
<div className="mt-2 mb-1">
{(() => {
const toolName = streamingToolCall.name || streamingToolCall.xml_tag_name || 'Tool';
const IconComponent = getToolIcon(toolName);
const paramDisplay = extractPrimaryParam(toolName, streamingToolCall.arguments || '');
return (
<button
className="inline-flex items-center gap-1.5 py-1 px-2.5 text-xs font-medium text-primary bg-primary/10 hover:bg-primary/20 rounded-md transition-colors cursor-pointer border border-primary/20"
>
<CircleDashed className="h-3.5 w-3.5 text-primary flex-shrink-0 animate-spin animation-duration-2000" />
<span className="font-mono text-xs text-primary">{toolName}</span>
{paramDisplay && <span className="ml-1 text-primary/70 truncate max-w-[200px]" title={paramDisplay}>{paramDisplay}</span>}
</button>
);
})()}
</div>
)}
</>
);
})()}
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}
return null;
});
})()}
{(agentStatus === 'running' || agentStatus === 'connecting') &&
(messages.length === 0 || messages[messages.length - 1].type === 'user') && (
<div ref={latestMessageRef}>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-5 h-5 rounded-md flex items-center justify-center overflow-hidden bg-primary/10">
<Image src="/kortix-symbol.svg" alt="Suna" width={14} height={14} className="object-contain"/>
</div>
<div className="flex-1 space-y-2">
<div className="max-w-[90%] px-4 py-3 text-sm">
<div className="flex items-center gap-1.5 py-1">
<div className="h-1.5 w-1.5 rounded-full bg-primary/50 animate-pulse" />
<div className="h-1.5 w-1.5 rounded-full bg-primary/50 animate-pulse delay-150" />
<div className="h-1.5 w-1.5 rounded-full bg-primary/50 animate-pulse delay-300" />
</div>
</div>
</div>
</div>
</div>
)}
</div>
)}
<div ref={messagesEndRef} className="h-1" />
</div>
</div>
</div>
<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>
<ToolCallSidePanel
isOpen={isSidePanelOpen}
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}
/>
{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 || null}
onDismiss={() => setShowBillingAlert(false)}
isOpen={showBillingAlert}
/>
</div>
);
}