import React, { useCallback, useEffect, useState, useRef } from 'react'; import { Button } from '@/components/ui/button'; import { Play, Pause, ArrowDown, FileText, PanelRightOpen, ArrowUp, } from 'lucide-react'; import { UnifiedMessage } from '@/components/thread/types'; import { safeJsonParse } from '@/components/thread/utils'; import Link from 'next/link'; import { parseXmlToolCalls } from '../tool-views/xml-parser'; import { HIDE_STREAMING_XML_TAGS } from '@/components/thread/utils'; export interface PlaybackControlsProps { messages: UnifiedMessage[]; isSidePanelOpen: boolean; onToggleSidePanel: () => void; toolCalls: any[]; setCurrentToolIndex: React.Dispatch>; onFileViewerOpen: () => void; projectName?: string; } export interface PlaybackState { isPlaying: boolean; currentMessageIndex: number; visibleMessages: UnifiedMessage[]; streamingText: string; isStreamingText: boolean; currentToolCall: any | null; } export interface PlaybackController { playbackState: PlaybackState; updatePlaybackState: (updates: Partial) => void; renderHeader: () => JSX.Element; renderFloatingControls: () => JSX.Element; renderWelcomeOverlay: () => JSX.Element; togglePlayback: () => void; resetPlayback: () => void; skipToEnd: () => void; forward: (step?: number) => 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, }); // Extract state variables for easier access const { isPlaying, currentMessageIndex, visibleMessages, streamingText, isStreamingText, currentToolCall, } = playbackState; const playbackTimeout = useRef(null); const [isToolInitialized, setIsToolInitialized] = useState(false); // 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, updatePlaybackState]); const resetPlayback = useCallback(() => { updatePlaybackState({ isPlaying: false, currentMessageIndex: 0, visibleMessages: [], streamingText: '', isStreamingText: false, currentToolCall: null, }); setCurrentToolIndex(0); setIsToolInitialized(false); if (playbackTimeout.current) { clearTimeout(playbackTimeout.current); } // If the side panel is open, close it if (isSidePanelOpen) { onToggleSidePanel(); } }, [ updatePlaybackState, setCurrentToolIndex, isSidePanelOpen, onToggleSidePanel, ]); const forward = useCallback( (step: number = 1) => { const newMessageIndex = Math.min( currentMessageIndex + step, messages.length, ); if (!isSidePanelOpen) { onToggleSidePanel(); } // If we're moving to a new message, update the visible messages if (newMessageIndex > currentMessageIndex) { const newVisibleMessages = messages.slice(0, newMessageIndex); updatePlaybackState({ currentMessageIndex: newMessageIndex, visibleMessages: newVisibleMessages, streamingText: '', isStreamingText: false, }); } // If we're at the end, stop playback if (newMessageIndex >= messages.length) { updatePlaybackState({ isPlaying: false }); } }, [ currentMessageIndex, messages, isSidePanelOpen, onToggleSidePanel, updatePlaybackState, ], ); const skipToEnd = useCallback(() => { updatePlaybackState({ isPlaying: false, currentMessageIndex: messages.length, visibleMessages: messages, streamingText: '', isStreamingText: false, currentToolCall: null, }); 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 chunks.push({ text: match[0], isTool: true, }); 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 (isToolInitialized) { // TODO: better to change tool index by uniq tool id setCurrentToolIndex((prev) => prev + 1); } else { setIsToolInitialized(true); } // 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, setCurrentToolIndex, isSidePanelOpen, onToggleSidePanel, updatePlaybackState, visibleMessages, ], ); // Main playback function useEffect(() => { if (!isPlaying || messages.length === 0) return; let cleanupStreaming: (() => void) | undefined; const playbackNextMessage = async () => { // Ensure we're within bounds if (currentMessageIndex >= messages.length) { updatePlaybackState({ isPlaying: false }); return; } const currentMessage = messages[currentMessageIndex]; // 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.current = setTimeout(playbackNextMessage, 500); return () => { clearTimeout(playbackTimeout.current); 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'; const PlayButton = useCallback( () => ( ), [isPlaying, togglePlayback, currentMessageIndex, messages], ); const ForwardButton = useCallback( () => ( ), [currentMessageIndex, messages, forward], ); const ResetButton = useCallback( () => ( ), [currentMessageIndex, resetPlayback], ); // Header with playback controls const renderHeader = useCallback( () => (
Kortix

{projectName}

), [ isSidePanelOpen, onToggleSidePanel, projectName, PlayButton, ResetButton, ForwardButton, ], ); const renderFloatingControls = useCallback( () => ( <> {messages.length > 0 && (
{Math.max(1, Math.min(currentMessageIndex, messages.length))}/ {messages.length}
)} ), [ controlsPositionClass, currentMessageIndex, messages.length, skipToEnd, PlayButton, ResetButton, ForwardButton, ], ); // 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, forward, }; }; export default PlaybackControls;