diff --git a/README.md b/README.md index f1b6a3db..e8c14d8e 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ You'll need the following components: - Generate an API key from your account settings - Go to [Images](https://app.daytona.io/dashboard/images) - Click "Add Image" - - Enter `adamcohenhillel/kortix-suna:0.0.13` as the image name + - Enter `adamcohenhillel/kortix-suna:0.0.16` as the image name - Set `exec /usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf` as the Entrypoint 4. **LLM API Keys**: @@ -251,3 +251,7 @@ python api.py ## License Kortix Suna is licensed under the Apache License, Version 2.0. See [LICENSE](./LICENSE) for the full license text. + + +## Co-Creators: +Adam Cohen Hillel, Marko Kraemer, Dom \ No newline at end of file diff --git a/backend/sandbox/docker/browser_api.py b/backend/sandbox/docker/browser_api.py index 80f14ea2..abb4c619 100644 --- a/backend/sandbox/docker/browser_api.py +++ b/backend/sandbox/docker/browser_api.py @@ -341,23 +341,22 @@ class BrowserAutomation: launch_options = {"timeout": 90000} self.browser = await playwright.chromium.launch(**launch_options) print("Browser launched with minimal options") - - print("Creating new page...") + try: + await self.get_current_page() + print("Found existing page, using it") + self.current_page_index = 0 + except Exception as page_error: + print(f"Error finding existing page, creating new one. ( {page_error})") page = await self.browser.new_page() print("New page created successfully") self.pages.append(page) self.current_page_index = 0 - # Navigate to about:blank to ensure page is ready - await page.goto("about:blank", timeout=30000) - print("Navigated to about:blank") + # await page.goto("google.com", timeout=30000) + print("Navigated to google.com") print("Browser initialization completed successfully") - except Exception as page_error: - print(f"Error creating page: {page_error}") - traceback.print_exc() - raise RuntimeError(f"Failed to initialize browser page: {page_error}") except Exception as e: print(f"Browser startup error: {str(e)}") traceback.print_exc() diff --git a/backend/sandbox/docker/docker-compose.yml b/backend/sandbox/docker/docker-compose.yml index 69ab629b..51ce1edc 100644 --- a/backend/sandbox/docker/docker-compose.yml +++ b/backend/sandbox/docker/docker-compose.yml @@ -6,7 +6,7 @@ services: dockerfile: ${DOCKERFILE:-Dockerfile} args: TARGETPLATFORM: ${TARGETPLATFORM:-linux/amd64} - image: adamcohenhillel/kortix-suna:0.0.13 + image: adamcohenhillel/kortix-suna:0.0.16 ports: - "6080:6080" # noVNC web interface - "5901:5901" # VNC port diff --git a/backend/sandbox/sandbox.py b/backend/sandbox/sandbox.py index d7ba2cab..eae680b6 100644 --- a/backend/sandbox/sandbox.py +++ b/backend/sandbox/sandbox.py @@ -96,7 +96,7 @@ def create_sandbox(password: str): logger.debug("OPENAI_API_KEY configured for sandbox") sandbox = daytona.create(CreateSandboxParams( - image="adamcohenhillel/kortix-suna:0.0.14", + image="adamcohenhillel/kortix-suna:0.0.16", public=False, env_vars={ "CHROME_PERSISTENT_SESSION": "true", diff --git a/backend/supabase/migrations/20240414161947_basejump-accounts.sql b/backend/supabase/migrations/20240414161947_basejump-accounts.sql index 87e913df..c85c79b7 100644 --- a/backend/supabase/migrations/20240414161947_basejump-accounts.sql +++ b/backend/supabase/migrations/20240414161947_basejump-accounts.sql @@ -268,7 +268,7 @@ select exists( ); $$; -grant execute on function basejump.has_role_on_account(uuid, basejump.account_role) to authenticated; +grant execute on function basejump.has_role_on_account(uuid, basejump.account_role) to authenticated, anon, public, service_role; /** diff --git a/backend/supabase/migrations/20250416133920_agentpress_schema.sql b/backend/supabase/migrations/20250416133920_agentpress_schema.sql index a7d8702b..c0156dd6 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 ) @@ -270,8 +274,8 @@ CREATE POLICY message_delete_policy ON messages -- Grant permissions to roles GRANT ALL PRIVILEGES ON TABLE projects TO authenticated, service_role; -GRANT ALL PRIVILEGES ON TABLE threads TO authenticated, service_role; -GRANT ALL PRIVILEGES ON TABLE messages TO authenticated, service_role; +GRANT SELECT ON TABLE threads TO authenticated, anon, service_role; +GRANT SELECT ON TABLE messages TO authenticated, anon, service_role; GRANT ALL PRIVILEGES ON TABLE agent_runs TO authenticated, service_role; -- Create a function that matches the Python get_messages behavior @@ -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 @@ -361,4 +371,4 @@ END; $$; -- Grant execute permissions -GRANT EXECUTE ON FUNCTION get_llm_formatted_messages TO authenticated, service_role; \ No newline at end of file +GRANT EXECUTE ON FUNCTION get_llm_formatted_messages TO authenticated, anon, service_role; \ No newline at end of file diff --git a/backend/utils/auth_utils.py b/backend/utils/auth_utils.py index 766e997d..efd35c32 100644 --- a/backend/utils/auth_utils.py +++ b/backend/utils/auth_utils.py @@ -128,6 +128,11 @@ async def verify_thread_access(client, thread_id: str, user_id: str): raise HTTPException(status_code=404, detail="Thread not found") thread_data = thread_result.data[0] + + # Check if thread is public + if thread_data.get('is_public'): + return True + account_id = thread_data.get('account_id') # When using service role, we need to manually check account membership instead of using current_user_account_role if account_id: 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..ec4a4089 --- /dev/null +++ b/frontend/src/app/share/[threadId]/page.tsx @@ -0,0 +1,1774 @@ +'use client'; + +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import Image from 'next/image'; +import { useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { + ArrowDown, CircleDashed, Info, File, ChevronRight, Play, Pause +} from 'lucide-react'; +import { getMessages, 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 { ToolCallSidePanel, ToolCallInput } from "@/components/thread/tool-call-side-panel"; +import { useAgentStream } from '@/hooks/useAgentStream'; +import { Markdown } from '@/components/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; + // Initially hide the side panel + setIsSidePanelOpen(false); + }, []); + + // Define togglePlayback and resetPlayback functions explicitly + const togglePlayback = useCallback(() => { + setIsPlaying(prev => { + if (!prev) { + // When starting playback, show the side panel + setIsSidePanelOpen(true); + } + return !prev; + }); + }, []); + + const resetPlayback = useCallback(() => { + setIsPlaying(false); + setCurrentMessageIndex(0); + setVisibleMessages([]); + setToolPlaybackIndex(-1); + setStreamingText(""); + setIsStreamingText(false); + setCurrentToolCall(null); + // Hide the side panel when resetting + setIsSidePanelOpen(false); + }, []); + + 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) { + try { + 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 || ''); + } + } catch (projectError) { + // Don't throw an error if project can't be loaded + // Just log it and continue with the thread + console.warn('[SHARE] Could not load project data:', projectError); + // Set a generic name if we couldn't load the project + setProjectName('Shared Conversation'); + } + } + + // 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 ? ( +
+ {/* Gradient overlay */} +
+ +
+
+ +
+

