From fea771b0f2bf1908e6840d20961ffd283712d1cc Mon Sep 17 00:00:00 2001 From: Vukasin Date: Tue, 6 May 2025 19:15:22 +0200 Subject: [PATCH] feat: rework thread content and improvements --- .../(dashboard)/agents/[threadId]/page.tsx | 803 ++++------------ frontend/src/app/share/[threadId]/page.tsx | 873 +++--------------- .../thread/content/PlaybackControls.tsx | 518 +++++++++++ .../thread/content/ThreadContent.tsx | 535 +++++++++++ .../src/components/thread/file-attachment.tsx | 225 +++++ .../src/lib/constants/errorCodeMessages.ts | 3 + 6 files changed, 1563 insertions(+), 1394 deletions(-) create mode 100644 frontend/src/components/thread/content/PlaybackControls.tsx create mode 100644 frontend/src/components/thread/content/ThreadContent.tsx create mode 100644 frontend/src/components/thread/file-attachment.tsx create mode 100644 frontend/src/lib/constants/errorCodeMessages.ts diff --git a/frontend/src/app/(dashboard)/agents/[threadId]/page.tsx b/frontend/src/app/(dashboard)/agents/[threadId]/page.tsx index d28b0485..184cb50e 100644 --- a/frontend/src/app/(dashboard)/agents/[threadId]/page.tsx +++ b/frontend/src/app/(dashboard)/agents/[threadId]/page.tsx @@ -1,11 +1,10 @@ 'use client'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +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, CheckCircle, CircleDashed, AlertTriangle, Info, File, ChevronRight + AlertTriangle } from 'lucide-react'; import { addUserMessage, getMessages, startAgent, stopAgent, getAgentRuns, getProject, getThread, updateProject, Project, Message as BaseApiMessageType, BillingError, checkBillingStatus } from '@/lib/api'; import { toast } from 'sonner'; @@ -21,38 +20,10 @@ import { cn } from "@/lib/utils"; import { useIsMobile } from "@/hooks/use-mobile"; import { BillingErrorAlert } from '@/components/billing/usage-limit-alert'; import { isLocalMode } from "@/lib/config"; - +import { ThreadContent } from '@/components/thread/content/ThreadContent'; import { 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' -]); +import { safeJsonParse } from '@/components/thread/utils'; // Extend the base Message type with the expected database fields interface ApiMessageType extends BaseApiMessageType { @@ -73,136 +44,11 @@ interface StreamingToolCall { 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 (using the helper) - contentParts.push( -
- {askContent} - {renderAttachments(attachments, fileViewerHandler)} -
- ); - } 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; -} - -// Helper function to render attachments (now deduplicated) -function renderAttachments(attachments: string[], fileViewerHandler?: (filePath?: string) => void) { - if (!attachments || attachments.length === 0) return null; - - // Deduplicate attachments using Set for simplicity - const uniqueAttachments = [...new Set(attachments)]; - - return ( -
-
- {uniqueAttachments.map((attachment, idx) => { - const extension = attachment.split('.').pop()?.toLowerCase(); - const filename = attachment.split('/').pop() || 'file'; - - // Define file size (placeholder logic) - const fileSize = - extension === 'html' ? '52.68 KB' : - attachment.includes('itinerary') ? '4.14 KB' : - attachment.includes('proposal') ? '6.20 KB' : - attachment.includes('todo') ? '1.89 KB' : - attachment.includes('research') ? '3.75 KB' : - `${(attachment.length % 5 + 1).toFixed(2)} KB`; // Use length for pseudo-stable size - - // Get file type display - const fileType = extension === 'html' ? 'Code' : 'Text'; - - return ( - - ); - })} -
-
- ); -} - export default function ThreadPage({ params }: { params: Promise }) { const unwrappedParams = React.use(params); const threadId = unwrappedParams.threadId; const isMobile = useIsMobile(); - + const router = useRouter(); const [messages, setMessages] = useState([]); const [newMessage, setNewMessage] = useState(''); @@ -216,7 +62,7 @@ export default function ThreadPage({ params }: { params: Promise } const [currentToolIndex, setCurrentToolIndex] = useState(0); const [autoOpenedPanel, setAutoOpenedPanel] = useState(false); const [initialPanelOpenAttempted, setInitialPanelOpenAttempted] = useState(false); - + // Billing alert state const [showBillingAlert, setShowBillingAlert] = useState(false); const [billingData, setBillingData] = useState<{ @@ -258,7 +104,7 @@ export default function ThreadPage({ params }: { params: Promise } if (initialLoadCompleted.current && !initialPanelOpenAttempted) { // Only attempt to open panel once on initial load setInitialPanelOpenAttempted(true); - + // Open the panel with tool calls if available if (toolCalls.length > 0) { setIsSidePanelOpen(true); @@ -313,7 +159,7 @@ export default function ThreadPage({ params }: { params: Promise } setLeftSidebarOpen(false); } } - + // CMD+B for Left Sidebar if ((event.metaKey || event.ctrlKey) && event.key === 'b') { event.preventDefault(); @@ -330,7 +176,7 @@ export default function ThreadPage({ params }: { params: Promise } } } }; - + window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [toggleSidePanel, isSidePanelOpen, leftSidebarState, setLeftSidebarOpen]); @@ -339,9 +185,9 @@ export default function ThreadPage({ params }: { params: Promise } // 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)}...`); + 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) { @@ -359,7 +205,7 @@ export default function ThreadPage({ params }: { params: Promise } const handleStreamStatusChange = useCallback((hookStatus: string) => { console.log(`[PAGE] Hook status changed: ${hookStatus}`); - switch(hookStatus) { + switch (hookStatus) { case 'idle': case 'completed': case 'stopped': @@ -370,7 +216,7 @@ export default function ThreadPage({ params }: { params: Promise } setAgentRunId(null); // Reset auto-opened state when agent completes to trigger tool detection setAutoOpenedPanel(false); - + // After terminal states, we should scroll to bottom to show latest messages // The hook will already have refetched messages by this point if (['completed', 'stopped', 'agent_not_running', 'error', 'failed'].includes(hookStatus)) { @@ -388,14 +234,14 @@ export default function ThreadPage({ params }: { params: Promise } 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}`); + 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}`); + console.log(`[PAGE] Stream hook closed with final status: ${agentStatus}`); }, [agentStatus]); const { @@ -420,24 +266,28 @@ export default function ThreadPage({ params }: { params: Promise } } }, [agentRunId, startStreaming, currentHookRunId]); + const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => { + messagesEndRef.current?.scrollIntoView({ behavior }); + }; + useEffect(() => { let isMounted = true; async function loadData() { if (!initialLoadCompleted.current) setIsLoading(true); setError(null); - + try { if (!threadId) throw new Error('Thread ID is required'); - const threadData = await getThread(threadId).catch(err => { - throw new Error('Failed to load thread data: ' + err.message); + const threadData = await getThread(threadId).catch(err => { + throw new Error('Failed to load thread data: ' + err.message); }); - + if (!isMounted) return; - + console.log('[PAGE] Thread data loaded:', threadData); - + if (threadData?.project_id) { console.log('[PAGE] Getting project data for project_id:', threadData.project_id); const projectData = await getProject(threadData.project_id); @@ -445,17 +295,17 @@ export default function ThreadPage({ params }: { params: Promise } console.log('[PAGE] Project data loaded:', projectData); console.log('[PAGE] Project ID:', projectData.id); console.log('[PAGE] Project sandbox data:', projectData.sandbox); - + // Set project data setProject(projectData); - + // Make sure sandbox ID is set correctly if (typeof projectData.sandbox === 'string') { setSandboxId(projectData.sandbox); } else if (projectData.sandbox?.id) { setSandboxId(projectData.sandbox.id); } - + setProjectName(projectData.name || ''); } } @@ -465,7 +315,7 @@ export default function ThreadPage({ params }: { params: Promise } if (isMounted) { // Log raw messages fetched from API console.log('[PAGE] Raw messages fetched:', messagesData); - + // Map API message type to UnifiedMessage type const unifiedMessages = (messagesData || []) .filter(msg => msg.type !== 'status') @@ -473,7 +323,7 @@ export default function ThreadPage({ params }: { params: Promise } console.log(`[MAP ${index}] Processing raw message:`, msg); const messageId = msg.message_id; console.log(`[MAP ${index}] Accessed msg.message_id:`, messageId); - if (!messageId && msg.type !== 'status') { + if (!messageId && msg.type !== 'status') { console.warn(`[MAP ${index}] Non-status message fetched from API is missing ID: Type=${msg.type}`); } const threadIdMapped = msg.thread_id || threadId; @@ -492,27 +342,27 @@ export default function ThreadPage({ params }: { params: Promise } console.log(`[MAP ${index}] Accessed msg.updated_at (using fallback):`, updatedAtMapped); return { - message_id: messageId || null, + message_id: messageId || null, thread_id: threadIdMapped, - type: typeMapped, + type: typeMapped, is_llm_message: isLlmMessageMapped, content: contentMapped, metadata: metadataMapped, created_at: createdAtMapped, updated_at: updatedAtMapped }; - }); - + }); + setMessages(unifiedMessages); // Set the filtered and mapped messages console.log('[PAGE] Loaded Messages (excluding status, keeping browser_state):', unifiedMessages.length) - + // Debug loaded messages const assistantMessages = unifiedMessages.filter(m => m.type === 'assistant'); const toolMessages = unifiedMessages.filter(m => m.type === 'tool'); - + console.log('[PAGE] Assistant messages:', assistantMessages.length); console.log('[PAGE] Tool messages:', toolMessages.length); - + // Check if tool messages have associated assistant messages toolMessages.forEach(toolMsg => { try { @@ -527,7 +377,7 @@ export default function ThreadPage({ params }: { params: Promise } console.error("Error parsing tool message metadata:", e); } }); - + messagesLoadedRef.current = true; if (!hasInitiallyScrolled.current) { scrollToBottom('auto'); @@ -556,7 +406,7 @@ export default function ThreadPage({ params }: { params: Promise } if (isMounted) setAgentStatus('idle'); } } - + initialLoadCompleted.current = true; } catch (err) { @@ -570,7 +420,7 @@ export default function ThreadPage({ params }: { params: Promise } if (isMounted) setIsLoading(false); } } - + loadData(); return () => { @@ -627,12 +477,12 @@ export default function ThreadPage({ params }: { params: Promise } accountId: project?.account_id || null // Pass account ID }); setShowBillingAlert(true); - + // Remove the optimistic message since the agent couldn't start setMessages(prev => prev.filter(m => m.message_id !== optimisticUserMessage.message_id)); return; // Stop further execution in this case } - + // Handle other agent start errors throw new Error(`Failed to start agent: ${error?.message || error}`); } @@ -658,25 +508,14 @@ export default function ThreadPage({ params }: { params: Promise } const handleStopAgent = useCallback(async () => { console.log(`[PAGE] Requesting agent stop via hook.`); setAgentStatus('idle'); - + // First stop the streaming and let the hook handle refetching await stopStreaming(); - + // We don't need to refetch messages here since the hook will do that // The centralizing of refetching in the hook simplifies this logic }, [stopStreaming]); - const handleScroll = () => { - if (!messagesContainerRef.current) return; - const { scrollTop, scrollHeight, clientHeight } = messagesContainerRef.current; - const isScrolledUp = scrollHeight - scrollTop - clientHeight > 100; - setShowScrollButton(isScrolledUp); - setUserHasScrolled(isScrolledUp); - }; - - const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => { - messagesEndRef.current?.scrollIntoView({ behavior }); - }; useEffect(() => { const lastMsg = messages[messages.length - 1]; @@ -696,18 +535,13 @@ export default function ThreadPage({ params }: { params: Promise } return () => observer.disconnect(); }, [messages, streamingTextContent, streamingToolCall, setShowScrollButton]); - const handleScrollButtonClick = () => { - scrollToBottom('smooth'); - setUserHasScrolled(false); - }; - useEffect(() => { console.log(`[PAGE] 🔄 Page AgentStatus: ${agentStatus}, Hook Status: ${streamHookStatus}, Target RunID: ${agentRunId || 'none'}, Hook RunID: ${currentHookRunId || 'none'}`); - + // If the stream hook reports completion/stopping but our UI hasn't updated - if ((streamHookStatus === 'completed' || streamHookStatus === 'stopped' || - streamHookStatus === 'agent_not_running' || streamHookStatus === 'error') && - (agentStatus === 'running' || agentStatus === 'connecting')) { + 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); @@ -724,12 +558,49 @@ export default function ThreadPage({ params }: { params: Promise } setFileViewerOpen(true); }, []); + // Process the assistant call data + const toolViewAssistant = useCallback((assistantContent?: string) => { + // This needs to stay simple as it's meant for the side panel tool call view + 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} +
+
+ ); + }, []); + // 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; @@ -756,7 +627,7 @@ export default function ThreadPage({ params }: { params: Promise } toolName = assistantContentParsed.tool_calls[0].name || 'unknown'; } } - } catch {} + } catch { } // Skip adding tags to the tool calls if (toolName === 'ask' || toolName === 'complete') { @@ -766,10 +637,10 @@ export default function ThreadPage({ params }: { params: Promise } let isSuccess = true; try { const toolContent = resultMessage.content?.toLowerCase() || ''; - isSuccess = !(toolContent.includes('failed') || - toolContent.includes('error') || - toolContent.includes('failure')); - } catch {} + isSuccess = !(toolContent.includes('failed') || + toolContent.includes('error') || + toolContent.includes('failure')); + } catch { } historicalToolPairs.push({ assistantCall: { @@ -788,18 +659,18 @@ export default function ThreadPage({ params }: { params: Promise } // 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); + // 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); + // 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 @@ -811,51 +682,13 @@ export default function ThreadPage({ params }: { params: Promise } } }, [isSidePanelOpen]); - // Process the assistant call data - const toolViewAssistant = useCallback((assistantContent?: string, toolContent?: string) => { - // This needs to stay simple as it's meant for the side panel tool call view - if (!assistantContent) return null; - - return ( -
-
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."); @@ -887,10 +720,10 @@ export default function ThreadPage({ params }: { params: Promise } 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; + tc.toolResult?.content === toolMessage?.content; }); @@ -906,91 +739,24 @@ export default function ThreadPage({ params }: { params: Promise } } }, [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' || toolName === 'complete') { - return; - } - - console.log("[STREAM] Received tool call:", toolName); - - // If user explicitly closed the panel, don't reopen it for streaming calls - if (userClosedPanelRef.current) return; - - // Create a properly formatted tool call input for the streaming tool - // that matches the format of historical tool calls - const toolArguments = toolCall.arguments || ''; - - // Format the arguments in a way that matches the expected XML format for each tool - // This ensures the specialized tool views render correctly - let formattedContent = toolArguments; - if (toolName.toLowerCase().includes('command') && !toolArguments.includes('')) { - 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) { // Update document title when project name changes document.title = `${projectName} | Kortix Suna`; - + // Update meta tags for SEO const metaDescription = document.querySelector('meta[name="description"]'); if (metaDescription) { metaDescription.setAttribute('content', `${projectName} - Interactive agent conversation powered by Kortix Suna`); } - + // Update OpenGraph tags if they exist const ogTitle = document.querySelector('meta[property="og:title"]'); if (ogTitle) { ogTitle.setAttribute('content', `${projectName} | Kortix Suna`); } - + const ogDescription = document.querySelector('meta[property="og:description"]'); if (ogDescription) { ogDescription.setAttribute('content', `Interactive AI conversation for ${projectName}`); @@ -1012,18 +778,18 @@ export default function ThreadPage({ params }: { params: Promise } console.log(`[PAGE] Backup refetch completed with ${messagesData.length} messages`); // Map API message type to UnifiedMessage type const unifiedMessages = (messagesData || []) - .filter(msg => msg.type !== 'status') + .filter(msg => msg.type !== 'status') .map((msg: ApiMessageType) => ({ - message_id: msg.message_id || null, + message_id: msg.message_id || null, thread_id: msg.thread_id || threadId, - type: (msg.type || 'system') as UnifiedMessage['type'], + type: (msg.type || 'system') as UnifiedMessage['type'], is_llm_message: Boolean(msg.is_llm_message), content: msg.content || '', metadata: msg.metadata || '{}', created_at: msg.created_at || new Date().toISOString(), updated_at: msg.updated_at || new Date().toISOString() })); - + setMessages(unifiedMessages); // Reset auto-opened panel to allow tool detection with fresh messages setAutoOpenedPanel(false); @@ -1033,7 +799,7 @@ export default function ThreadPage({ params }: { params: Promise } console.error('Error in backup message refetch:', err); }); }, 1000); - + return () => clearTimeout(timer); } } @@ -1049,7 +815,7 @@ export default function ThreadPage({ params }: { params: Promise } try { const result = await checkBillingStatus(); - + if (!result.can_run) { setBillingData({ currentUsage: result.subscription?.minutes_limit || 0, @@ -1070,12 +836,12 @@ export default function ThreadPage({ params }: { params: Promise } // Update useEffect to use the renamed function useEffect(() => { const previousStatus = previousAgentStatus.current; - + // Check if agent just completed (status changed from running to idle) if (previousStatus === 'running' && agentStatus === 'idle') { checkBillingLimits(); } - + // Store current status for next comparison previousAgentStatus.current = agentStatus; }, [agentStatus, checkBillingLimits]); @@ -1115,7 +881,7 @@ export default function ThreadPage({ params }: { params: Promise } - + {/* Skeleton Chat Messages */}
@@ -1128,7 +894,7 @@ export default function ThreadPage({ params }: { params: Promise }
- + {/* Assistant response with tool usage */}
@@ -1141,12 +907,12 @@ export default function ThreadPage({ params }: { params: Promise }
- + {/* Tool call button skeleton */}
- +
@@ -1156,14 +922,14 @@ export default function ThreadPage({ params }: { params: Promise }
- + {/* User message */}
- + {/* Assistant thinking state */}
@@ -1179,7 +945,7 @@ export default function ThreadPage({ params }: { params: Promise }
- + {/* Skeleton Chat Input */}
@@ -1192,7 +958,7 @@ export default function ThreadPage({ params }: { params: Promise }
- + {/* Skeleton Side Panel (closed state) */}
@@ -1211,11 +977,11 @@ export default function ThreadPage({ params }: { params: Promise } return (
- @@ -1226,15 +992,15 @@ export default function ThreadPage({ params }: { params: Promise }

Thread Not Found

- {error.includes('JSON object requested, multiple (or no) rows returned') + {error.includes('JSON object requested, multiple (or no) rows returned') ? 'This thread either does not exist or you do not have access to it.' : error}

- setIsSidePanelOpen(false)} toolCalls={[]} currentIndex={0} @@ -1249,287 +1015,58 @@ export default function ThreadPage({ params }: { params: Promise } return (
- -
-
- {messages.length === 0 && !streamingTextContent && !streamingToolCall && agentStatus === 'idle' ? ( -
-
Send a message to start.
-
- ) : ( -
- {(() => { - // Group messages logic - type MessageGroup = { - type: 'user' | 'assistant_group'; - messages: UnifiedMessage[]; - key: string; - }; - const groupedMessages: MessageGroup[] = []; - let currentGroup: MessageGroup | null = null; - messages.forEach((message, index) => { - const messageType = message.type; - const key = message.message_id || `msg-${index}`; + {/* Replace the entire messages section with ThreadContent component */} + - 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; - } - })(); - - // Extract attachments from the message content - const attachmentsMatch = messageContent.match(/\[Uploaded File: (.*?)\]/g); - const attachments = attachmentsMatch - ? attachmentsMatch.map(match => { - const pathMatch = match.match(/\[Uploaded File: (.*?)\]/); - return pathMatch ? pathMatch[1] : null; - }).filter(Boolean) - : []; - - // Remove attachment info from the message content - const cleanContent = messageContent.replace(/\[Uploaded File: .*?\]/g, '').trim(); - - return ( -
-
-
- {cleanContent && ( - {cleanContent} - )} - - {/* Use the helper function to render user attachments */} - {renderAttachments(attachments as string[], handleOpenFileViewer)} -
-
-
- ); - } else if (group.type === 'assistant_group') { - return ( -
-
-
- Kortix -
-
-
-
- {(() => { - 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 && (streamHookStatus === 'streaming' || streamHookStatus === 'connecting') && ( -
- {(() => { - let detectedTag: string | null = null; - let tagStartIndex = -1; - if (streamingTextContent) { - for (const tag of HIDE_STREAMING_XML_TAGS) { - const openingTagPattern = `<${tag}`; - const index = streamingTextContent.indexOf(openingTagPattern); - if (index !== -1) { - detectedTag = tag; - tagStartIndex = index; - break; - } - } - } - - const textToRender = streamingTextContent || ''; - const textBeforeTag = detectedTag ? textToRender.substring(0, tagStartIndex) : textToRender; - const showCursor = (streamHookStatus === 'streaming' || streamHookStatus === 'connecting') && !detectedTag; - - return ( - <> - {textBeforeTag && ( - {textBeforeTag} - )} - {showCursor && ( - - )} - - {detectedTag && ( -
- -
- )} - - {streamingToolCall && !detectedTag && ( -
- {(() => { - const toolName = streamingToolCall.name || streamingToolCall.xml_tag_name || 'Tool'; - const IconComponent = getToolIcon(toolName); - const paramDisplay = extractPrimaryParam(toolName, streamingToolCall.arguments || ''); - return ( - - ); - })()} -
- )} - - ); - })()} -
- )} -
-
-
-
-
- ); - } - return null; - }); - })()} - {(agentStatus === 'running' || agentStatus === 'connecting') && - (messages.length === 0 || messages[messages.length - 1].type === 'user') && ( -
-
-
- Suna -
-
-
-
-
-
-
-
-
-
-
-
- )} -
- )} -
+
+
+
-
-
- -
-
- - { setIsSidePanelOpen(false); userClosedPanelRef.current = true; @@ -1556,7 +1093,7 @@ export default function ThreadPage({ params }: { params: Promise } )} {/* Billing Alert for usage limit */} -
); }, []); - // Update handleToolClick to respect user closing preference and navigate correctly + // Handle tool clicks const handleToolClick = useCallback((clickedAssistantMessageId: string | null, clickedToolName: string) => { // Explicitly ignore ask tags from opening the side panel if (clickedToolName === 'ask') { @@ -974,106 +852,17 @@ export default function ThreadPage({ params }: { params: Promise } 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; + // Direct mapping using the message-to-tool-index map + const toolIndex = messageToToolIndex[clickedAssistantMessageId]; - // 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}`); + if (toolIndex !== undefined) { setCurrentToolIndex(toolIndex); - setIsSidePanelOpen(true); // Explicitly open the panel + setIsSidePanelOpen(true); } else { - console.warn(`[PAGE] Could not find matching tool call in toolCalls array for assistant message ID: ${clickedAssistantMessageId}`); + console.warn(`Could not find matching tool call for 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); - }, []); + }, [messageToToolIndex]); // SEO title update useEffect(() => { @@ -1082,7 +871,7 @@ export default function ThreadPage({ params }: { params: Promise } const metaDescription = document.querySelector('meta[name="description"]'); if (metaDescription) { - metaDescription.setAttribute('content', `${projectName} - Public AI conversation shared from Kortix Suna`); + metaDescription.setAttribute('content', `${projectName} - Public AI conversation shared from Suna`); } const ogTitle = document.querySelector('meta[property="og:title"]'); @@ -1097,63 +886,6 @@ export default function ThreadPage({ params }: { params: Promise } } }, [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; @@ -1168,7 +900,6 @@ export default function ThreadPage({ params }: { params: Promise } 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) { @@ -1177,23 +908,6 @@ export default function ThreadPage({ params }: { params: Promise } } }, [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 @@ -1230,57 +944,44 @@ export default function ThreadPage({ params }: { params: Promise } } }, [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; + // Build the message-to-tool-index map when tool calls change + useEffect(() => { + if (!toolCalls.length) return; - // 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]); + const mapBuilder: Record = {}; - return ( -
- + 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}`); + } + }); - {/* Add debug button */} - {isOpen && toolCalls && toolCalls.length > 0 && ( -
- -
- )} -
- ); - }; + setMessageToToolIndex(mapBuilder); + }, [toolCalls]); - return WrappedPanel; - }, []); + // Initialize PlaybackControls + const playbackController: PlaybackController = PlaybackControls({ + messages, + isSidePanelOpen, + onToggleSidePanel: toggleSidePanel, + toolCalls, + setCurrentToolIndex, + onFileViewerOpen: handleOpenFileViewer, + projectName: projectName || 'Shared Conversation' + }); + // Extract the playback state and functions + const { + playbackState, + renderHeader, + renderFloatingControls, + renderWelcomeOverlay + } = playbackController; + + // Loading skeleton UI if (isLoading && !initialLoadCompleted.current) { return (
@@ -1304,7 +1005,6 @@ export default function ThreadPage({ params }: { params: Promise } {/* Skeleton Chat Messages */}
- {/* User message */}
@@ -1314,7 +1014,6 @@ export default function ThreadPage({ params }: { params: Promise }
- {/* Assistant response with tool usage */}
@@ -1327,15 +1026,9 @@ export default function ThreadPage({ params }: { params: Promise }
- {/* Tool call button skeleton */}
- -
- - -
@@ -1345,7 +1038,7 @@ export default function ThreadPage({ params }: { params: Promise }
- {/* Skeleton Side Panel (closed state) */} + {/* Skeleton Side Panel */}
@@ -1359,6 +1052,7 @@ export default function ThreadPage({ params }: { params: Promise } ); } + // Error state UI if (error) { return (
@@ -1374,9 +1068,9 @@ export default function ThreadPage({ params }: { params: Promise }

Error

{error}

- +
@@ -1384,379 +1078,36 @@ export default function ThreadPage({ params }: { params: Promise } ); } + // Main UI return (
{/* Header with playback controls */} -
-
-
-
-
- Kortix -
- {projectName || 'Shared Conversation'} -
-
-
- - - - -
-
-
-
-
- {visibleMessages.length === 0 && !streamingText && !currentToolCall ? ( -
- {/* Gradient overlay */} -
+ {renderHeader()} -
-
- -
-

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; + {/* Thread Content */} + - visibleMessages.forEach((message, index) => { - const messageType = message.type; - const key = message.message_id ? `${message.message_id}-${index}` : `msg-${index}`; + {/* Welcome overlay */} + {renderWelcomeOverlay()} - 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 ? `${message.message_id}-${msgIndex}` : `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} -
- - - - -
-
- )} + {/* Floating playback controls */} + {renderFloatingControls()}
- {/* Scroll to bottom button */} - {showScrollButton && ( - - )} - - {/* Tool calls side panel - Replace with debug-enabled version */} - setIsSidePanelOpen(false)} toolCalls={toolCalls} @@ -1769,7 +1120,7 @@ export default function ThreadPage({ params }: { params: Promise } renderToolResult={toolViewResult} /> - {/* Show FileViewerModal regardless of sandboxId availability */} + {/* File viewer modal */}