import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { Card, CardContent, CardHeader, CardTitle, } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Presentation, Clock, Loader2, CheckCircle, AlertTriangle, FileText, Hash, Maximize2, Download, ExternalLink, ChevronDown, } from 'lucide-react'; import { ToolViewProps } from '../types'; import { formatTimestamp, extractToolData, getToolTitle } from '../utils'; import { constructHtmlPreviewUrl } from '@/lib/utils/url'; import { CodeBlockCode } from '@/components/ui/code-block'; import { LoadingState } from '../shared/LoadingState'; import { FullScreenPresentationViewer } from './FullScreenPresentationViewer'; import { toast } from 'sonner'; interface SlideMetadata { title: string; filename: string; file_path: string; preview_url: string; created_at: string; } interface PresentationMetadata { presentation_name: string; title: string; description: string; slides: Record; created_at: string; updated_at: string; } interface PresentationViewerProps extends ToolViewProps { // All data will be extracted from toolContent } export function PresentationViewer({ assistantContent, toolContent, assistantTimestamp, toolTimestamp, isSuccess = true, isStreaming = false, name, project, }: PresentationViewerProps) { const [metadata, setMetadata] = useState(null); const [isLoadingMetadata, setIsLoadingMetadata] = useState(false); const [error, setError] = useState(null); const [retryAttempt, setRetryAttempt] = useState(0); const [hasScrolledToCurrentSlide, setHasScrolledToCurrentSlide] = useState(false); const [backgroundRetryInterval, setBackgroundRetryInterval] = useState(null); const [visibleSlide, setVisibleSlide] = useState(null); const [isFullScreenOpen, setIsFullScreenOpen] = useState(false); const [fullScreenInitialSlide, setFullScreenInitialSlide] = useState(null); const [isDownloadingPDF, setIsDownloadingPDF] = useState(false); // Extract presentation info from tool data const { toolResult } = extractToolData(toolContent); let extractedPresentationName: string | undefined; let extractedPresentationPath: string | undefined; let currentSlideNumber: number | undefined; let presentationTitle: string | undefined; let toolExecutionError: string | undefined; if (toolResult && toolResult.toolOutput && toolResult.toolOutput !== 'STREAMING') { try { let output; if (typeof toolResult.toolOutput === 'string') { // Check if the string looks like an error message if (toolResult.toolOutput.startsWith('Error') || toolResult.toolOutput.includes('exec')) { console.error('Tool execution error:', toolResult.toolOutput); toolExecutionError = toolResult.toolOutput; // Don't return early - let the component render the error state } else { // Try to parse as JSON try { output = JSON.parse(toolResult.toolOutput); } catch (parseError) { console.error('Failed to parse tool output as JSON:', parseError); console.error('Raw tool output:', toolResult.toolOutput); toolExecutionError = `Failed to parse tool output: ${toolResult.toolOutput}`; // Don't return early - let the component render the error state } } } else { output = toolResult.toolOutput; } // Only extract data if we have a valid parsed object if (output && typeof output === 'object') { extractedPresentationName = output.presentation_name; extractedPresentationPath = output.presentation_path; currentSlideNumber = output.slide_number; presentationTitle = output.presentation_title || output.title; } } catch (e) { console.error('Failed to process tool output:', e); console.error('Tool output type:', typeof toolResult.toolOutput); console.error('Tool output value:', toolResult.toolOutput); toolExecutionError = `Unexpected error processing tool output: ${String(e)}`; } } // Get tool title for display const toolTitle = getToolTitle(name || 'presentation-viewer'); // Helper function to sanitize filename (matching backend logic) const sanitizeFilename = (name: string): string => { return name.replace(/[^a-zA-Z0-9\-_]/g, '').toLowerCase(); }; // Load metadata.json for the presentation with retry logic const loadMetadata = async (retryCount = 0, maxRetries = 5) => { if (!extractedPresentationName || !project?.sandbox?.sandbox_url) return; setIsLoadingMetadata(true); setError(null); setRetryAttempt(retryCount); try { // Sanitize the presentation name to match backend directory creation const sanitizedPresentationName = sanitizeFilename(extractedPresentationName); const metadataUrl = constructHtmlPreviewUrl( project.sandbox.sandbox_url, `presentations/${sanitizedPresentationName}/metadata.json` ); // Add cache-busting parameter to ensure fresh data const urlWithCacheBust = `${metadataUrl}?t=${Date.now()}`; console.log(`Loading presentation metadata (attempt ${retryCount + 1}/${maxRetries + 1}):`, urlWithCacheBust); const response = await fetch(urlWithCacheBust, { cache: 'no-cache', headers: { 'Cache-Control': 'no-cache' } }); if (response.ok) { const data = await response.json(); setMetadata(data); console.log('Successfully loaded presentation metadata:', data); setIsLoadingMetadata(false); // Clear background retry interval on success if (backgroundRetryInterval) { clearInterval(backgroundRetryInterval); setBackgroundRetryInterval(null); } return; // Success, exit early } else { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } } catch (err) { console.error(`Error loading metadata (attempt ${retryCount + 1}):`, err); // If we haven't reached max retries, try again with exponential backoff if (retryCount < maxRetries) { const delay = Math.min(1000 * Math.pow(2, retryCount), 10000); // Cap at 10 seconds console.log(`Retrying in ${delay}ms...`); setTimeout(() => { loadMetadata(retryCount + 1, maxRetries); }, delay); return; // Don't set error state yet, we're retrying } // All retries exhausted, set error and start background retry setError('Failed to load presentation metadata after multiple attempts'); setIsLoadingMetadata(false); // Start background retry every 10 seconds if (!backgroundRetryInterval) { const interval = setInterval(() => { console.log('Background retry attempt...'); loadMetadata(0, 2); // Fewer retries for background attempts }, 10000); setBackgroundRetryInterval(interval); } } }; useEffect(() => { // Clear any existing background retry when dependencies change if (backgroundRetryInterval) { clearInterval(backgroundRetryInterval); setBackgroundRetryInterval(null); } loadMetadata(); }, [extractedPresentationName, project?.sandbox?.sandbox_url, toolContent]); // Cleanup background retry interval on unmount useEffect(() => { return () => { if (backgroundRetryInterval) { clearInterval(backgroundRetryInterval); } }; }, [backgroundRetryInterval]); // Reset scroll state when tool content changes (new tool call) useEffect(() => { setHasScrolledToCurrentSlide(false); }, [toolContent, currentSlideNumber]); // Scroll to current slide when metadata loads or when tool content changes useEffect(() => { if (metadata && currentSlideNumber && !hasScrolledToCurrentSlide) { // Wait longer for memoized components to render scrollToCurrentSlide(800); setHasScrolledToCurrentSlide(true); } }, [metadata, currentSlideNumber, hasScrolledToCurrentSlide]); const slides = metadata ? Object.entries(metadata.slides) .map(([num, slide]) => ({ number: parseInt(num), ...slide })) .sort((a, b) => a.number - b.number) : []; // Additional effect to scroll when slides are actually rendered useEffect(() => { if (slides.length > 0 && currentSlideNumber && metadata && !hasScrolledToCurrentSlide) { // Extra delay to ensure DOM is fully rendered const timer = setTimeout(() => { scrollToCurrentSlide(100); setHasScrolledToCurrentSlide(true); }, 1000); return () => clearTimeout(timer); } }, [slides.length, currentSlideNumber, metadata, hasScrolledToCurrentSlide]); // Scroll-based slide detection with proper edge handling useEffect(() => { if (!slides.length) return; // Initialize with first slide setVisibleSlide(slides[0].number); const handleScroll = () => { const scrollArea = document.querySelector('[data-radix-scroll-area-viewport]'); if (!scrollArea || slides.length === 0) return; const { scrollTop, scrollHeight, clientHeight } = scrollArea; const scrollViewportRect = scrollArea.getBoundingClientRect(); const viewportCenter = scrollViewportRect.top + scrollViewportRect.height / 2; // Check if we're at the very top (first slide) if (scrollTop <= 10) { setVisibleSlide(slides[0].number); return; } // Check if we're at the very bottom (last slide) if (scrollTop + clientHeight >= scrollHeight - 10) { setVisibleSlide(slides[slides.length - 1].number); return; } // For middle slides, find the slide closest to the viewport center let closestSlide = slides[0]; let smallestDistance = Infinity; slides.forEach((slide) => { const slideElement = document.getElementById(`slide-${slide.number}`); if (!slideElement) return; const slideRect = slideElement.getBoundingClientRect(); const slideCenter = slideRect.top + slideRect.height / 2; const distanceFromCenter = Math.abs(slideCenter - viewportCenter); // Only consider slides that are at least partially visible const isPartiallyVisible = slideRect.bottom > scrollViewportRect.top && slideRect.top < scrollViewportRect.bottom; if (isPartiallyVisible && distanceFromCenter < smallestDistance) { smallestDistance = distanceFromCenter; closestSlide = slide; } }); setVisibleSlide(closestSlide.number); }; // Debounce scroll handler for better performance let scrollTimeout: NodeJS.Timeout; const debouncedHandleScroll = () => { clearTimeout(scrollTimeout); scrollTimeout = setTimeout(handleScroll, 50); }; const scrollArea = document.querySelector('[data-radix-scroll-area-viewport]'); if (scrollArea) { scrollArea.addEventListener('scroll', debouncedHandleScroll); // Run once immediately to set initial state handleScroll(); } return () => { clearTimeout(scrollTimeout); if (scrollArea) { scrollArea.removeEventListener('scroll', debouncedHandleScroll); } }; }, [slides]); // Helper function to scroll to current slide const scrollToCurrentSlide = (delay: number = 200) => { if (!currentSlideNumber || !metadata) return; setTimeout(() => { const slideElement = document.getElementById(`slide-${currentSlideNumber}`); if (slideElement) { slideElement.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); } else { // Fallback: try again after a longer delay if element not found yet setTimeout(() => { const retryElement = document.getElementById(`slide-${currentSlideNumber}`); if (retryElement) { retryElement.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); } }, 500); } }, delay); }; // Create a refresh timestamp when metadata changes const refreshTimestamp = useMemo(() => Date.now(), [metadata]); // Memoized slide iframe component to prevent unnecessary re-renders const SlideIframe = useMemo(() => { const SlideIframeComponent = React.memo(({ slide }: { slide: SlideMetadata & { number: number } }) => { const [containerRef, setContainerRef] = useState(null); const [scale, setScale] = useState(1); useEffect(() => { if (containerRef) { const updateScale = () => { const containerWidth = containerRef.offsetWidth; const containerHeight = containerRef.offsetHeight; // Calculate scale to fit 1920x1080 into container while maintaining aspect ratio const scaleX = containerWidth / 1920; const scaleY = containerHeight / 1080; const newScale = Math.min(scaleX, scaleY); // Only update if scale actually changed to prevent unnecessary re-renders if (Math.abs(newScale - scale) > 0.001) { setScale(newScale); } }; // Use a debounced version for resize events to prevent excessive updates let resizeTimeout: NodeJS.Timeout; const debouncedUpdateScale = () => { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(updateScale, 100); }; updateScale(); window.addEventListener('resize', debouncedUpdateScale); return () => { window.removeEventListener('resize', debouncedUpdateScale); clearTimeout(resizeTimeout); }; } }, [containerRef, scale]); if (!project?.sandbox?.sandbox_url) { return (

No slide content to preview

); } const slideUrl = constructHtmlPreviewUrl(project.sandbox.sandbox_url, slide.file_path); // Add cache-busting to iframe src to ensure fresh content const slideUrlWithCacheBust = `${slideUrl}?t=${refreshTimestamp}`; return (