import React, { useCallback, useEffect, useState, useRef } from 'react'; 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', ]); export interface PlaybackControlsProps { 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; } export interface PlaybackController { 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', }: PlaybackControlsProps): PlaybackController => { 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, }); // When starting playback, show the side panel if (!isPlaying && !isSidePanelOpen) { onToggleSidePanel(); } }, [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, }); 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, }); } // 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 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;