suna/frontend/src/components/thread/tool-views/presentation-tools/PresentationViewer.tsx

788 lines
30 KiB
TypeScript

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<string, SlideMetadata>;
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<PresentationMetadata | null>(null);
const [isLoadingMetadata, setIsLoadingMetadata] = useState(false);
const [error, setError] = useState<string | null>(null);
const [retryAttempt, setRetryAttempt] = useState(0);
const [hasScrolledToCurrentSlide, setHasScrolledToCurrentSlide] = useState(false);
const [backgroundRetryInterval, setBackgroundRetryInterval] = useState<NodeJS.Timeout | null>(null);
const [visibleSlide, setVisibleSlide] = useState<number | null>(null);
const [isFullScreenOpen, setIsFullScreenOpen] = useState(false);
const [fullScreenInitialSlide, setFullScreenInitialSlide] = useState<number | null>(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<HTMLDivElement | null>(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 (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<Presentation className="h-12 w-12 mx-auto mb-4 text-zinc-400" />
<p className="text-sm text-zinc-500 dark:text-zinc-400">No slide content to preview</p>
</div>
</div>
);
}
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 (
<div className="w-full h-full flex items-center justify-center bg-transparent p-4">
<div
ref={setContainerRef}
className="relative bg-white dark:bg-zinc-900 rounded-lg overflow-hidden border border-zinc-200/40 dark:border-zinc-800/40"
style={{
width: '100%',
maxWidth: '90vw',
aspectRatio: '16 / 9',
maxHeight: 'calc(100vh - 12rem)',
containIntrinsicSize: '1920px 1080px',
contain: 'layout style'
}}
>
<iframe
key={`slide-${slide.number}-${refreshTimestamp}`} // Key with stable timestamp ensures iframe refreshes when metadata changes
src={slideUrlWithCacheBust}
title={`Slide ${slide.number}: ${slide.title}`}
className="border-0 rounded-xl"
sandbox="allow-same-origin allow-scripts"
style={{
width: '1920px',
height: '1080px',
border: 'none',
display: 'block',
transform: `scale(${scale})`,
transformOrigin: '0 0',
position: 'absolute',
top: 0,
left: 0,
willChange: 'transform',
backfaceVisibility: 'hidden',
WebkitBackfaceVisibility: 'hidden'
}}
/>
</div>
</div>
);
}, (prevProps, nextProps) => {
// Custom comparison function - only re-render if slide number or file_path changes
return prevProps.slide.number === nextProps.slide.number &&
prevProps.slide.file_path === nextProps.slide.file_path;
});
SlideIframeComponent.displayName = 'SlideIframeComponent';
return SlideIframeComponent;
}, [project?.sandbox?.sandbox_url, refreshTimestamp]);
// Render individual slide using the original approach
const renderSlidePreview = useCallback((slide: SlideMetadata & { number: number }) => {
return <SlideIframe slide={slide} />;
}, [SlideIframe]);
const downloadPresentationAsPDF = async (sandboxUrl: string, presentationName: string): Promise<void> => {
try {
const response = await fetch(`${sandboxUrl}/presentation/convert-to-pdf`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
presentation_path: `/workspace/presentations/${presentationName}`,
download: true
})
});
if (!response.ok) {
throw new Error('Failed to download PDF');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${presentationName}.pdf`;
a.click();
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Error downloading PDF:', error);
// You could show a toast notification here
toast.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
const handlePDFDownload = useCallback(async (setIsDownloadingPDF: (isDownloading: boolean) => void) => {
if (!project?.sandbox?.sandbox_url || !extractedPresentationName) return;
setIsDownloadingPDF(true);
try{
await downloadPresentationAsPDF(project.sandbox.sandbox_url, extractedPresentationName);
} catch (error) {
console.error('Error downloading PDF:', error);
} finally {
setIsDownloadingPDF(false);
}
}, [project?.sandbox?.sandbox_url, extractedPresentationName]);
return (
<Card className="gap-0 flex border shadow-none border-t border-b-0 border-x-0 p-0 rounded-none flex-col h-full overflow-hidden bg-card">
<CardHeader className="h-14 bg-zinc-50/80 dark:bg-zinc-900/80 backdrop-blur-sm border-b p-2 px-4 space-y-2">
<div className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<div className="relative p-2 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 border border-blue-500/20">
<Presentation className="w-5 h-5 text-blue-500 dark:text-blue-400" />
</div>
<div>
<CardTitle className="text-base font-medium text-zinc-900 dark:text-zinc-100">
{metadata?.title || metadata?.presentation_name || toolTitle}
</CardTitle>
</div>
</div>
<div className="flex items-center gap-2">
{/* Export actions */}
{metadata && slides.length > 0 && !isStreaming && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => {
setFullScreenInitialSlide(visibleSlide || currentSlideNumber || slides[0]?.number || 1);
setIsFullScreenOpen(true);
}}
className="h-8 w-8 p-0"
title="Open in full screen"
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
title="Export presentation"
>
{isDownloadingPDF ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem
onClick={() => handlePDFDownload(setIsDownloadingPDF)}
className="cursor-pointer"
>
<FileText className="h-4 w-4 mr-2" />
PDF
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
// TODO: Implement PPTX export
console.log('Export as PPTX');
}}
className="cursor-pointer"
>
<Presentation className="h-4 w-4 mr-2" />
PPTX
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
// TODO: Implement Google Slides export
console.log('Export to Google Slides');
}}
className="cursor-pointer"
>
<ExternalLink className="h-4 w-4 mr-2" />
Google Slides
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
{!isStreaming && (
<Badge
variant="secondary"
className="bg-gradient-to-b from-emerald-200 to-emerald-100 text-emerald-700 dark:from-emerald-800/50 dark:to-emerald-900/60 dark:text-emerald-300"
>
<CheckCircle className="h-3.5 w-3.5 mr-1" />
Success
</Badge>
)}
{isStreaming && (
<Badge className="bg-gradient-to-b from-blue-200 to-blue-100 text-blue-700 dark:from-blue-800/50 dark:to-blue-900/60 dark:text-blue-300">
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
Loading
</Badge>
)}
</div>
</div>
</CardHeader>
<CardContent className="p-0 h-full flex-1 overflow-hidden relative">
{(isStreaming || (isLoadingMetadata && !metadata)) ? (
<LoadingState
icon={Presentation}
iconColor="text-blue-500 dark:text-blue-400"
bgColor="bg-gradient-to-b from-blue-100 to-blue-50 shadow-inner dark:from-blue-800/40 dark:to-blue-900/60 dark:shadow-blue-950/20"
title="Loading presentation"
filePath={retryAttempt > 0 ? `Retrying... (attempt ${retryAttempt + 1})` : "Loading slides..."}
showProgress={true}
/>
) : error || toolExecutionError || !metadata ? (
<div className="flex flex-col items-center justify-center h-full py-12 px-6 bg-gradient-to-b from-white to-zinc-50 dark:from-zinc-950 dark:to-zinc-900">
<div className="w-20 h-20 rounded-full flex items-center justify-center mb-6 bg-gradient-to-b from-rose-100 to-rose-50 shadow-inner dark:from-rose-800/40 dark:to-rose-900/60">
<AlertTriangle className="h-10 w-10 text-rose-400 dark:text-rose-600" />
</div>
<h3 className="text-xl font-semibold mb-2 text-zinc-900 dark:text-zinc-100">
{toolExecutionError ? 'Tool Execution Error' : (error || 'Failed to load presentation')}
</h3>
<p className="text-sm text-zinc-500 dark:text-zinc-400 text-center max-w-md mb-4">
{toolExecutionError ? 'The presentation tool encountered an error during execution:' :
(error || 'There was an error loading the presentation. Please try again.')}
</p>
{retryAttempt > 0 && !toolExecutionError && (
<p className="text-xs text-zinc-400 dark:text-zinc-500 mb-4">
Attempted {retryAttempt + 1} times
</p>
)}
{backgroundRetryInterval && !toolExecutionError && (
<p className="text-xs text-blue-500 dark:text-blue-400 mb-4 flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
Retrying in background...
</p>
)}
{!toolExecutionError && error && (
<Button
onClick={() => loadMetadata()}
variant="outline"
size="sm"
disabled={isLoadingMetadata}
className="mb-4"
>
{isLoadingMetadata ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Retrying...
</>
) : (
'Try Again'
)}
</Button>
)}
{toolExecutionError && (
<div className="w-full max-w-2xl">
<CodeBlockCode
code={toolExecutionError}
language="text"
className="text-xs bg-zinc-100 dark:bg-zinc-800 p-3 rounded-md border"
/>
</div>
)}
</div>
) : slides.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full py-12 px-6 bg-gradient-to-b from-white to-zinc-50 dark:from-zinc-950 dark:to-zinc-900">
<div className="w-20 h-20 rounded-full flex items-center justify-center mb-6 bg-gradient-to-b from-blue-100 to-blue-50 shadow-inner dark:from-blue-800/40 dark:to-blue-900/60">
<Presentation className="h-10 w-10 text-blue-400 dark:text-blue-600" />
</div>
<h3 className="text-xl font-semibold mb-2 text-zinc-900 dark:text-zinc-100">
No slides found
</h3>
<p className="text-sm text-zinc-500 dark:text-zinc-400 text-center max-w-md">
This presentation doesn't have any slides yet.
</p>
</div>
) : (
<ScrollArea className="h-full">
<div className="space-y-6 p-6">
{slides.map((slide) => (
<div
key={slide.number}
id={`slide-${slide.number}`}
className={`group rounded-lg cursor-pointer transition-all duration-200 ${
currentSlideNumber === slide.number
? 'ring-2 ring-blue-500/20 shadow-md'
: 'hover:shadow-lg hover:scale-[1.01]'
} bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 shadow-sm`}
onClick={() => {
setFullScreenInitialSlide(slide.number);
setIsFullScreenOpen(true);
}}
>
{/* Slide Preview */}
<div className="relative h-80 rounded-t-lg overflow-hidden">
{renderSlidePreview(slide)}
{/* Clickable overlay to ensure iframe is clickable */}
<div
className="absolute inset-0 cursor-pointer z-10"
onClick={() => {
setFullScreenInitialSlide(slide.number);
setIsFullScreenOpen(true);
}}
/>
{/* Simple hover indicator */}
<div className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-20 pointer-events-none">
<div className="bg-black/20 dark:bg-white/20 backdrop-blur-sm rounded-full p-2">
<Maximize2 className="h-3.5 w-3.5 text-white dark:text-black" />
</div>
</div>
</div>
{/* Simple footer */}
<div className="px-4 py-3 border-t border-zinc-100 dark:border-zinc-800">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
#{slide.number}
</span>
{slide.title && (
<span className="text-sm text-zinc-600 dark:text-zinc-400 truncate">
{slide.title}
</span>
)}
</div>
</div>
</div>
))}
</div>
</ScrollArea>
)}
</CardContent>
<div className="px-4 py-2 h-9 bg-zinc-50/30 dark:bg-zinc-900/30 border-t border-zinc-200/30 dark:border-zinc-800/30 flex justify-between items-center">
<div className="text-xs text-zinc-400 dark:text-zinc-500">
{slides.length > 0 && visibleSlide && (
<span className="font-mono">
{visibleSlide}/{slides.length}
</span>
)}
</div>
<div className="text-xs text-zinc-400 dark:text-zinc-500">
{formatTimestamp(toolTimestamp)}
</div>
</div>
{/* Full Screen Presentation Viewer */}
<FullScreenPresentationViewer
isOpen={isFullScreenOpen}
onClose={() => {
setIsFullScreenOpen(false);
setFullScreenInitialSlide(null);
// Reload metadata after closing full screen viewer in case edits were made
setTimeout(() => {
loadMetadata();
}, 300);
}}
presentationName={extractedPresentationName}
sandboxUrl={project?.sandbox?.sandbox_url}
initialSlide={fullScreenInitialSlide || visibleSlide || currentSlideNumber || slides[0]?.number || 1}
onPDFDownload={handlePDFDownload}
/>
</Card>
);
}