From 2227b2bc5cc15c0d03f3dc210c7ea459f4807f95 Mon Sep 17 00:00:00 2001 From: Rishi Date: Sat, 17 May 2025 10:19:30 +0200 Subject: [PATCH 1/2] fix: z-index issue on share pages preventing clicks on header, also add canonical --- frontend/src/app/share/[threadId]/layout.tsx | 21 +- frontend/src/app/share/[threadId]/page.tsx | 279 +++-- .../thread/content/PlaybackControls.tsx | 1046 +++++++++-------- 3 files changed, 757 insertions(+), 589 deletions(-) diff --git a/frontend/src/app/share/[threadId]/layout.tsx b/frontend/src/app/share/[threadId]/layout.tsx index c27b77dc..18564e41 100644 --- a/frontend/src/app/share/[threadId]/layout.tsx +++ b/frontend/src/app/share/[threadId]/layout.tsx @@ -6,14 +6,17 @@ export async function generateMetadata({ params }): Promise { const fallbackMetaData = { title: 'Shared Conversation | Kortix Suna', description: 'Replay this Agent conversation on Kortix Suna', + alternates: { + canonical: `${process.env.NEXT_PUBLIC_URL}/share/${threadId}`, + }, openGraph: { + url: `${process.env.NEXT_PUBLIC_URL}/share/${threadId}`, title: 'Shared Conversation | Kortix Suna', description: 'Replay this Agent conversation on Kortix Suna', images: [`${process.env.NEXT_PUBLIC_URL}/share-page/og-fallback.png`], }, }; - try { const threadData = await getThread(threadId); const projectData = await getProject(threadData.project_id); @@ -27,15 +30,20 @@ export async function generateMetadata({ params }): Promise { process.env.NEXT_PUBLIC_ENV_MODE === 'LOCAL' || process.env.NEXT_PUBLIC_ENV_MODE === 'local'; - const title = projectData.name || 'Shared Conversation | Kortix Suna'; - const description = projectData.description || 'Replay this Agent conversation on Kortix Suna'; - const ogImage = isDevelopment - ? `${process.env.NEXT_PUBLIC_URL}/share-page/og-fallback.png` - : `${process.env.NEXT_PUBLIC_URL}/api/share-page/og-image?title=${projectData.name}`; + const title = projectData.name || 'Shared Conversation | Kortix Suna'; + const description = + projectData.description || + 'Replay this Agent conversation on Kortix Suna'; + const ogImage = isDevelopment + ? `${process.env.NEXT_PUBLIC_URL}/share-page/og-fallback.png` + : `${process.env.NEXT_PUBLIC_URL}/api/share-page/og-image?title=${projectData.name}`; return { title, description, + alternates: { + canonical: `${process.env.NEXT_PUBLIC_URL}/share/${threadId}`, + }, openGraph: { title, description, @@ -45,7 +53,6 @@ export async function generateMetadata({ params }): Promise { title, description, images: ogImage, - creator: '@kortixai', card: 'summary_large_image', }, }; diff --git a/frontend/src/app/share/[threadId]/page.tsx b/frontend/src/app/share/[threadId]/page.tsx index cec47582..a085473c 100644 --- a/frontend/src/app/share/[threadId]/page.tsx +++ b/frontend/src/app/share/[threadId]/page.tsx @@ -12,16 +12,25 @@ import { 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 { + ToolCallSidePanel, + ToolCallInput, +} from '@/components/thread/tool-call-side-panel'; import { ThreadContent } from '@/components/thread/content/ThreadContent'; -import { PlaybackControls, PlaybackController } from '@/components/thread/content/PlaybackControls'; -import { UnifiedMessage, ParsedMetadata, ThreadParams } from '@/components/thread/types'; +import { + PlaybackControls, + PlaybackController, +} from '@/components/thread/content/PlaybackControls'; +import { + UnifiedMessage, + ParsedMetadata, + ThreadParams, +} from '@/components/thread/types'; import { safeJsonParse } from '@/components/thread/utils'; import { useAgentStream } from '@/hooks/useAgentStream'; import { threadErrorCodeMessages } from '@/lib/constants/errorCodeMessages'; import { ThreadSkeleton } from '@/components/thread/content/ThreadSkeleton'; - // Extend the base Message type with the expected database fields interface ApiMessageType extends BaseApiMessageType { message_id?: string; @@ -43,7 +52,8 @@ interface StreamingToolCall { // 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 toolCallRegex = + /<([a-zA-Z\-_]+)(?:\s+[^>]*)?>(?:[\s\S]*?)<\/\1>|<([a-zA-Z\-_]+)(?:\s+[^>]*)?\/>/g; const results = []; let match; @@ -51,7 +61,7 @@ const extractToolCallsFromMessage = (content: string) => { const toolName = match[1] || match[2]; results.push({ name: toolName, - fullMatch: match[0] + fullMatch: match[0], }); } @@ -71,7 +81,9 @@ export default function ThreadPage({ 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 [agentStatus, setAgentStatus] = useState< + 'idle' | 'running' | 'connecting' | 'error' + >('idle'); const [isSidePanelOpen, setIsSidePanelOpen] = useState(false); const [toolCalls, setToolCalls] = useState([]); const [currentToolIndex, setCurrentToolIndex] = useState(0); @@ -85,7 +97,9 @@ export default function ThreadPage({ useState(null); // Create a message-to-tool-index map for faster lookups - const [messageToToolIndex, setMessageToToolIndex] = useState>({}); + const [messageToToolIndex, setMessageToToolIndex] = useState< + Record + >({}); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); @@ -130,10 +144,12 @@ export default function ThreadPage({ `[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 => { + setMessages((prev) => { // First check if the message already exists const messageExists = prev.some( (m) => m.message_id === message.message_id, @@ -155,34 +171,37 @@ export default function ThreadPage({ } }, []); - 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); - 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(() => { + 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); - }, 3000); - break; - } - }, [threadId]); + // Reset auto-opened state when agent completes to trigger tool detection + setAutoOpenedPanel(false); + 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}`); @@ -207,7 +226,11 @@ export default function ThreadPage({ } const currentMessage = messages[currentMessageIndex]; - console.log(`Playing message ${currentMessageIndex}:`, currentMessage.type, currentMessage.message_id); + console.log( + `Playing message ${currentMessageIndex}:`, + currentMessage.type, + currentMessage.message_id, + ); // Move to the next message setCurrentMessageIndex((prevIndex) => prevIndex + 1); @@ -260,7 +283,7 @@ export default function ThreadPage({ // Start loading all data in parallel const [threadData, messagesData] = await Promise.all([ - getThread(threadId).catch(err => { + getThread(threadId).catch((err) => { if (threadErrorCodeMessages[err.code]) { setError(threadErrorCodeMessages[err.code]); } else { @@ -268,7 +291,7 @@ export default function ThreadPage({ } return null; }), - getMessages(threadId).catch(err => { + getMessages(threadId).catch((err) => { console.warn('Failed to load messages:', err); return []; }), @@ -277,11 +300,12 @@ export default function ThreadPage({ if (!isMounted) return; // Load project data if we have a project ID - const projectData = threadData?.project_id ? - await getProject(threadData.project_id).catch(err => { - console.warn('[SHARE] Could not load project data:', err); - return null; - }) : null; + const projectData = threadData?.project_id + ? await getProject(threadData.project_id).catch((err) => { + console.warn('[SHARE] Could not load project data:', err); + return null; + }) + : null; if (isMounted) { if (projectData) { @@ -319,7 +343,9 @@ export default function ThreadPage({ // Calculate historical tool pairs const historicalToolPairs: ToolCallInput[] = []; - const assistantMessages = unifiedMessages.filter(m => m.type === 'assistant' && m.message_id); + const assistantMessages = unifiedMessages.filter( + (m) => m.type === 'assistant' && m.message_id, + ); // Map to track which assistant messages have tool results const assistantToolMap = new Map(); @@ -355,7 +381,8 @@ export default function ThreadPage({ assistantContent = { content: assistantMsg.content }; } - const assistantMessageText = 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); @@ -448,63 +475,84 @@ export default function ThreadPage({ return (
-
Assistant Message
+
+ Assistant Message +
-
{assistantContent}
+
+ {assistantContent} +
); }, []); // Process the tool result data - const toolViewResult = useCallback((toolContent?: string, isSuccess?: boolean) => { - if (!toolContent) return null; + const toolViewResult = useCallback( + (toolContent?: string, isSuccess?: boolean) => { + if (!toolContent) return null; - return ( -
-
-
Tool Result
-
- {isSuccess ? 'Success' : 'Failed'} + return ( +
+
+
+ Tool Result +
+
+ {isSuccess ? 'Success' : 'Failed'} +
+
+
+
+ {toolContent} +
-
-
{toolContent}
-
-
- ); - }, []); + ); + }, + [], + ); // Handle tool clicks - const handleToolClick = useCallback((clickedAssistantMessageId: string | null, clickedToolName: string) => { - // Explicitly ignore ask tags from opening the side panel - if (clickedToolName === 'ask') { - return; - } + 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; - } + 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; + // Reset user closed state when explicitly clicking a tool + userClosedPanelRef.current = false; - // Direct mapping using the message-to-tool-index map - const toolIndex = messageToToolIndex[clickedAssistantMessageId]; + // Direct mapping using the message-to-tool-index map + const toolIndex = messageToToolIndex[clickedAssistantMessageId]; - if (toolIndex !== undefined) { - setCurrentToolIndex(toolIndex); - setIsSidePanelOpen(true); - } else { - console.warn(`Could not find matching tool call for message ID: ${clickedAssistantMessageId}`); - toast.info("Could not find details for this tool call."); - } - }, [messageToToolIndex]); + if (toolIndex !== undefined) { + setCurrentToolIndex(toolIndex); + setIsSidePanelOpen(true); + } else { + console.warn( + `Could not find matching tool call for message ID: ${clickedAssistantMessageId}`, + ); + toast.info('Could not find details for this tool call.'); + } + }, + [messageToToolIndex], + ); const handleOpenFileViewer = useCallback((filePath?: string) => { if (filePath) { @@ -523,7 +571,7 @@ export default function ThreadPage({ toolCalls, setCurrentToolIndex, onFileViewerOpen: handleOpenFileViewer, - projectName: projectName || 'Shared Conversation' + projectName: projectName || 'Shared Conversation', }); // Extract the playback state and functions @@ -534,7 +582,7 @@ export default function ThreadPage({ renderWelcomeOverlay, togglePlayback, resetPlayback, - skipToEnd + skipToEnd, } = playbackController; // Connect playbackState to component state @@ -553,7 +601,8 @@ export default function ThreadPage({ // Scroll button visibility useEffect(() => { - if (!latestMessageRef.current || playbackState.visibleMessages.length === 0) return; + if (!latestMessageRef.current || playbackState.visibleMessages.length === 0) + return; const observer = new IntersectionObserver( ([entry]) => setShowScrollButton(!entry?.isIntersecting), { root: messagesContainerRef.current, threshold: 0.1 }, @@ -563,13 +612,21 @@ export default function ThreadPage({ }, [playbackState.visibleMessages, streamingText, currentToolCall]); useEffect(() => { - console.log(`[PAGE] 🔄 Page AgentStatus: ${agentStatus}, Hook Status: ${streamHookStatus}, Target RunID: ${agentRunId || 'none'}, Hook RunID: ${currentHookRunId || 'none'}`); + 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'); + 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); @@ -577,11 +634,14 @@ export default function ThreadPage({ }, [agentStatus, streamHookStatus, agentRunId, currentHookRunId]); // Auto-scroll function for use throughout the component - const autoScrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => { - if (!userHasScrolled && messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ behavior }); - } - }, [userHasScrolled]); + const autoScrollToBottom = useCallback( + (behavior: ScrollBehavior = 'smooth') => { + if (!userHasScrolled && messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior }); + } + }, + [userHasScrolled], + ); // Very direct approach to update the tool index during message playback useEffect(() => { @@ -613,7 +673,7 @@ export default function ThreadPage({ // Skip if not playing or no messages if (!isPlaying || messages.length === 0 || currentMessageIndex <= 0) return; - // Get all messages up to the current index + // 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 @@ -625,7 +685,9 @@ export default function ThreadPage({ const assistantId = metadata.assistant_message_id; if (assistantId) { - console.log(`Looking for tool panel for assistant message ${assistantId}`); + console.log( + `Looking for tool panel for assistant message ${assistantId}`, + ); // Scan for matching tool call for (let j = 0; j < toolCalls.length; j++) { @@ -648,7 +710,9 @@ export default function ThreadPage({ // Loading skeleton UI if (isLoading && !initialLoadCompleted.current) { - return ; + return ( + + ); } // Error state UI @@ -658,7 +722,7 @@ export default function ThreadPage({
-
+
@@ -671,7 +735,10 @@ export default function ThreadPage({

Error

{error}

-
@@ -701,7 +768,7 @@ export default function ThreadPage({ streamingText={playbackState.streamingText} isStreamingText={playbackState.isStreamingText} currentToolCall={playbackState.currentToolCall} - sandboxId={sandboxId || ""} + sandboxId={sandboxId || ''} project={project} /> diff --git a/frontend/src/components/thread/content/PlaybackControls.tsx b/frontend/src/components/thread/content/PlaybackControls.tsx index f1d80edf..47faa98f 100644 --- a/frontend/src/components/thread/content/PlaybackControls.tsx +++ b/frontend/src/components/thread/content/PlaybackControls.tsx @@ -3,516 +3,610 @@ import { Button } from '@/components/ui/button'; import { Play, Pause, ArrowDown, FileText, Info } from 'lucide-react'; import { UnifiedMessage } from '@/components/thread/types'; import { safeJsonParse } from '@/components/thread/utils'; +import Link from 'next/link'; // 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' + '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', ]); export interface PlaybackControlsProps { - messages: UnifiedMessage[]; - isSidePanelOpen: boolean; - onToggleSidePanel: () => void; - toolCalls: any[]; - setCurrentToolIndex: (index: number) => void; - onFileViewerOpen: () => void; - projectName?: string; + messages: UnifiedMessage[]; + isSidePanelOpen: boolean; + onToggleSidePanel: () => void; + toolCalls: any[]; + setCurrentToolIndex: (index: number) => void; + onFileViewerOpen: () => void; + projectName?: string; } export interface PlaybackState { - isPlaying: boolean; - currentMessageIndex: number; - visibleMessages: UnifiedMessage[]; - streamingText: string; - isStreamingText: boolean; - currentToolCall: any | null; - toolPlaybackIndex: number; + isPlaying: boolean; + currentMessageIndex: number; + visibleMessages: UnifiedMessage[]; + streamingText: string; + isStreamingText: boolean; + currentToolCall: any | null; + toolPlaybackIndex: number; } export interface PlaybackController { - playbackState: PlaybackState; - updatePlaybackState: (updates: Partial) => void; - renderHeader: () => JSX.Element; - renderFloatingControls: () => JSX.Element; - renderWelcomeOverlay: () => JSX.Element; - togglePlayback: () => void; - resetPlayback: () => void; - skipToEnd: () => void; + playbackState: PlaybackState; + updatePlaybackState: (updates: Partial) => void; + renderHeader: () => JSX.Element; + renderFloatingControls: () => JSX.Element; + renderWelcomeOverlay: () => JSX.Element; + togglePlayback: () => void; + resetPlayback: () => void; + skipToEnd: () => void; } export const PlaybackControls = ({ - messages, - isSidePanelOpen, - onToggleSidePanel, - toolCalls, - setCurrentToolIndex, - onFileViewerOpen, - projectName = 'Shared Conversation' + messages, + isSidePanelOpen, + onToggleSidePanel, + toolCalls, + setCurrentToolIndex, + onFileViewerOpen, + projectName = 'Shared Conversation', }: PlaybackControlsProps): PlaybackController => { - const [playbackState, setPlaybackState] = useState({ - isPlaying: false, - currentMessageIndex: 0, - visibleMessages: [], - streamingText: "", - isStreamingText: false, - currentToolCall: null, - toolPlaybackIndex: -1 + const [playbackState, setPlaybackState] = useState({ + isPlaying: false, + currentMessageIndex: 0, + visibleMessages: [], + streamingText: '', + isStreamingText: false, + currentToolCall: null, + toolPlaybackIndex: -1, + }); + + // Extract state variables for easier access + const { + isPlaying, + currentMessageIndex, + visibleMessages, + streamingText, + isStreamingText, + currentToolCall, + toolPlaybackIndex, + } = playbackState; + + // Helper function to update playback state + const updatePlaybackState = useCallback((updates: Partial) => { + setPlaybackState((prev) => ({ ...prev, ...updates })); + }, []); + + // Define togglePlayback and resetPlayback functions + const togglePlayback = useCallback(() => { + updatePlaybackState({ + isPlaying: !isPlaying, }); - // Extract state variables for easier access - const { - isPlaying, - currentMessageIndex, - visibleMessages, - streamingText, - isStreamingText, - currentToolCall, - toolPlaybackIndex - } = playbackState; + // When starting playback, show the side panel + if (!isPlaying && !isSidePanelOpen) { + onToggleSidePanel(); + } + }, [isPlaying, isSidePanelOpen, onToggleSidePanel]); - // Helper function to update playback state - const updatePlaybackState = useCallback((updates: Partial) => { - setPlaybackState(prev => ({ ...prev, ...updates })); - }, []); + const resetPlayback = useCallback(() => { + updatePlaybackState({ + isPlaying: false, + currentMessageIndex: 0, + visibleMessages: [], + streamingText: '', + isStreamingText: false, + currentToolCall: null, + toolPlaybackIndex: -1, + }); + }, [updatePlaybackState]); - // Define togglePlayback and resetPlayback functions - const togglePlayback = useCallback(() => { - updatePlaybackState({ - isPlaying: !isPlaying - }); + const skipToEnd = useCallback(() => { + updatePlaybackState({ + isPlaying: false, + currentMessageIndex: messages.length, + visibleMessages: messages, + streamingText: '', + isStreamingText: false, + currentToolCall: null, + toolPlaybackIndex: toolCalls.length - 1, + }); - // When starting playback, show the side panel - if (!isPlaying && !isSidePanelOpen) { - onToggleSidePanel(); + if (toolCalls.length > 0) { + setCurrentToolIndex(toolCalls.length - 1); + if (!isSidePanelOpen) { + onToggleSidePanel(); + } + } + }, [ + messages, + toolCalls, + isSidePanelOpen, + onToggleSidePanel, + setCurrentToolIndex, + updatePlaybackState, + ]); + + // Streaming text function + const streamText = useCallback( + (text: string, onComplete: () => void) => { + if (!text || !isPlaying) { + onComplete(); + return () => {}; + } + + updatePlaybackState({ + isStreamingText: true, + streamingText: '', + }); + + // 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, + }); } - }, [isPlaying, isSidePanelOpen, onToggleSidePanel]); - const resetPlayback = useCallback(() => { - updatePlaybackState({ - isPlaying: false, - currentMessageIndex: 0, - visibleMessages: [], - streamingText: "", - isStreamingText: false, - currentToolCall: null, - toolPlaybackIndex: -1 - }); - }, [updatePlaybackState]); - - const skipToEnd = useCallback(() => { - updatePlaybackState({ - isPlaying: false, - currentMessageIndex: messages.length, - visibleMessages: messages, - streamingText: "", - isStreamingText: false, - currentToolCall: null, - toolPlaybackIndex: toolCalls.length - 1 + // Add the tool call + const toolName = match[1] || match[2]; + chunks.push({ + text: match[0], + isTool: true, + toolName, }); - if (toolCalls.length > 0) { - setCurrentToolIndex(toolCalls.length - 1); + 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 = () => { + // Check if component is unmounted or playback is stopped + if (!isPlaying || isPaused) { + setTimeout(processNextCharacter, 100); // Check again after a short delay + return; + } + + if (chunkIndex >= chunks.length) { + // All chunks processed, we're done + updatePlaybackState({ + isStreamingText: false, + }); + + // Update visible messages with the complete message + const currentMessage = messages[currentMessageIndex]; + const lastMessage = visibleMessages[visibleMessages.length - 1]; + + if (lastMessage?.message_id === currentMessage.message_id) { + // Replace the streaming message with the complete one + updatePlaybackState({ + visibleMessages: [ + ...visibleMessages.slice(0, -1), + currentMessage, + ], + }); + } else { + // Add the complete message + updatePlaybackState({ + visibleMessages: [...visibleMessages, 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 = { + name: currentChunk.toolName, + arguments: currentChunk.text, + xml_tag_name: currentChunk.toolName, + }; + + updatePlaybackState({ + currentToolCall: toolCall, + toolPlaybackIndex: toolPlaybackIndex + 1, + }); + if (!isSidePanelOpen) { - onToggleSidePanel(); + onToggleSidePanel(); } - } - }, [messages, toolCalls, isSidePanelOpen, onToggleSidePanel, setCurrentToolIndex, updatePlaybackState]); - // Streaming text function - const streamText = useCallback((text: string, onComplete: () => void) => { - if (!text || !isPlaying) { - onComplete(); - return () => { }; + setCurrentToolIndex(toolPlaybackIndex + 1); + + // Pause streaming briefly while showing the tool + isPaused = true; + setTimeout(() => { + isPaused = false; + updatePlaybackState({ currentToolCall: 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]; + updatePlaybackState({ streamingText: 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 () => { updatePlaybackState({ - isStreamingText: true, - streamingText: "" + isStreamingText: false, + streamingText: '', + }); + isPaused = true; // Stop processing + }; + }, + [ + isPlaying, + messages, + currentMessageIndex, + toolPlaybackIndex, + setCurrentToolIndex, + isSidePanelOpen, + onToggleSidePanel, + updatePlaybackState, + visibleMessages, + ], + ); + + // Main playback function + useEffect(() => { + if (!isPlaying || messages.length === 0) return; + + let playbackTimeout: NodeJS.Timeout; + let cleanupStreaming: (() => void) | undefined; + + const playbackNextMessage = async () => { + // Ensure we're within bounds + if (currentMessageIndex >= messages.length) { + updatePlaybackState({ isPlaying: 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) => { + cleanupStreaming = streamText(content, resolve); + }); + } catch (error) { + console.error('Error streaming message:', error); + } + } else { + // For non-assistant messages, just add them to visible messages + updatePlaybackState({ + visibleMessages: [...visibleMessages, currentMessage], }); - // Define regex to find tool calls in text - const toolCallRegex = /<([a-zA-Z\-_]+)(?:\s+[^>]*)?>(?:[\s\S]*?)<\/\1>|<([a-zA-Z\-_]+)(?:\s+[^>]*)?\/>/g; + // Wait a moment before showing the next message + await new Promise((resolve) => setTimeout(resolve, 500)); + } - // 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 = () => { - // Check if component is unmounted or playback is stopped - if (!isPlaying || isPaused) { - setTimeout(processNextCharacter, 100); // Check again after a short delay - return; - } - - if (chunkIndex >= chunks.length) { - // All chunks processed, we're done - updatePlaybackState({ - isStreamingText: false - }); - - // Update visible messages with the complete message - const currentMessage = messages[currentMessageIndex]; - const lastMessage = visibleMessages[visibleMessages.length - 1]; - - if (lastMessage?.message_id === currentMessage.message_id) { - // Replace the streaming message with the complete one - updatePlaybackState({ - visibleMessages: [...visibleMessages.slice(0, -1), currentMessage] - }); - } else { - // Add the complete message - updatePlaybackState({ - visibleMessages: [...visibleMessages, 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 = { - name: currentChunk.toolName, - arguments: currentChunk.text, - xml_tag_name: currentChunk.toolName - }; - - updatePlaybackState({ - currentToolCall: toolCall, - toolPlaybackIndex: toolPlaybackIndex + 1 - }); - - if (!isSidePanelOpen) { - onToggleSidePanel(); - } - - setCurrentToolIndex(toolPlaybackIndex + 1); - - // Pause streaming briefly while showing the tool - isPaused = true; - setTimeout(() => { - isPaused = false; - updatePlaybackState({ currentToolCall: 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]; - updatePlaybackState({ streamingText: 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 () => { - updatePlaybackState({ - isStreamingText: false, - streamingText: "" - }); - isPaused = true; // Stop processing - }; - }, [isPlaying, messages, currentMessageIndex, toolPlaybackIndex, setCurrentToolIndex, isSidePanelOpen, onToggleSidePanel, updatePlaybackState, visibleMessages]); - - // Main playback function - useEffect(() => { - if (!isPlaying || messages.length === 0) return; - - let playbackTimeout: NodeJS.Timeout; - let cleanupStreaming: (() => void) | undefined; - - const playbackNextMessage = async () => { - // Ensure we're within bounds - if (currentMessageIndex >= messages.length) { - updatePlaybackState({ isPlaying: 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) => { - cleanupStreaming = streamText(content, resolve); - }); - } catch (error) { - console.error('Error streaming message:', error); - } - } else { - // For non-assistant messages, just add them to visible messages - updatePlaybackState({ - visibleMessages: [...visibleMessages, currentMessage] - }); - - // Wait a moment before showing the next message - await new Promise(resolve => setTimeout(resolve, 500)); - } - - // Move to the next message - updatePlaybackState({ - currentMessageIndex: currentMessageIndex + 1 - }); - }; - - // Start playback with a small delay - playbackTimeout = setTimeout(playbackNextMessage, 500); - - return () => { - clearTimeout(playbackTimeout); - if (cleanupStreaming) cleanupStreaming(); - }; - }, [isPlaying, currentMessageIndex, messages, streamText, updatePlaybackState, visibleMessages]); - - // Floating playback controls position based on side panel state - const controlsPositionClass = isSidePanelOpen - ? 'left-1/2 -translate-x-1/4 sm:left-[calc(50%-225px)] md:left-[calc(50%-250px)] lg:left-[calc(50%-275px)] xl:left-[calc(50%-325px)]' - : 'left-1/2 -translate-x-1/2'; - - // Header with playback controls - const renderHeader = useCallback(() => ( -
-
-
-
-
- Kortix -
- {projectName} -
-
-
- - - - -
-
-
- ), [isPlaying, isSidePanelOpen, onFileViewerOpen, onToggleSidePanel, projectName, resetPlayback, togglePlayback]); - - const renderFloatingControls = useCallback(() => ( - <> - {messages.length > 0 && ( -
-
- - -
- {Math.min(currentMessageIndex + (isStreamingText ? 0 : 1), messages.length)}/{messages.length} -
- - - - -
-
- )} - - ), [controlsPositionClass, currentMessageIndex, isPlaying, isStreamingText, messages.length, resetPlayback, skipToEnd, togglePlayback]); - - // When no messages are displayed yet, show the welcome overlay - const renderWelcomeOverlay = useCallback(() => ( - <> - {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. -

- -
-
- )} - - ), [currentToolCall, streamingText, togglePlayback, visibleMessages.length]); - - return { - playbackState, - updatePlaybackState, - renderHeader, - renderFloatingControls, - renderWelcomeOverlay, - togglePlayback, - resetPlayback, - skipToEnd + // Move to the next message + updatePlaybackState({ + currentMessageIndex: currentMessageIndex + 1, + }); }; + + // Start playback with a small delay + playbackTimeout = setTimeout(playbackNextMessage, 500); + + return () => { + clearTimeout(playbackTimeout); + if (cleanupStreaming) cleanupStreaming(); + }; + }, [ + isPlaying, + currentMessageIndex, + messages, + streamText, + updatePlaybackState, + visibleMessages, + ]); + + // Floating playback controls position based on side panel state + const controlsPositionClass = isSidePanelOpen + ? 'left-1/2 -translate-x-1/4 sm:left-[calc(50%-225px)] md:left-[calc(50%-250px)] lg:left-[calc(50%-275px)] xl:left-[calc(50%-325px)]' + : 'left-1/2 -translate-x-1/2'; + + // Header with playback controls + const renderHeader = useCallback( + () => ( +
+
+
+
+
+ + Kortix + +
+

+ + {projectName} + +

+
+
+
+ + + + +
+
+
+ ), + [ + isPlaying, + isSidePanelOpen, + onFileViewerOpen, + onToggleSidePanel, + projectName, + resetPlayback, + togglePlayback, + ], + ); + + const renderFloatingControls = useCallback( + () => ( + <> + {messages.length > 0 && ( +
+
+ + +
+ + {Math.min( + currentMessageIndex + (isStreamingText ? 0 : 1), + messages.length, + )} + /{messages.length} + +
+ + + + +
+
+ )} + + ), + [ + controlsPositionClass, + currentMessageIndex, + isPlaying, + isStreamingText, + messages.length, + resetPlayback, + skipToEnd, + togglePlayback, + ], + ); + + // When s are displayed yet, show the welcome overlay + const renderWelcomeOverlay = useCallback( + () => ( + <> + {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. +

