From 797b49686353e749de00257085bb29be34622081 Mon Sep 17 00:00:00 2001 From: marko-kraemer Date: Sun, 20 Apr 2025 17:08:17 +0100 Subject: [PATCH] frontend --- .../20250416133920_agentpress_schema.sql | 14 +- .../app/dashboard/agents/[threadId]/page.tsx | 14 +- frontend/src/app/dashboard/layout.tsx | 2 +- frontend/src/app/share/[threadId]/layout.tsx | 19 + frontend/src/app/share/[threadId]/page.tsx | 1725 +++++++++++++++++ .../{team => basejump}/create-team-dialog.tsx | 0 .../dashboard/sidebar/calendars.tsx | 71 - .../{dashboard => }/sidebar/cta.tsx | 2 +- .../{dashboard => }/sidebar/date-picker.tsx | 0 .../sidebar/kortix-enterprise-modal.tsx | 14 +- .../{dashboard => }/sidebar/kortix-logo.tsx | 0 .../{dashboard => }/sidebar/nav-agents.tsx | 166 +- .../{dashboard => }/sidebar/nav-main.tsx | 0 .../sidebar/nav-user-with-teams.tsx | 0 .../{dashboard => }/sidebar/sidebar-left.tsx | 8 +- .../components/thread/thread-site-header.tsx | 7 + frontend/src/lib/api.ts | 136 +- 17 files changed, 1992 insertions(+), 186 deletions(-) create mode 100644 frontend/src/app/share/[threadId]/layout.tsx create mode 100644 frontend/src/app/share/[threadId]/page.tsx rename frontend/src/components/{team => basejump}/create-team-dialog.tsx (100%) delete mode 100644 frontend/src/components/dashboard/sidebar/calendars.tsx rename frontend/src/components/{dashboard => }/sidebar/cta.tsx (93%) rename frontend/src/components/{dashboard => }/sidebar/date-picker.tsx (100%) rename frontend/src/components/{dashboard => }/sidebar/kortix-enterprise-modal.tsx (95%) rename frontend/src/components/{dashboard => }/sidebar/kortix-logo.tsx (100%) rename frontend/src/components/{dashboard => }/sidebar/nav-agents.tsx (63%) rename frontend/src/components/{dashboard => }/sidebar/nav-main.tsx (100%) rename frontend/src/components/{dashboard => }/sidebar/nav-user-with-teams.tsx (100%) rename frontend/src/components/{dashboard => }/sidebar/sidebar-left.tsx (92%) diff --git a/backend/supabase/migrations/20250416133920_agentpress_schema.sql b/backend/supabase/migrations/20250416133920_agentpress_schema.sql index a7d8702b..a33510f0 100644 --- a/backend/supabase/migrations/20250416133920_agentpress_schema.sql +++ b/backend/supabase/migrations/20250416133920_agentpress_schema.sql @@ -15,6 +15,7 @@ CREATE TABLE threads ( thread_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), account_id UUID REFERENCES basejump.accounts(id) ON DELETE CASCADE, project_id UUID REFERENCES projects(project_id) ON DELETE CASCADE, + is_public BOOLEAN DEFAULT FALSE, created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL, updated_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL ); @@ -113,6 +114,7 @@ CREATE POLICY project_delete_policy ON projects CREATE POLICY thread_select_policy ON threads FOR SELECT USING ( + is_public = TRUE OR basejump.has_role_on_account(account_id) = true OR EXISTS ( SELECT 1 FROM projects @@ -163,6 +165,7 @@ CREATE POLICY agent_run_select_policy ON agent_runs LEFT JOIN projects ON threads.project_id = projects.project_id WHERE threads.thread_id = agent_runs.thread_id AND ( + threads.is_public = TRUE OR basejump.has_role_on_account(threads.account_id) = true OR basejump.has_role_on_account(projects.account_id) = true ) @@ -220,6 +223,7 @@ CREATE POLICY message_select_policy ON messages LEFT JOIN projects ON threads.project_id = projects.project_id WHERE threads.thread_id = messages.thread_id AND ( + threads.is_public = TRUE OR basejump.has_role_on_account(threads.account_id) = true OR basejump.has_role_on_account(projects.account_id) = true ) @@ -286,12 +290,18 @@ DECLARE current_role TEXT; latest_summary_id UUID; latest_summary_time TIMESTAMP WITH TIME ZONE; + is_thread_public BOOLEAN; BEGIN -- Get current role SELECT current_user INTO current_role; - -- Skip access check for service_role - IF current_role = 'authenticated' THEN + -- Check if thread is public + SELECT is_public INTO is_thread_public + FROM threads + WHERE thread_id = p_thread_id; + + -- Skip access check for service_role or public threads + IF current_role = 'authenticated' AND NOT is_thread_public THEN -- Check if thread exists and user has access SELECT EXISTS ( SELECT 1 FROM threads t diff --git a/frontend/src/app/dashboard/agents/[threadId]/page.tsx b/frontend/src/app/dashboard/agents/[threadId]/page.tsx index a47a332a..43baf885 100644 --- a/frontend/src/app/dashboard/agents/[threadId]/page.tsx +++ b/frontend/src/app/dashboard/agents/[threadId]/page.tsx @@ -417,10 +417,14 @@ export default function ThreadPage({ params }: { params: Promise } 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 @@ -1089,7 +1093,7 @@ export default function ThreadPage({ params }: { params: Promise } @@ -1109,7 +1113,7 @@ export default function ThreadPage({ params }: { params: Promise } toolCalls={[]} currentIndex={0} onNavigate={handleSidePanelNavigate} - project={project} + project={project || undefined} agentStatus="error" /> @@ -1122,7 +1126,7 @@ export default function ThreadPage({ params }: { params: Promise } } agentStatus={agentStatus} currentIndex={currentToolIndex} onNavigate={handleSidePanelNavigate} - project={project} + project={project || undefined} renderAssistantMessage={toolViewAssistant} renderToolResult={toolViewResult} /> @@ -1396,7 +1400,7 @@ export default function ThreadPage({ params }: { params: Promise } onOpenChange={setFileViewerOpen} sandboxId={sandboxId} initialFilePath={fileToView} - project={project} + project={project || undefined} /> )} diff --git a/frontend/src/app/dashboard/layout.tsx b/frontend/src/app/dashboard/layout.tsx index 70c00da1..cf2afd5c 100644 --- a/frontend/src/app/dashboard/layout.tsx +++ b/frontend/src/app/dashboard/layout.tsx @@ -1,6 +1,6 @@ "use client" -import { SidebarLeft } from "@/components/dashboard/sidebar/sidebar-left" +import { SidebarLeft } from "@/components/sidebar/sidebar-left" import { SidebarInset, SidebarProvider, diff --git a/frontend/src/app/share/[threadId]/layout.tsx b/frontend/src/app/share/[threadId]/layout.tsx new file mode 100644 index 00000000..58870b1f --- /dev/null +++ b/frontend/src/app/share/[threadId]/layout.tsx @@ -0,0 +1,19 @@ +import { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Shared Conversation', + description: 'View a shared AI conversation', + openGraph: { + title: 'Shared AI Conversation', + description: 'View a shared AI conversation from Kortix Manus', + images: ['/kortix-logo.png'], + }, +}; + +export default function ThreadLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}; +} \ No newline at end of file diff --git a/frontend/src/app/share/[threadId]/page.tsx b/frontend/src/app/share/[threadId]/page.tsx new file mode 100644 index 00000000..13938e3a --- /dev/null +++ b/frontend/src/app/share/[threadId]/page.tsx @@ -0,0 +1,1725 @@ +'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, Play, Pause +} from 'lucide-react'; +import { getMessages, getAgentRuns, getProject, getThread, Project, Message as BaseApiMessageType } from '@/lib/api'; +import { toast } from 'sonner'; +import { Skeleton } from "@/components/ui/skeleton"; +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 { useAgentStream } from '@/hooks/useAgentStream'; +import { Markdown } from '@/components/home/ui/markdown'; +import { cn } from "@/lib/utils"; + +import { UnifiedMessage, ParsedContent, ParsedMetadata, ThreadParams } from '@/components/thread/types'; +import { getToolIcon, extractPrimaryParam, safeJsonParse } from '@/components/thread/utils'; + +// Define the set of tags whose raw XML should be hidden during streaming +const HIDE_STREAMING_XML_TAGS = new Set([ + 'execute-command', + 'create-file', + 'delete-file', + 'full-file-rewrite', + 'str-replace', + 'browser-click-element', + 'browser-close-tab', + 'browser-drag-drop', + 'browser-get-dropdown-options', + 'browser-go-back', + 'browser-input-text', + 'browser-navigate-to', + 'browser-scroll-down', + 'browser-scroll-to-text', + 'browser-scroll-up', + 'browser-select-dropdown-option', + 'browser-send-keys', + 'browser-switch-tab', + 'browser-wait', + 'deploy', + 'ask', + 'complete', + 'crawl-webpage', + 'web-search' +]); + +// Extend the base Message type with the expected database fields +interface ApiMessageType extends BaseApiMessageType { + message_id?: string; + thread_id?: string; + is_llm_message?: boolean; + metadata?: string; + created_at?: string; + updated_at?: string; +} + +// Add a simple interface for streaming tool calls +interface StreamingToolCall { + id?: string; + name?: string; + arguments?: string; + index?: number; + xml_tag_name?: string; +} + +// Render Markdown content while preserving XML tags that should be displayed as tool calls +function renderMarkdownContent(content: string, handleToolClick: (assistantMessageId: string | null, toolName: string) => void, messageId: string | null, fileViewerHandler?: (filePath?: string) => void) { + const xmlRegex = /<(?!inform\b)([a-zA-Z\-_]+)(?:\s+[^>]*)?>(?:[\s\S]*?)<\/\1>|<(?!inform\b)([a-zA-Z\-_]+)(?:\s+[^>]*)?\/>/g; + let lastIndex = 0; + const contentParts: React.ReactNode[] = []; + let match; + + // If no XML tags found, just return the full content as markdown + if (!content.match(xmlRegex)) { + return {content}; + } + + while ((match = xmlRegex.exec(content)) !== null) { + // Add text before the tag as markdown + if (match.index > lastIndex) { + const textBeforeTag = content.substring(lastIndex, match.index); + contentParts.push( + {textBeforeTag} + ); + } + + const rawXml = match[0]; + const toolName = match[1] || match[2]; + const IconComponent = getToolIcon(toolName); + const paramDisplay = extractPrimaryParam(toolName, rawXml); + const toolCallKey = `tool-${match.index}`; + + if (toolName === 'ask') { + // Extract attachments from the XML attributes + const attachmentsMatch = rawXml.match(/attachments=["']([^"']*)["']/i); + const attachments = attachmentsMatch + ? attachmentsMatch[1].split(',').map(a => a.trim()) + : []; + + // Extract content from the ask tag + const contentMatch = rawXml.match(/]*>([\s\S]*?)<\/ask>/i); + const askContent = contentMatch ? contentMatch[1] : ''; + + // Render tag content with attachment UI + contentParts.push( +
+ {askContent} + + {attachments.length > 0 && ( +
+
Attachments:
+
+ {attachments.map((attachment, idx) => { + const extension = attachment.split('.').pop()?.toLowerCase(); + const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(extension || ''); + const isPdf = extension === 'pdf'; + const isMd = extension === 'md'; + const isCode = ['js', 'jsx', 'ts', 'tsx', 'py', 'html', 'css', 'json'].includes(extension || ''); + + let icon = ; + if (isImage) icon = ; + if (isPdf) icon = ; + if (isMd) icon = ; + if (isCode) icon = ; + + return ( + + ); + })} +
+
+ )} +
+ ); + } else { + // Render tool button as a clickable element + contentParts.push( + + ); + } + lastIndex = xmlRegex.lastIndex; + } + + // Add text after the last tag + if (lastIndex < content.length) { + contentParts.push( + {content.substring(lastIndex)} + ); + } + + return contentParts; +} + +export default function ThreadPage({ params }: { params: Promise }) { + const unwrappedParams = React.use(params); + const threadId = unwrappedParams.threadId; + + const router = useRouter(); + const [messages, setMessages] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [agentRunId, setAgentRunId] = useState(null); + const [agentStatus, setAgentStatus] = useState<'idle' | 'running' | 'connecting' | 'error'>('idle'); + const [isSidePanelOpen, setIsSidePanelOpen] = useState(true); + const [toolCalls, setToolCalls] = useState([]); + const [currentToolIndex, setCurrentToolIndex] = useState(0); + const [autoOpenedPanel, setAutoOpenedPanel] = useState(false); + + // Playback control states + const [visibleMessages, setVisibleMessages] = useState([]); + const [isPlaying, setIsPlaying] = useState(false); + const [currentMessageIndex, setCurrentMessageIndex] = useState(0); + const [playbackSpeed, setPlaybackSpeed] = useState(0.5); // reduced from 2 to 0.5 seconds between messages + const [toolPlaybackIndex, setToolPlaybackIndex] = useState(-1); + const [streamingText, setStreamingText] = useState(""); + const [isStreamingText, setIsStreamingText] = useState(false); + const [currentToolCall, setCurrentToolCall] = useState(null); + + const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); + const latestMessageRef = useRef(null); + const [showScrollButton, setShowScrollButton] = useState(false); + const [userHasScrolled, setUserHasScrolled] = useState(false); + const hasInitiallyScrolled = useRef(false); + + const [project, setProject] = useState(null); + const [sandboxId, setSandboxId] = useState(null); + const [fileViewerOpen, setFileViewerOpen] = useState(false); + const [projectName, setProjectName] = useState(''); + const [fileToView, setFileToView] = useState(null); + + const initialLoadCompleted = useRef(false); + const messagesLoadedRef = useRef(false); + const agentRunsCheckedRef = useRef(false); + + const handleProjectRenamed = useCallback((newName: string) => { + setProjectName(newName); + }, []); + + const userClosedPanelRef = useRef(false); + + // Initialize as if user already closed panel to prevent auto-opening + useEffect(() => { + userClosedPanelRef.current = true; + }, []); + + // Define togglePlayback and resetPlayback functions explicitly + const togglePlayback = useCallback(() => { + setIsPlaying(prev => !prev); + }, []); + + const resetPlayback = useCallback(() => { + setIsPlaying(false); + setCurrentMessageIndex(0); + setVisibleMessages([]); + setToolPlaybackIndex(-1); + setStreamingText(""); + setIsStreamingText(false); + setCurrentToolCall(null); + }, []); + + const toggleSidePanel = useCallback(() => { + setIsSidePanelOpen(prev => !prev); + }, []); + + const handleSidePanelNavigate = useCallback((newIndex: number) => { + setCurrentToolIndex(newIndex); + console.log(`Tool panel manually set to index ${newIndex}`); + }, []); + + 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': + setAgentStatus('idle'); + setAgentRunId(null); + // Reset auto-opened state when agent completes to trigger tool detection + setAutoOpenedPanel(false); + + // Refetch messages to ensure we have the final state after completion OR stopping + if (hookStatus === 'completed' || hookStatus === 'stopped') { + getMessages(threadId).then(messagesData => { + if (messagesData) { + console.log(`[PAGE] Refetched messages after ${hookStatus}:`, messagesData.length); + // 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); + scrollToBottom('smooth'); + } + }).catch(err => { + console.error(`Error refetching messages after ${hookStatus}:`, err); + }); + } + break; + case 'connecting': + setAgentStatus('connecting'); + break; + case 'streaming': + setAgentStatus('running'); + break; + case 'error': + setAgentStatus('error'); + // Handle errors by going back to idle state after a short delay + setTimeout(() => { + setAgentStatus('idle'); + setAgentRunId(null); + }, 3000); + break; + } + }, [threadId]); + + 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]); + + // Streaming text function + const streamText = useCallback((text: string, onComplete: () => void) => { + if (!text || !isPlaying) { + onComplete(); + return () => {}; + } + + setIsStreamingText(true); + setStreamingText(""); + + // Define regex to find tool calls in text + const toolCallRegex = /<([a-zA-Z\-_]+)(?:\s+[^>]*)?>(?:[\s\S]*?)<\/\1>|<([a-zA-Z\-_]+)(?:\s+[^>]*)?\/>/g; + + // Split text into chunks (handling tool calls as special chunks) + const chunks: { text: string; isTool: boolean; toolName?: string }[] = []; + let lastIndex = 0; + let match; + + while ((match = toolCallRegex.exec(text)) !== null) { + // Add text before the tool call + if (match.index > lastIndex) { + chunks.push({ + text: text.substring(lastIndex, match.index), + isTool: false + }); + } + + // Add the tool call + const toolName = match[1] || match[2]; + chunks.push({ + text: match[0], + isTool: true, + toolName + }); + + lastIndex = toolCallRegex.lastIndex; + } + + // Add any remaining text after the last tool call + if (lastIndex < text.length) { + chunks.push({ + text: text.substring(lastIndex), + isTool: false + }); + } + + let currentIndex = 0; + let chunkIndex = 0; + let currentText = ''; + let isPaused = false; + + const processNextCharacter = () => { + if (!isPlaying || isPaused) { + setTimeout(processNextCharacter, 100); // Check again after a short delay + return; + } + + if (chunkIndex >= chunks.length) { + // All chunks processed, we're done + setIsStreamingText(false); + + // Update visible messages with the complete message + const currentMessage = messages[currentMessageIndex]; + setVisibleMessages(prev => { + const lastMessage = prev[prev.length - 1]; + if (lastMessage?.message_id === currentMessage.message_id) { + // Replace the streaming message with the complete one + return [...prev.slice(0, -1), currentMessage]; + } else { + // Add the complete message + return [...prev, currentMessage]; + } + }); + + onComplete(); + return; + } + + const currentChunk = chunks[chunkIndex]; + + // If this is a tool call chunk and we're at the start of it + if (currentChunk.isTool && currentIndex === 0) { + // For tool calls, check if they should be hidden during streaming + if (currentChunk.toolName && HIDE_STREAMING_XML_TAGS.has(currentChunk.toolName)) { + // Instead of showing the XML, create a tool call object + const toolCall: StreamingToolCall = { + name: currentChunk.toolName, + arguments: currentChunk.text, + xml_tag_name: currentChunk.toolName + }; + + setCurrentToolCall(toolCall); + setIsSidePanelOpen(true); + setCurrentToolIndex(toolPlaybackIndex + 1); + setToolPlaybackIndex(prev => prev + 1); + + // Pause streaming briefly while showing the tool + isPaused = true; + setTimeout(() => { + isPaused = false; + setCurrentToolCall(null); + chunkIndex++; // Move to next chunk + currentIndex = 0; // Reset index for next chunk + processNextCharacter(); + }, 500); // Reduced from 1500ms to 500ms pause for tool display + + return; + } + } + + // Handle normal text streaming for non-tool chunks or visible tool chunks + if (currentIndex < currentChunk.text.length) { + // Dynamically adjust typing speed for a more realistic effect + const baseDelay = 5; // Reduced from 15ms to 5ms + let typingDelay = baseDelay; + + // Add more delay for punctuation to make it feel more natural + const char = currentChunk.text[currentIndex]; + if (".!?,;:".includes(char)) { + typingDelay = baseDelay + Math.random() * 100 + 50; // Reduced from 300+100 to 100+50ms pause after punctuation + } else { + const variableDelay = Math.random() * 5; // Reduced from 15 to 5ms + typingDelay = baseDelay + variableDelay; // 5-10ms for normal typing + } + + // Add the next character + currentText += currentChunk.text[currentIndex]; + setStreamingText(currentText); + currentIndex++; + + // Process next character with dynamic delay + setTimeout(processNextCharacter, typingDelay); + } else { + // Move to the next chunk + chunkIndex++; + currentIndex = 0; + processNextCharacter(); + } + }; + + processNextCharacter(); + + // Return cleanup function + return () => { + setIsStreamingText(false); + setStreamingText(""); + isPaused = true; // Stop processing + }; + }, [isPlaying, messages, currentMessageIndex]); + + // Main playback function + useEffect(() => { + if (!isPlaying || messages.length === 0) return; + + let playbackTimeout: NodeJS.Timeout; + + const playbackNextMessage = async () => { + // Ensure we're within bounds + if (currentMessageIndex >= messages.length) { + setIsPlaying(false); + return; + } + + const currentMessage = messages[currentMessageIndex]; + console.log(`Playing message ${currentMessageIndex}:`, currentMessage.type, currentMessage.message_id); + + // If it's an assistant message, stream it + if (currentMessage.type === 'assistant') { + try { + // Parse the content if it's JSON + let content = currentMessage.content; + try { + const parsed = JSON.parse(content); + if (parsed.content) { + content = parsed.content; + } + } catch (e) { + // Not JSON, use as is + } + + // Stream the message content + await new Promise((resolve) => { + const cleanupFn = streamText(content, resolve); + return cleanupFn; + }); + } catch (error) { + console.error('Error streaming message:', error); + } + } else { + // For non-assistant messages, just add them to visible messages + setVisibleMessages(prev => [...prev, currentMessage]); + + // Wait a moment before showing the next message + await new Promise(resolve => setTimeout(resolve, 500)); + } + + // Move to the next message + setCurrentMessageIndex(prevIndex => prevIndex + 1); + }; + + // Start playback with a small delay + playbackTimeout = setTimeout(playbackNextMessage, 500); + + return () => { + clearTimeout(playbackTimeout); + }; + }, [isPlaying, currentMessageIndex, messages, streamText]); + + 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'); + + // Fetch the thread to check if it's public + const threadData = await getThread(threadId).catch(err => { + // If it fails with 404, we'll catch it here + throw new Error('Failed to load thread data: ' + err.message); + }); + + if (!isMounted) return; + + // Make sure the thread is public + if (!(threadData as any).is_public) { + throw new Error('This thread is not available for public viewing.'); + } + + if (threadData?.project_id) { + const projectData = await getProject(threadData.project_id); + if (isMounted && projectData) { + console.log('[SHARE] Project data loaded:', projectData); + + // 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 || ''); + } + } + + // Fetch all messages for the thread + const messagesData = await getMessages(threadId); + if (isMounted) { + // Log raw messages fetched from API + console.log('[SHARE] Raw messages fetched:', messagesData); + + // 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); + + // Calculate historical tool pairs + const historicalToolPairs: ToolCallInput[] = []; + const assistantMessages = unifiedMessages.filter(m => m.type === 'assistant' && m.message_id); + + console.log('Building tool calls from', assistantMessages.length, 'assistant messages'); + + // Map to track which assistant messages have tool results + const assistantToolMap = new Map(); + + // First build a map of assistant message IDs to tool messages + unifiedMessages.forEach(msg => { + if (msg.type === 'tool' && msg.metadata) { + try { + const metadata = JSON.parse(msg.metadata); + if (metadata.assistant_message_id) { + assistantToolMap.set(metadata.assistant_message_id, msg); + } + } catch (e) { + // Ignore parsing errors + } + } + }); + + console.log('Found', assistantToolMap.size, 'tool messages with assistant IDs'); + + // Now process each assistant message + assistantMessages.forEach((assistantMsg, index) => { + // Get message ID + const messageId = assistantMsg.message_id; + if (!messageId) return; + + console.log(`Processing assistant message ${index}:`, messageId); + + // Find corresponding tool message + const toolMessage = assistantToolMap.get(messageId); + + // Check for tool calls in the assistant message content + let assistantContent: any; + try { + assistantContent = JSON.parse(assistantMsg.content); + } catch (e) { + assistantContent = { content: assistantMsg.content }; + } + + const assistantMessageText = assistantContent.content || assistantMsg.content; + + // Use a regex to find tool calls in the message content + const toolCalls = extractToolCallsFromMessage(assistantMessageText); + console.log(`Found ${toolCalls.length} tool calls in message ${messageId}`); + + if (toolCalls.length > 0 && toolMessage) { + // For each tool call in the message, create a pair + toolCalls.forEach((toolCall, callIndex) => { + console.log(`Adding tool call ${callIndex}:`, toolCall.name, 'for message', messageId); + + let toolContent: any; + try { + toolContent = JSON.parse(toolMessage.content); + } catch (e) { + toolContent = { content: toolMessage.content }; + } + + historicalToolPairs.push({ + assistantCall: { + name: toolCall.name, + content: `${toolCall.fullMatch}`, + timestamp: assistantMsg.created_at + }, + toolResult: { + content: toolContent.content || toolMessage.content, + isSuccess: true, + timestamp: toolMessage.created_at + } + }); + }); + } + }); + + // Sort the tool calls chronologically by timestamp + historicalToolPairs.sort((a, b) => { + const timeA = new Date(a.assistantCall.timestamp || '').getTime(); + const timeB = new Date(b.assistantCall.timestamp || '').getTime(); + return timeA - timeB; + }); + + console.log('Created', historicalToolPairs.length, 'total tool calls'); + + setToolCalls(historicalToolPairs); + + // When loading is complete, prepare for playback + 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 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 }); + }; + + // Auto-scroll when new messages appear during playback + useEffect(() => { + if (visibleMessages.length > 0 && !userHasScrolled) { + scrollToBottom('smooth'); + } + }, [visibleMessages, userHasScrolled]); + + // Scroll button visibility + useEffect(() => { + if (!latestMessageRef.current || visibleMessages.length === 0) return; + const observer = new IntersectionObserver( + ([entry]) => setShowScrollButton(!entry?.isIntersecting), + { root: messagesContainerRef.current, threshold: 0.1 } + ); + observer.observe(latestMessageRef.current); + return () => observer.disconnect(); + }, [visibleMessages, streamingText, currentToolCall]); + + const handleScrollButtonClick = () => { + scrollToBottom('smooth'); + setUserHasScrolled(false); + }; + + useEffect(() => { + console.log(`[PAGE] 🔄 Page AgentStatus: ${agentStatus}, Hook Status: ${streamHookStatus}, Target RunID: ${agentRunId || 'none'}, Hook RunID: ${currentHookRunId || 'none'}`); + + // If the stream hook reports completion/stopping but our UI hasn't updated + if ((streamHookStatus === 'completed' || streamHookStatus === 'stopped' || + streamHookStatus === 'agent_not_running' || streamHookStatus === 'error') && + (agentStatus === 'running' || agentStatus === 'connecting')) { + console.log('[PAGE] Detected hook completed but UI still shows running, updating status'); + setAgentStatus('idle'); + setAgentRunId(null); + setAutoOpenedPanel(false); + } + }, [agentStatus, streamHookStatus, agentRunId, currentHookRunId]); + + const handleOpenFileViewer = useCallback((filePath?: string) => { + if (filePath) { + setFileToView(filePath); + } else { + setFileToView(null); + } + setFileViewerOpen(true); + }, []); + + // Automatically detect and populate tool calls from messages + useEffect(() => { + // Calculate historical tool pairs regardless of panel state + const historicalToolPairs: ToolCallInput[] = []; + const assistantMessages = messages.filter(m => m.type === 'assistant' && m.message_id); + + assistantMessages.forEach(assistantMsg => { + const resultMessage = messages.find(toolMsg => { + if (toolMsg.type !== 'tool' || !toolMsg.metadata || !assistantMsg.message_id) return false; + try { + const metadata = JSON.parse(toolMsg.metadata); + return metadata.assistant_message_id === assistantMsg.message_id; + } catch (e) { + return false; + } + }); + + if (resultMessage) { + // Determine tool name from assistant message content + let toolName = 'unknown'; + try { + // Try to extract tool name from content + const xmlMatch = assistantMsg.content.match(/<([a-zA-Z\-_]+)(?:\s+[^>]*)?>|<([a-zA-Z\-_]+)(?:\s+[^>]*)?\/>/); + if (xmlMatch) { + toolName = xmlMatch[1] || xmlMatch[2] || 'unknown'; + } else { + // Fallback to checking for tool_calls JSON structure + const assistantContentParsed = safeJsonParse<{ tool_calls?: { name: string }[] }>(assistantMsg.content, {}); + if (assistantContentParsed.tool_calls && assistantContentParsed.tool_calls.length > 0) { + toolName = assistantContentParsed.tool_calls[0].name || 'unknown'; + } + } + } catch {} + + // Skip adding tags to the tool calls + if (toolName === 'ask') { + 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) => { + if (!assistantContent) return null; + + return ( +
+
Assistant Message
+
+ {assistantContent} +
+
+ ); + }, []); + + // Process the tool result data + const toolViewResult = useCallback((toolContent?: string, isSuccess?: boolean) => { + if (!toolContent) return null; + + return ( +
+
+
Tool Result
+
+ {isSuccess ? 'Success' : 'Failed'} +
+
+
+ {toolContent} +
+
+ ); + }, []); + + // Update handleToolClick to respect user closing preference and navigate correctly + const handleToolClick = useCallback((clickedAssistantMessageId: string | null, clickedToolName: string) => { + // Explicitly ignore ask tags from opening the side panel + if (clickedToolName === 'ask') { + return; + } + + if (!clickedAssistantMessageId) { + console.warn("Clicked assistant message ID is null. Cannot open side panel."); + toast.warning("Cannot view details: Assistant message ID is missing."); + return; + } + + // Reset user closed state when explicitly clicking a tool + userClosedPanelRef.current = false; + + console.log("[PAGE] Tool Click Triggered. Assistant Message ID:", clickedAssistantMessageId, "Tool Name:", clickedToolName); + + // Find the index of the tool call associated with the clicked assistant message + const toolIndex = toolCalls.findIndex(tc => { + // Find the original assistant message based on the ID + const assistantMessage = messages.find(m => m.message_id === clickedAssistantMessageId && m.type === 'assistant'); + if (!assistantMessage) return false; + + // Find the corresponding tool message using metadata + const toolMessage = messages.find(m => { + if (m.type !== 'tool' || !m.metadata) return false; + try { + const metadata = safeJsonParse(m.metadata, {}); + return metadata.assistant_message_id === assistantMessage.message_id; + } catch { + return false; + } + }); + + // Check if the current toolCall 'tc' corresponds to this assistant/tool message pair + return tc.assistantCall?.content === assistantMessage.content && + tc.toolResult?.content === toolMessage?.content; + }); + + if (toolIndex !== -1) { + console.log(`[PAGE] Found tool call at index ${toolIndex} for assistant message ${clickedAssistantMessageId}`); + setCurrentToolIndex(toolIndex); + setIsSidePanelOpen(true); // Explicitly open the panel + } else { + console.warn(`[PAGE] Could not find matching tool call in toolCalls array for assistant message ID: ${clickedAssistantMessageId}`); + toast.info("Could not find details for this tool call."); + // Optionally, still open the panel but maybe at the last index or show a message? + // setIsSidePanelOpen(true); + } + }, [messages, toolCalls]); // Add toolCalls as a dependency + + // Handle streaming tool calls + const handleStreamingToolCall = useCallback((toolCall: StreamingToolCall | null) => { + if (!toolCall) return; + + const toolName = toolCall.name || toolCall.xml_tag_name || 'Unknown Tool'; + + // Skip tags from showing in the side panel during streaming + if (toolName === 'ask') { + return; + } + + console.log("[STREAM] Received tool call:", toolName); + + // If user explicitly closed the panel, don't reopen it for streaming calls + if (userClosedPanelRef.current) return; + + // Create a properly formatted tool call input for the streaming tool + // that matches the format of historical tool calls + const toolArguments = toolCall.arguments || ''; + + // Format the arguments in a way that matches the expected XML format for each tool + // This ensures the specialized tool views render correctly + let formattedContent = toolArguments; + if (toolName.toLowerCase().includes('command') && !toolArguments.includes('')) { + formattedContent = `${toolArguments}`; + } else if (toolName.toLowerCase().includes('file') && !toolArguments.includes('')) { + // For file operations, wrap with appropriate tag if not already wrapped + const fileOpTags = ['create-file', 'delete-file', 'full-file-rewrite']; + const matchingTag = fileOpTags.find(tag => toolName.toLowerCase().includes(tag)); + if (matchingTag && !toolArguments.includes(`<${matchingTag}>`)) { + formattedContent = `<${matchingTag}>${toolArguments}`; + } + } + + const newToolCall: ToolCallInput = { + assistantCall: { + name: toolName, + content: formattedContent, + timestamp: new Date().toISOString() + }, + // For streaming tool calls, provide empty content that indicates streaming + toolResult: { + content: "STREAMING", + isSuccess: true, + timestamp: new Date().toISOString() + } + }; + + // Update the tool calls state to reflect the streaming tool + setToolCalls(prev => { + // If the same tool is already being streamed, update it instead of adding a new one + if (prev.length > 0 && prev[0].assistantCall.name === toolName) { + return [{ + ...prev[0], + assistantCall: { + ...prev[0].assistantCall, + content: formattedContent + } + }]; + } + return [newToolCall]; + }); + + setCurrentToolIndex(0); + setIsSidePanelOpen(true); + }, []); + + // SEO title update + useEffect(() => { + if (projectName) { + document.title = `${projectName} | Shared Thread`; + + const metaDescription = document.querySelector('meta[name="description"]'); + if (metaDescription) { + metaDescription.setAttribute('content', `${projectName} - Public AI conversation shared from Kortix Manus`); + } + + const ogTitle = document.querySelector('meta[property="og:title"]'); + if (ogTitle) { + ogTitle.setAttribute('content', `${projectName} | Shared AI Conversation`); + } + + const ogDescription = document.querySelector('meta[property="og:description"]'); + if (ogDescription) { + ogDescription.setAttribute('content', `Public AI conversation of ${projectName}`); + } + } + }, [projectName]); + + useEffect(() => { + if (streamingTextContent && streamHookStatus === 'streaming' && messages.length > 0) { + // Find the last assistant message to update with streaming content + const lastAssistantIndex = messages.findIndex(m => + m.type === 'assistant' && m.message_id === messages[currentMessageIndex]?.message_id); + + if (lastAssistantIndex >= 0) { + const assistantMessage = {...messages[lastAssistantIndex]}; + assistantMessage.content = streamingTextContent; + + // Update the message in the messages array + const updatedMessages = [...messages]; + updatedMessages[lastAssistantIndex] = assistantMessage; + + // Only show the streaming message if we're not already streaming and we're in play mode + if (!isStreamingText && isPlaying) { + const cleanup = streamText(streamingTextContent, () => { + // When streaming completes, update the visible messages + setVisibleMessages(prev => { + const messageExists = prev.some(m => m.message_id === assistantMessage.message_id); + if (messageExists) { + // Replace the existing message + return prev.map(m => m.message_id === assistantMessage.message_id ? assistantMessage : m); + } else { + // Add as a new message + return [...prev, assistantMessage]; + } + }); + }); + + return cleanup; + } + } + } + }, [streamingTextContent, streamHookStatus, messages, isStreamingText, isPlaying, currentMessageIndex, streamText]); + + // Create a message-to-tool-index map for faster lookups + const [messageToToolIndex, setMessageToToolIndex] = useState>({}); + + // Build the message-to-tool-index map when tool calls change + useEffect(() => { + if (!toolCalls.length) return; + + const mapBuilder: Record = {}; + + toolCalls.forEach((tool, index) => { + const content = tool.assistantCall?.content || ''; + const match = content.match(//); + if (match && match[1]) { + mapBuilder[match[1]] = index; + console.log(`Mapped message ID ${match[1]} to tool index ${index}`); + } + }); + + setMessageToToolIndex(mapBuilder); + }, [toolCalls]); + + // Very direct approach to update the tool index during message playback + useEffect(() => { + if (!isPlaying || currentMessageIndex <= 0 || !messages.length) return; + + // Check if current message is a tool message + const currentMsg = messages[currentMessageIndex - 1]; // Look at previous message that just played + + if (currentMsg?.type === 'tool' && currentMsg.metadata) { + try { + const metadata = safeJsonParse(currentMsg.metadata, {}); + const assistantId = metadata.assistant_message_id; + + if (assistantId && messageToToolIndex[assistantId] !== undefined) { + const toolIndex = messageToToolIndex[assistantId]; + console.log(`Direct mapping: Setting tool index to ${toolIndex} for message ${assistantId}`); + setCurrentToolIndex(toolIndex); + } + } catch (e) { + console.error('Error in direct tool mapping:', e); + } + } + }, [currentMessageIndex, isPlaying, messages, messageToToolIndex]); + + // Add a helper function to extract tool calls from message content + const extractToolCallsFromMessage = (content: string) => { + const toolCallRegex = /<([a-zA-Z\-_]+)(?:\s+[^>]*)?>(?:[\s\S]*?)<\/\1>|<([a-zA-Z\-_]+)(?:\s+[^>]*)?\/>/g; + const results = []; + let match; + + while ((match = toolCallRegex.exec(content)) !== null) { + const toolName = match[1] || match[2]; + results.push({ + name: toolName, + fullMatch: match[0] + }); + } + + return results; + }; + + // Force an explicit update to the tool panel based on the current message index + useEffect(() => { + // Skip if not playing or no messages + if (!isPlaying || messages.length === 0 || currentMessageIndex <= 0) return; + + // Get all messages up to the current index + const currentMessages = messages.slice(0, currentMessageIndex); + + // Find the most recent tool message to determine which panel to show + for (let i = currentMessages.length - 1; i >= 0; i--) { + const msg = currentMessages[i]; + if (msg.type === 'tool' && msg.metadata) { + try { + const metadata = safeJsonParse(msg.metadata, {}); + const assistantId = metadata.assistant_message_id; + + if (assistantId) { + console.log(`Looking for tool panel for assistant message ${assistantId}`); + + // Scan for matching tool call + for (let j = 0; j < toolCalls.length; j++) { + const content = toolCalls[j].assistantCall?.content || ''; + if (content.includes(assistantId)) { + console.log(`Found matching tool call at index ${j}, updating panel`); + setCurrentToolIndex(j); + return; + } + } + } + } catch (e) { + console.error('Error parsing tool message metadata:', e); + } + } + } + }, [currentMessageIndex, isPlaying, messages, toolCalls]); + + // Add a special button to each tool call to show its debug info + // This replaces the existing ToolCallSidePanel component with a wrapper that adds debug info + const ToolCallPanelWithDebugInfo = React.useMemo(() => { + const WrappedPanel = (props: any) => { + const { isOpen, onClose, toolCalls, currentIndex, onNavigate, ...rest } = props; + + // Add a function to show debug info for the current tool call + const showDebugInfo = useCallback(() => { + if (toolCalls && toolCalls.length > 0 && currentIndex >= 0 && currentIndex < toolCalls.length) { + const tool = toolCalls[currentIndex]; + console.log('Current tool call debug info:', { + name: tool.assistantCall?.name, + content: tool.assistantCall?.content, + messageIdMatches: tool.assistantCall?.content?.match(//), + toolResult: tool.toolResult + }); + } + }, [toolCalls, currentIndex]); + + return ( +
+ + + {/* Add debug button */} + {isOpen && toolCalls && toolCalls.length > 0 && ( +
+ +
+ )} +
+ ); + }; + + return WrappedPanel; + }, []); + + if (isLoading && !initialLoadCompleted.current) { + return ( +
+
+ {/* Skeleton Header */} +
+
+
+
+ + +
+
+
+ + +
+
+
+ + {/* Skeleton Chat Messages */} +
+
+ {/* User message */} +
+
+
+ + +
+
+
+ + {/* Assistant response with tool usage */} +
+
+ +
+
+
+
+ + + +
+ + {/* Tool call button skeleton */} +
+ +
+ +
+ + +
+
+
+
+
+
+
+
+
+ + {/* Skeleton Side Panel (closed state) */} +
+
+
+ + + +
+
+
+
+ ); + } + + if (error) { + return ( +
+
+
+
+
+ Shared Conversation +
+
+
+
+
+

Error

+

{error}

+ +
+
+
+
+ ); + } + + return ( +
+
+ {/* Header with playback controls */} +
+
+
+
+
+ Kortix +
+ {projectName || 'Shared Conversation'} +
+
+
+ + + +
+
+
+
+
+ {visibleMessages.length === 0 && !streamingText && !currentToolCall ? ( +
+
+
+ +
+

Watch this conversation

+

+ This is a shared view-only conversation. Click play to replay the entire conversation with realistic timing. +

+ +
+
+ ) : ( +
+ {(() => { + // Group messages logic + type MessageGroup = { + type: 'user' | 'assistant_group'; + messages: UnifiedMessage[]; + key: string; + }; + const groupedMessages: MessageGroup[] = []; + let currentGroup: MessageGroup | null = null; + + visibleMessages.forEach((message, index) => { + const messageType = message.type; + const key = message.message_id || `msg-${index}`; + + if (messageType === 'user') { + if (currentGroup) { + groupedMessages.push(currentGroup); + } + groupedMessages.push({ type: 'user', messages: [message], key }); + currentGroup = null; + } else if (messageType === 'assistant' || messageType === 'tool' || messageType === 'browser_state') { + if (currentGroup && currentGroup.type === 'assistant_group') { + currentGroup.messages.push(message); + } else { + if (currentGroup) { + groupedMessages.push(currentGroup); + } + currentGroup = { type: 'assistant_group', messages: [message], key }; + } + } else if (messageType !== 'status') { + if (currentGroup) { + groupedMessages.push(currentGroup); + currentGroup = null; + } + } + }); + + if (currentGroup) { + groupedMessages.push(currentGroup); + } + + return groupedMessages.map((group, groupIndex) => { + if (group.type === 'user') { + const message = group.messages[0]; + const messageContent = (() => { + try { + const parsed = safeJsonParse(message.content, { content: message.content }); + return parsed.content || message.content; + } catch { + return message.content; + } + })(); + + return ( +
+
+ {messageContent} +
+
+ ); + } else if (group.type === 'assistant_group') { + return ( +
+
+
+ Suna +
+
+
+
+ {(() => { + const toolResultsMap = new Map(); + group.messages.forEach(msg => { + if (msg.type === 'tool') { + const meta = safeJsonParse(msg.metadata, {}); + const assistantId = meta.assistant_message_id || null; + if (!toolResultsMap.has(assistantId)) { + toolResultsMap.set(assistantId, []); + } + toolResultsMap.get(assistantId)?.push(msg); + } + }); + + const renderedToolResultIds = new Set(); + const elements: React.ReactNode[] = []; + + group.messages.forEach((message, msgIndex) => { + if (message.type === 'assistant') { + const parsedContent = safeJsonParse(message.content, {}); + const msgKey = message.message_id || `submsg-assistant-${msgIndex}`; + + if (!parsedContent.content) return; + + const renderedContent = renderMarkdownContent( + parsedContent.content, + handleToolClick, + message.message_id, + handleOpenFileViewer + ); + + elements.push( +
0 ? "mt-2" : ""}> +
+ {renderedContent} +
+
+ ); + } + }); + + return elements; + })()} + + {groupIndex === groupedMessages.length - 1 && isStreamingText && ( +
+ {(() => { + let detectedTag: string | null = null; + let tagStartIndex = -1; + if (streamingText) { + for (const tag of HIDE_STREAMING_XML_TAGS) { + const openingTagPattern = `<${tag}`; + const index = streamingText.indexOf(openingTagPattern); + if (index !== -1) { + detectedTag = tag; + tagStartIndex = index; + break; + } + } + } + + const textToRender = streamingText || ''; + const textBeforeTag = detectedTag ? textToRender.substring(0, tagStartIndex) : textToRender; + const showCursor = isStreamingText && !detectedTag; + + return ( + <> + {textBeforeTag && ( + {textBeforeTag} + )} + {showCursor && ( + + )} + + {detectedTag && ( +
+ +
+ )} + + ); + })()} +
+ )} +
+
+
+
+
+ ); + } + return null; + }); + })()} + + {/* Show tool call animation if active */} + {currentToolCall && ( +
+
+
+ Suna +
+
+
+ + + {currentToolCall.name || 'Using Tool'} + +
+
+
+
+ )} + + {/* Show streaming indicator if no messages yet */} + {visibleMessages.length === 0 && isStreamingText && ( +
+
+
+ Suna +
+
+
+
+
+
+
+
+
+
+
+
+ )} +
+ )} +
+
+
+ + {/* Floating playback controls - moved to be centered in the chat area when side panel is open */} + {messages.length > 0 && ( +
+
+ + +
+ {Math.min(currentMessageIndex + (isStreamingText ? 0 : 1), messages.length)}/{messages.length} +
+ + +
+
+ )} +
+ + {/* Scroll to bottom button */} + {showScrollButton && ( + + )} + + {/* Tool calls side panel - Replace with debug-enabled version */} + setIsSidePanelOpen(false)} + toolCalls={toolCalls} + messages={messages as ApiMessageType[]} + agentStatus="idle" + currentIndex={currentToolIndex} + onNavigate={handleSidePanelNavigate} + project={project} + renderAssistantMessage={toolViewAssistant} + renderToolResult={toolViewResult} + /> + + {sandboxId && ( + + )} +
+ ); +} diff --git a/frontend/src/components/team/create-team-dialog.tsx b/frontend/src/components/basejump/create-team-dialog.tsx similarity index 100% rename from frontend/src/components/team/create-team-dialog.tsx rename to frontend/src/components/basejump/create-team-dialog.tsx diff --git a/frontend/src/components/dashboard/sidebar/calendars.tsx b/frontend/src/components/dashboard/sidebar/calendars.tsx deleted file mode 100644 index cf6469fd..00000000 --- a/frontend/src/components/dashboard/sidebar/calendars.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import * as React from "react" -import { Check, ChevronRight } from "lucide-react" - -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible" -import { - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarSeparator, -} from "@/components/ui/sidebar" - -export function Calendars({ - calendars, -}: { - calendars: { - name: string - items: string[] - }[] -}) { - return ( - <> - {calendars.map((calendar, index) => ( - - - - - - {calendar.name}{" "} - - - - - - - {calendar.items.map((item, index) => ( - - -
- -
- {item} -
-
- ))} -
-
-
-
-
- -
- ))} - - ) -} diff --git a/frontend/src/components/dashboard/sidebar/cta.tsx b/frontend/src/components/sidebar/cta.tsx similarity index 93% rename from frontend/src/components/dashboard/sidebar/cta.tsx rename to frontend/src/components/sidebar/cta.tsx index dcebdbb7..56eb959a 100644 --- a/frontend/src/components/dashboard/sidebar/cta.tsx +++ b/frontend/src/components/sidebar/cta.tsx @@ -1,7 +1,7 @@ import { Button } from "@/components/ui/button" import Link from "next/link" import { Briefcase, ExternalLink } from "lucide-react" -import { KortixProcessModal } from "@/components/dashboard/sidebar/kortix-enterprise-modal" +import { KortixProcessModal } from "@/components/sidebar/kortix-enterprise-modal" export function CTACard() { return ( diff --git a/frontend/src/components/dashboard/sidebar/date-picker.tsx b/frontend/src/components/sidebar/date-picker.tsx similarity index 100% rename from frontend/src/components/dashboard/sidebar/date-picker.tsx rename to frontend/src/components/sidebar/date-picker.tsx diff --git a/frontend/src/components/dashboard/sidebar/kortix-enterprise-modal.tsx b/frontend/src/components/sidebar/kortix-enterprise-modal.tsx similarity index 95% rename from frontend/src/components/dashboard/sidebar/kortix-enterprise-modal.tsx rename to frontend/src/components/sidebar/kortix-enterprise-modal.tsx index 890b06ba..6bc5d045 100644 --- a/frontend/src/components/dashboard/sidebar/kortix-enterprise-modal.tsx +++ b/frontend/src/components/sidebar/kortix-enterprise-modal.tsx @@ -39,15 +39,15 @@ export function KortixProcessModal() { Custom AI Employees for your Business.
{/* Info Panel */} -
+
-
+
Kortix Logo
@@ -117,7 +117,8 @@ export function KortixProcessModal() {
- + +
diff --git a/frontend/src/components/dashboard/sidebar/kortix-logo.tsx b/frontend/src/components/sidebar/kortix-logo.tsx similarity index 100% rename from frontend/src/components/dashboard/sidebar/kortix-logo.tsx rename to frontend/src/components/sidebar/kortix-logo.tsx diff --git a/frontend/src/components/dashboard/sidebar/nav-agents.tsx b/frontend/src/components/sidebar/nav-agents.tsx similarity index 63% rename from frontend/src/components/dashboard/sidebar/nav-agents.tsx rename to frontend/src/components/sidebar/nav-agents.tsx index 14ca78ec..b4eb6d19 100644 --- a/frontend/src/components/dashboard/sidebar/nav-agents.tsx +++ b/frontend/src/components/sidebar/nav-agents.tsx @@ -34,87 +34,85 @@ import { TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" -import { getProjects, getThreads } from "@/lib/api" +import { getProjects, getThreads, clearApiCache, Project, Thread } from "@/lib/api" import Link from "next/link" -// Define a type to handle potential database schema/API response differences -type ProjectResponse = { - id: string; - project_id?: string; - name: string; - updated_at?: string; - [key: string]: any; // Allow other properties -} - -// Agent type with project ID for easier updating -type Agent = { - projectId: string; +// Thread with associated project info for display in sidebar +type ThreadWithProject = { threadId: string; - name: string; + projectId: string; + projectName: string; url: string; - updatedAt: string; // Store updated_at for consistent sorting + updatedAt: string; } export function NavAgents() { const { isMobile, state } = useSidebar() - const [agents, setAgents] = useState([]) + const [threads, setThreads] = useState([]) const [isLoading, setIsLoading] = useState(true) - const [loadingAgentId, setLoadingAgentId] = useState(null) + const [loadingThreadId, setLoadingThreadId] = useState(null) const pathname = usePathname() const router = useRouter() - // Helper to sort agents by updated_at (most recent first) - const sortAgents = (agentsList: Agent[]): Agent[] => { - return [...agentsList].sort((a, b) => { + // Helper to sort threads by updated_at (most recent first) + const sortThreads = (threadsList: ThreadWithProject[]): ThreadWithProject[] => { + return [...threadsList].sort((a, b) => { return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); }); }; - // Function to load agents data - const loadAgents = async (showLoading = true) => { + // Function to load threads data with associated projects + const loadThreadsWithProjects = async (showLoading = true) => { try { if (showLoading) { setIsLoading(true) } // Get all projects - const projectsData = await getProjects() as ProjectResponse[] + const projects = await getProjects() as Project[] + console.log("Projects loaded:", projects.length, projects.map(p => ({ id: p.id, name: p.name }))); + + // Create a map of projects by ID for faster lookups + const projectsById = new Map(); + projects.forEach(project => { + projectsById.set(project.id, project); + }); // Get all threads at once const allThreads = await getThreads() + console.log("Threads loaded:", allThreads.length, allThreads.map(t => ({ thread_id: t.thread_id, project_id: t.project_id }))); - // For each project, find its matching threads - const agentsList: Agent[] = [] - for (const project of projectsData) { - // Get the project ID (handle potential different field names) - const projectId = project.id || project.project_id || '' + // Create display objects for threads with their project info + const threadsWithProjects: ThreadWithProject[] = []; + + for (const thread of allThreads) { + const projectId = thread.project_id; + // Skip threads without a project ID + if (!projectId) continue; - // Get the updated_at timestamp (default to current time if not available) - const updatedAt = project.updated_at || new Date().toISOString() - - // Match threads that belong to this project - const projectThreads = allThreads.filter(thread => - thread.project_id === projectId - ) - - if (projectThreads.length > 0) { - // For each thread in this project, create an agent entry - for (const thread of projectThreads) { - agentsList.push({ - projectId, - threadId: thread.thread_id, - name: project.name || 'Unnamed Project', - url: `/dashboard/agents/${thread.thread_id}`, - updatedAt: thread.updated_at || updatedAt // Use thread update time if available - }) - } + // Get the associated project + const project = projectsById.get(projectId); + if (!project) { + console.log(`❌ Thread ${thread.thread_id} has project_id=${projectId} but no matching project found`); + continue; } + + console.log(`✅ Thread ${thread.thread_id} matched with project "${project.name}" (${projectId})`); + + // Add to our list + threadsWithProjects.push({ + threadId: thread.thread_id, + projectId: projectId, + projectName: project.name || 'Unnamed Project', + url: `/dashboard/agents/${thread.thread_id}`, + updatedAt: thread.updated_at || project.updated_at || new Date().toISOString() + }); } - // Set agents, ensuring consistent sort order - setAgents(sortAgents(agentsList)) + // Set threads, ensuring consistent sort order + setThreads(sortThreads(threadsWithProjects)) } catch (err) { - console.error("Error loading agents for sidebar:", err) + console.error("Error loading threads with projects:", err) } finally { if (showLoading) { setIsLoading(false) @@ -122,10 +120,10 @@ export function NavAgents() { } } - // Load agents dynamically from the API on initial load + // Load threads dynamically from the API on initial load useEffect(() => { - loadAgents(true) - }, []) + loadThreadsWithProjects(true); + }, []); // Listen for project-updated events to update the sidebar without full reload useEffect(() => { @@ -134,25 +132,23 @@ export function NavAgents() { if (customEvent.detail) { const { projectId, updatedData } = customEvent.detail; - // Update just the name for the agents with the matching project ID - // Don't update the timestamp here to prevent immediate re-sorting - setAgents(prevAgents => { - const updatedAgents = prevAgents.map(agent => - agent.projectId === projectId + // Update just the name for the threads with the matching project ID + setThreads(prevThreads => { + const updatedThreads = prevThreads.map(thread => + thread.projectId === projectId ? { - ...agent, - name: updatedData.name, - // Keep the original updatedAt timestamp locally + ...thread, + projectName: updatedData.name, } - : agent + : thread ); - // Return the agents without re-sorting immediately - return updatedAgents; + // Return the threads without re-sorting immediately + return updatedThreads; }); // Silently refresh in background to fetch updated timestamp and re-sort - setTimeout(() => loadAgents(false), 1000); + setTimeout(() => loadThreadsWithProjects(false), 1000); } } @@ -167,13 +163,13 @@ export function NavAgents() { // Reset loading state when navigation completes (pathname changes) useEffect(() => { - setLoadingAgentId(null) + setLoadingThreadId(null) }, [pathname]) - // Function to handle agent click with loading state - const handleAgentClick = (e: React.MouseEvent, threadId: string, url: string) => { + // Function to handle thread click with loading state + const handleThreadClick = (e: React.MouseEvent, threadId: string, url: string) => { e.preventDefault() - setLoadingAgentId(threadId) + setLoadingThreadId(threadId) router.push(url) } @@ -224,41 +220,41 @@ export function NavAgents() { )) - ) : agents.length > 0 ? ( - // Show all agents + ) : threads.length > 0 ? ( + // Show all threads with project info <> - {agents.map((agent, index) => { - // Check if this agent is currently active - const isActive = pathname.includes(agent.threadId); - const isAgentLoading = loadingAgentId === agent.threadId; + {threads.map((thread) => { + // Check if this thread is currently active + const isActive = pathname?.includes(thread.threadId) || false; + const isThreadLoading = loadingThreadId === thread.threadId; return ( - + {state === "collapsed" ? ( - handleAgentClick(e, agent.threadId, agent.url)}> - {isAgentLoading ? ( + handleThreadClick(e, thread.threadId, thread.url)}> + {isThreadLoading ? ( ) : ( )} - {agent.name} + {thread.projectName} - {agent.name} + {thread.projectName} ) : ( - handleAgentClick(e, agent.threadId, agent.url)}> - {isAgentLoading ? ( + handleThreadClick(e, thread.threadId, thread.url)}> + {isThreadLoading ? ( ) : ( )} - {agent.name} + {thread.projectName} )} @@ -276,14 +272,14 @@ export function NavAgents() { align={isMobile ? "end" : "start"} > { - navigator.clipboard.writeText(window.location.origin + agent.url) + navigator.clipboard.writeText(window.location.origin + thread.url) toast.success("Link copied to clipboard") }}> Copy Link - + Open in New Tab diff --git a/frontend/src/components/dashboard/sidebar/nav-main.tsx b/frontend/src/components/sidebar/nav-main.tsx similarity index 100% rename from frontend/src/components/dashboard/sidebar/nav-main.tsx rename to frontend/src/components/sidebar/nav-main.tsx diff --git a/frontend/src/components/dashboard/sidebar/nav-user-with-teams.tsx b/frontend/src/components/sidebar/nav-user-with-teams.tsx similarity index 100% rename from frontend/src/components/dashboard/sidebar/nav-user-with-teams.tsx rename to frontend/src/components/sidebar/nav-user-with-teams.tsx diff --git a/frontend/src/components/dashboard/sidebar/sidebar-left.tsx b/frontend/src/components/sidebar/sidebar-left.tsx similarity index 92% rename from frontend/src/components/dashboard/sidebar/sidebar-left.tsx rename to frontend/src/components/sidebar/sidebar-left.tsx index e55b5a82..9944c963 100644 --- a/frontend/src/components/dashboard/sidebar/sidebar-left.tsx +++ b/frontend/src/components/sidebar/sidebar-left.tsx @@ -3,10 +3,10 @@ import * as React from "react" import Link from "next/link" -import { NavAgents } from "@/components/dashboard/sidebar/nav-agents" -import { NavUserWithTeams } from "@/components/dashboard/sidebar/nav-user-with-teams" -import { KortixLogo } from "@/components/dashboard/sidebar/kortix-logo" -import { CTACard } from "@/components/dashboard/sidebar/cta" +import { NavAgents } from "@/components/sidebar/nav-agents" +import { NavUserWithTeams } from "@/components/sidebar/nav-user-with-teams" +import { KortixLogo } from "@/components/sidebar/kortix-logo" +import { CTACard } from "@/components/sidebar/cta" import { Sidebar, SidebarContent, diff --git a/frontend/src/components/thread/thread-site-header.tsx b/frontend/src/components/thread/thread-site-header.tsx index 1496608e..4ac1a4db 100644 --- a/frontend/src/components/thread/thread-site-header.tsx +++ b/frontend/src/components/thread/thread-site-header.tsx @@ -66,6 +66,13 @@ export function SiteHeader({ if (editName !== projectName) { try { + if (!projectId) { + toast.error("Cannot rename: Project ID is missing") + setEditName(projectName) + setIsEditing(false) + return + } + const updatedProject = await updateProject(projectId, { name: editName }) if (updatedProject) { onProjectRenamed?.(editName) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1345a186..5f192993 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -16,12 +16,29 @@ const apiCache = { getThreads: (projectId: string) => apiCache.threads.get(projectId || 'all'), setThreads: (projectId: string, data: any) => apiCache.threads.set(projectId || 'all', data), + invalidateThreads: (projectId: string) => apiCache.threads.delete(projectId || 'all'), getThreadMessages: (threadId: string) => apiCache.threadMessages.get(threadId), setThreadMessages: (threadId: string, data: any) => apiCache.threadMessages.set(threadId, data), - - // Helper to clear parts of the cache when data changes invalidateThreadMessages: (threadId: string) => apiCache.threadMessages.delete(threadId), + + // Functions to clear all cache + clearAll: () => { + apiCache.projects.clear(); + apiCache.threads.clear(); + apiCache.threadMessages.clear(); + console.log('[API] Cache cleared'); + }, + + clearProjects: () => { + apiCache.projects.clear(); + console.log('[API] Projects cache cleared'); + }, + + clearThreads: () => { + apiCache.threads.clear(); + console.log('[API] Threads cache cleared'); + } }; // Track active streams by agent run ID @@ -36,20 +53,24 @@ export type Project = { description: string; account_id: string; created_at: string; + updated_at?: string; sandbox: { vnc_preview?: string; sandbox_url?: string; id?: string; pass?: string; }; + [key: string]: any; // Allow additional properties to handle database fields } export type Thread = { thread_id: string; account_id: string | null; project_id?: string | null; + is_public?: boolean; created_at: string; updated_at: string; + [key: string]: any; // Allow additional properties to handle database fields } export type Message = { @@ -78,6 +99,7 @@ export const getProjects = async (): Promise => { // Check cache first const cached = apiCache.getProjects(); if (cached) { + console.log('[API] Returning cached projects:', cached.length); return cached; } @@ -96,9 +118,24 @@ export const getProjects = async (): Promise => { throw error; } + console.log('[API] Raw projects from DB:', data?.length, data); + + // Map database fields to our Project type + const mappedProjects: Project[] = (data || []).map(project => ({ + id: project.project_id, + name: project.name || '', + description: project.description || '', + account_id: project.account_id, + created_at: project.created_at, + updated_at: project.updated_at, + sandbox: project.sandbox || { id: "", pass: "", vnc_preview: "", sandbox_url: "" } + })); + + console.log('[API] Mapped projects for frontend:', mappedProjects.length); + // Cache the result - apiCache.setProjects(data || []); - return data || []; + apiCache.setProjects(mappedProjects); + return mappedProjects; } catch (err) { console.error('Error fetching projects:', err); // Return empty array for permission errors to avoid crashing the UI @@ -122,6 +159,8 @@ export const getProject = async (projectId: string): Promise => { if (error) throw error; + console.log('Raw project data from database:', data); + // If project has a sandbox, ensure it's started if (data.sandbox?.id) { try { @@ -149,9 +188,21 @@ export const getProject = async (projectId: string): Promise => { } } + // Map database fields to our Project type + const mappedProject: Project = { + id: data.project_id, + name: data.name || '', + description: data.description || '', + account_id: data.account_id, + created_at: data.created_at, + sandbox: data.sandbox || { id: "", pass: "", vnc_preview: "", sandbox_url: "" } + }; + + console.log('Mapped project data for frontend:', mappedProject); + // Cache the result - apiCache.setProject(projectId, data); - return data; + apiCache.setProject(projectId, mappedProject); + return mappedProject; }; export const createProject = async ( @@ -196,6 +247,16 @@ export const createProject = async ( export const updateProject = async (projectId: string, data: Partial): Promise => { const supabase = createClient(); + + console.log('Updating project with ID:', projectId); + console.log('Update data:', data); + + // Sanity check to avoid update errors + if (!projectId || projectId === '') { + console.error('Attempted to update project with invalid ID:', projectId); + throw new Error('Cannot update project: Invalid project ID'); + } + const { data: updatedData, error } = await supabase .from('projects') .update(data) @@ -230,14 +291,14 @@ export const updateProject = async (projectId: string, data: Partial): })); } - // Return formatted project data + // Return formatted project data - use same mapping as getProject return { id: updatedData.project_id, name: updatedData.name, description: updatedData.description || '', account_id: updatedData.account_id, created_at: updatedData.created_at, - sandbox: updatedData.sandbox || { id: "", pass: "", vnc_preview: "" } + sandbox: updatedData.sandbox || { id: "", pass: "", vnc_preview: "", sandbox_url: "" } }; }; @@ -256,6 +317,7 @@ export const getThreads = async (projectId?: string): Promise => { // Check cache first const cached = apiCache.getThreads(projectId || 'all'); if (cached) { + console.log('[API] Returning cached threads:', cached.length, projectId ? `for project ${projectId}` : 'for all projects'); return cached; } @@ -263,16 +325,31 @@ export const getThreads = async (projectId?: string): Promise => { let query = supabase.from('threads').select('*'); if (projectId) { + console.log('[API] Filtering threads by project_id:', projectId); query = query.eq('project_id', projectId); } const { data, error } = await query; - if (error) throw error; + if (error) { + console.error('[API] Error fetching threads:', error); + throw error; + } + + console.log('[API] Raw threads from DB:', data?.length, data); + + // Map database fields to ensure consistency with our Thread type + const mappedThreads: Thread[] = (data || []).map(thread => ({ + thread_id: thread.thread_id, + account_id: thread.account_id, + project_id: thread.project_id, + created_at: thread.created_at, + updated_at: thread.updated_at + })); // Cache the result - apiCache.setThreads(projectId || 'all', data || []); - return data || []; + apiCache.setThreads(projectId || 'all', mappedThreads); + return mappedThreads; }; export const getThread = async (threadId: string): Promise => { @@ -921,3 +998,40 @@ export const getSandboxFileContent = async (sandboxId: string, path: string): Pr throw error; } }; + +// Function to clear all API cache +export const clearApiCache = () => { + apiCache.clearAll(); +}; + +export const updateThread = async (threadId: string, data: Partial): Promise => { + const supabase = createClient(); + + // Format the data for update + const updateData = { ...data }; + + // Update the thread + const { data: updatedThread, error } = await supabase + .from('threads') + .update(updateData) + .eq('thread_id', threadId) + .select() + .single(); + + if (error) { + console.error('Error updating thread:', error); + throw new Error(`Error updating thread: ${error.message}`); + } + + // Invalidate thread cache if we're updating thread data + if (updatedThread.project_id) { + apiCache.invalidateThreads(updatedThread.project_id); + } + apiCache.invalidateThreads('all'); + + return updatedThread; +}; + +export const toggleThreadPublicStatus = async (threadId: string, isPublic: boolean): Promise => { + return updateThread(threadId, { is_public: isPublic }); +};