import { useCallback, useEffect, useMemo, useState, type MouseEvent as ReactMouseEvent } from 'react' import { copyImageAssetToClipboard, exportImageAsset } from '../../../components/image-preview/image-asset' import { useI18n } from '../../../i18n' import type { StudioFileAttachment, StudioPermissionDecision, StudioPermissionRequest, StudioRun, StudioSession, StudioTask, StudioWork, StudioWorkResult, } from '../../protocol/studio-agent-types' import { truncateStudioText } from '../../theme' import { PlotPreviewLightbox } from '../lightbox/PlotPreviewLightbox' import type { PlotPreviewVariant, PlotWorkListItem } from '../types' import { PlotHistoryStrip } from './PlotHistoryStrip' import { PlotPreviewContextMenu } from './PlotPreviewContextMenu' import { PlotPreviewSurface } from './PlotPreviewSurface' import { usePlotPreviewImage } from './use-plot-preview-image' interface PlotPreviewPanelProps { session: StudioSession | null works: PlotWorkListItem[] selectedWorkId: string | null work: StudioWork | null result: StudioWorkResult | null latestRun: StudioRun | null tasks: StudioTask[] requests: StudioPermissionRequest[] replyingPermissionIds: Record latestAssistantText: string errorMessage?: string | null onSelectWork: (workId: string) => void onReorderWorks: (workIds: string[]) => void onReply: (requestId: string, reply: StudioPermissionDecision) => Promise | void onSendPreviewToComposer?: (attachment: { url: string; name: string; mimeType?: string }) => void variant?: PlotPreviewVariant } export function PlotPreviewPanel({ session, works, selectedWorkId, result, onSelectWork, onReorderWorks, onSendPreviewToComposer, variant = 'default', }: PlotPreviewPanelProps) { const { t } = useI18n() const isTLayout = variant === 't-layout-top' const isMinimal = variant === 'pure-minimal-top' const [lightboxOpen, setLightboxOpen] = useState(false) const [draggingWorkId, setDraggingWorkId] = useState(null) const [selectedImageIndex, setSelectedImageIndex] = useState(0) const [previewMotionKey, setPreviewMotionKey] = useState(0) const [previewContextMenu, setPreviewContextMenu] = useState({ open: false, x: 0, y: 0 }) const [exportingFormat, setExportingFormat] = useState<'png' | 'svg' | 'pdf' | null>(null) const [copyingFormat, setCopyingFormat] = useState<'png' | 'svg' | null>(null) const stripItems = works.slice(0, 12) const historyImages = useMemo(() => { return stripItems.flatMap((entry) => ( getImageAttachments(entry.result?.attachments).map((attachment, imageIndex) => ({ workId: entry.work.id, attachment, title: entry.work.title, imageIndex, })) )) }, [stripItems]) const currentWorkImages = useMemo(() => getImageAttachments(result?.attachments), [result?.attachments]) const currentImagePathsKey = currentWorkImages.map((attachment) => attachment.path).join('|') const clampedImageIndex = currentWorkImages.length === 0 ? 0 : Math.min(selectedImageIndex, currentWorkImages.length - 1) const selectedHistoryIndex = historyImages.findIndex((entry) => ( entry.workId === selectedWorkId && entry.imageIndex === clampedImageIndex )) const activeHistoryIndex = selectedHistoryIndex >= 0 ? selectedHistoryIndex : historyImages.findIndex((entry) => entry.workId === selectedWorkId) const activeHistoryEntry = historyImages[activeHistoryIndex] ?? null const previewAttachment = currentWorkImages[clampedImageIndex] ?? activeHistoryEntry?.attachment ?? null const { previewSrc: previewDisplaySrc } = usePlotPreviewImage(previewAttachment?.path) const outputPath = formatOutputPath(previewAttachment, session, t('studio.plot.inlinePreview'), t('studio.plot.waitingOutputFile')) const handlePreviewExport = useCallback(async (format: 'png' | 'svg' | 'pdf') => { if (!previewAttachment?.path || exportingFormat) { return } setPreviewContextMenu({ open: false, x: 0, y: 0 }) setExportingFormat(format) try { await exportImageAsset({ source: previewAttachment.path, format, index: clampedImageIndex, fallbackName: previewAttachment.name, }) } catch (error) { console.error(`Failed to export ${format}`, error) } finally { setExportingFormat(null) } }, [clampedImageIndex, exportingFormat, previewAttachment]) const handlePreviewCopy = useCallback(async (format: 'png' | 'svg') => { if (!previewAttachment?.path || copyingFormat) { return } setPreviewContextMenu({ open: false, x: 0, y: 0 }) setCopyingFormat(format) try { await copyImageAssetToClipboard({ source: previewAttachment.path, format, }) } catch (error) { console.error(`Failed to copy ${format}`, error) } finally { setCopyingFormat(null) } }, [copyingFormat, previewAttachment]) useEffect(() => { setSelectedImageIndex(0) }, [selectedWorkId, result?.id]) useEffect(() => { setSelectedImageIndex((current) => { if (currentWorkImages.length === 0) { return current === 0 ? current : 0 } const next = Math.min(current, currentWorkImages.length - 1) return next === current ? current : next }) }, [currentImagePathsKey, currentWorkImages.length]) useEffect(() => { if (!previewAttachment?.path) { return } setPreviewMotionKey((current) => current + 1) }, [previewAttachment?.path, result?.id]) const handlePrev = useCallback(() => { if (historyImages.length <= 1) { return } const baseIndex = activeHistoryIndex >= 0 ? activeHistoryIndex : 0 const nextIndex = baseIndex <= 0 ? historyImages.length - 1 : baseIndex - 1 const nextEntry = historyImages[nextIndex] onSelectWork(nextEntry.workId) setSelectedImageIndex(nextEntry.imageIndex) }, [activeHistoryIndex, historyImages, onSelectWork]) const handleNext = useCallback(() => { if (historyImages.length <= 1) { return } const baseIndex = activeHistoryIndex >= 0 ? activeHistoryIndex : 0 const nextIndex = baseIndex >= historyImages.length - 1 ? 0 : baseIndex + 1 const nextEntry = historyImages[nextIndex] onSelectWork(nextEntry.workId) setSelectedImageIndex(nextEntry.imageIndex) }, [activeHistoryIndex, historyImages, onSelectWork]) useEffect(() => { if (lightboxOpen || historyImages.length <= 1) { return undefined } const handleWindowKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) { return } const target = event.target as HTMLElement | null if ( target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || target?.isContentEditable ) { return } if (event.key === 'ArrowLeft') { event.preventDefault() handlePrev() return } if (event.key === 'ArrowRight') { event.preventDefault() handleNext() } } window.addEventListener('keydown', handleWindowKeyDown, true) return () => window.removeEventListener('keydown', handleWindowKeyDown, true) }, [handleNext, handlePrev, historyImages.length, lightboxOpen]) const moveWork = (targetWorkId: string) => { if (!draggingWorkId || draggingWorkId === targetWorkId) { return } const nextIds = stripItems.map((entry) => entry.work.id) const fromIndex = nextIds.indexOf(draggingWorkId) const toIndex = nextIds.indexOf(targetWorkId) if (fromIndex === -1 || toIndex === -1) { return } const reordered = [...nextIds] const [moved] = reordered.splice(fromIndex, 1) reordered.splice(toIndex, 0, moved) onReorderWorks(reordered) } const handleSurfaceContextMenu = (event: ReactMouseEvent) => { event.preventDefault() if (!previewAttachment?.path) { return } setPreviewContextMenu({ open: true, x: event.clientX, y: event.clientY, }) } return (
{!isTLayout && !isMinimal && (
{outputPath}
)}
1} currentIndex={activeHistoryIndex >= 0 ? activeHistoryIndex : 0} total={historyImages.length} variant={variant} onOpen={() => setLightboxOpen(true)} onContextMenu={handleSurfaceContextMenu} onPrev={handlePrev} onNext={handleNext} />
{!isTLayout && !isMinimal && ( { onSelectWork(workId) setSelectedImageIndex(imageIndex) }} onDragStart={setDraggingWorkId} onDrop={(workId) => { moveWork(workId) setDraggingWorkId(null) }} onDragEnd={() => setDraggingWorkId(null)} /> )}
= 0 ? activeHistoryIndex : 0} total={historyImages.length} editableFilename={previewAttachment?.name} canNavigate={historyImages.length > 1} onPrev={handlePrev} onNext={handleNext} onSendPreviewToComposer={onSendPreviewToComposer} onClose={() => { setLightboxOpen(false) }} /> { void handlePreviewCopy(format) }} onExport={(format) => { void handlePreviewExport(format) }} onOpenLightbox={() => { setPreviewContextMenu({ open: false, x: 0, y: 0 }) setLightboxOpen(true) }} onClose={() => setPreviewContextMenu({ open: false, x: 0, y: 0 })} />
) } function getImageAttachments(attachments: StudioFileAttachment[] | undefined): StudioFileAttachment[] { return (attachments ?? []).filter(isImageAttachment) } function formatOutputPath( attachment: StudioFileAttachment | null | undefined, session: StudioSession | null, inlinePreviewLabel: string, waitingOutputLabel: string, ) { if (attachment?.name) { return attachment.name } if (attachment?.path) { if (attachment.path.startsWith('data:')) { return inlinePreviewLabel } return truncateStudioText(attachment.path, 88) } return session?.directory ?? waitingOutputLabel } function isImageAttachment(attachment: { path: string; mimeType?: string } | undefined) { if (!attachment) { return false } return attachment.mimeType?.startsWith('image/') || isImagePath(attachment.path) } function isImagePath(path?: string) { return Boolean(path && /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(path)) } function PlotCornerPaw({ className = '' }: { className?: string }) { return ( ) }