+ +
+
+ )} + + ), + [currentToolCall, streamingText, togglePlayback, visibleMessages.length], + ); + + return { + playbackState, + updatePlaybackState, + renderHeader, + renderFloatingControls, + renderWelcomeOverlay, + togglePlayback, + resetPlayback, + skipToEnd, + }; }; -export default PlaybackControls; \ No newline at end of file +export default PlaybackControls; From 0658f3f4d8688e7f43eb9c6c72f602eaeecbc5bc Mon Sep 17 00:00:00 2001 From: Rishi Date: Sat, 17 May 2025 10:21:18 +0200 Subject: [PATCH 2/2] remove url tag --- frontend/src/app/share/[threadId]/layout.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/app/share/[threadId]/layout.tsx b/frontend/src/app/share/[threadId]/layout.tsx index 18564e41..714e840d 100644 --- a/frontend/src/app/share/[threadId]/layout.tsx +++ b/frontend/src/app/share/[threadId]/layout.tsx @@ -10,7 +10,6 @@ export async function generateMetadata({ params }): Promise { canonical: `${process.env.NEXT_PUBLIC_URL}/share/${threadId}`, }, openGraph: { - url: `${process.env.NEXT_PUBLIC_URL}/share/${threadId}`, title: 'Shared Conversation | Kortix Suna', description: 'Replay this Agent conversation on Kortix Suna', images: [`${process.env.NEXT_PUBLIC_URL}/share-page/og-fallback.png`],