Watch this agent in action

+

+ This is a shared view-only agent run. 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 && ( + + )} + + {/* Show a fallback modal when sandbox is not available */} + {!sandboxId && fileViewerOpen && ( +
+
+

File Unavailable

+

+ The file viewer is not available for this shared thread. +

+ +
+
+ )} +
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1e8ac654..e8611d82 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -16,11 +16,10 @@ 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 @@ -67,7 +66,8 @@ export type Project = { export type Thread = { thread_id: string; account_id: string | null; - project_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 @@ -151,58 +151,70 @@ export const getProject = async (projectId: string): Promise => { } const supabase = createClient(); - const { data, error } = await supabase - .from('projects') - .select('*') - .eq('project_id', projectId) - .single(); - 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 { - const { data: { session } } = await supabase.auth.getSession(); - if (session?.access_token) { - console.log(`Ensuring sandbox is active for project ${projectId}...`); - const response = await fetch(`${API_URL}/project/${projectId}/sandbox/ensure-active`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${session.access_token}`, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - const errorText = await response.text().catch(() => 'No error details available'); - console.warn(`Failed to ensure sandbox is active: ${response.status} ${response.statusText}`, errorText); - } else { - console.log('Sandbox activation successful'); - } + try { + const { data, error } = await supabase + .from('projects') + .select('*') + .eq('project_id', projectId) + .single(); + + if (error) { + // Handle the specific "no rows returned" error from Supabase + if (error.code === 'PGRST116') { + throw new Error(`Project not found or not accessible: ${projectId}`); } - } catch (sandboxError) { - console.warn('Failed to ensure sandbox is active:', sandboxError); - // Non-blocking error - continue with the project data + throw error; } + + console.log('Raw project data from database:', data); + + // If project has a sandbox, ensure it's started + if (data.sandbox?.id) { + try { + const { data: { session } } = await supabase.auth.getSession(); + if (session?.access_token) { + console.log(`Ensuring sandbox is active for project ${projectId}...`); + const response = await fetch(`${API_URL}/project/${projectId}/sandbox/ensure-active`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${session.access_token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'No error details available'); + console.warn(`Failed to ensure sandbox is active: ${response.status} ${response.statusText}`, errorText); + } else { + console.log('Sandbox activation successful'); + } + } + } catch (sandboxError) { + console.warn('Failed to ensure sandbox is active:', sandboxError); + // Non-blocking error - continue with the project data + } + } + + // 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, mappedProject); + return mappedProject; + } catch (error) { + console.error(`Error fetching project ${projectId}:`, error); + throw error; } - - // 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, mappedProject); - return mappedProject; }; export const createProject = async ( @@ -1003,3 +1015,35 @@ export const getSandboxFileContent = async (sandboxId: string, path: string): Pr 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 }); +};