import { useCallback, useEffect, useMemo, useState, type MouseEvent as ReactMouseEvent } from 'react' import { ImageLightbox } from '../../components/image-preview/lightbox' import { CLOSED_IMAGE_CONTEXT_MENU, ImageContextMenu } from '../../components/image-preview/context-menu' import { useI18n } from '../../i18n' import type { StudioFileAttachment, StudioPermissionDecision, StudioPermissionRequest, StudioRun, StudioSession, StudioTask, StudioWork, StudioWorkResult, } from '../protocol/studio-agent-types' import { truncateStudioText } from '../theme' interface PlotWorkListItem { work: StudioWork latestTask: StudioTask | null result: StudioWorkResult | null } 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 } export function PlotPreviewPanel({ session, works, selectedWorkId, result, onSelectWork, onReorderWorks, }: PlotPreviewPanelProps) { const { t } = useI18n() const [lightboxOpen, setLightboxOpen] = useState(false) const [zoom, setZoom] = useState(1) const [draggingWorkId, setDraggingWorkId] = useState(null) const [selectedImageIndex, setSelectedImageIndex] = useState(0) const [previewMotionKey, setPreviewMotionKey] = useState(0) const [previewContextMenu, setPreviewContextMenu] = useState(CLOSED_IMAGE_CONTEXT_MENU) 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 outputPath = formatOutputPath(previewAttachment, session, t('studio.plot.inlinePreview'), t('studio.plot.waitingOutputFile')) useEffect(() => { if (!lightboxOpen) { setZoom(1) } }, [lightboxOpen]) 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) } return (
{outputPath}
1} currentIndex={activeHistoryIndex >= 0 ? activeHistoryIndex : 0} total={historyImages.length} onOpen={() => setLightboxOpen(true)} onContextMenu={(event) => { event.preventDefault() if (!previewAttachment?.path) { return } setPreviewContextMenu({ open: true, x: event.clientX, y: event.clientY, }) }} onPrev={handlePrev} onNext={handleNext} />
{t('studio.plot.history')}
{historyImages.length.toString().padStart(2, '0')}
{historyImages.map((entry, index) => { const selected = entry.workId === selectedWorkId && entry.imageIndex === clampedImageIndex return ( ) })}
= 0 ? activeHistoryIndex : 0} total={historyImages.length} zoom={zoom} variant="studio-light" onZoomChange={setZoom} onPrev={historyImages.length > 1 ? handlePrev : undefined} onNext={historyImages.length > 1 ? handleNext : undefined} onClose={() => { setLightboxOpen(false) setZoom(1) }} /> { void downloadPreviewAttachment(previewAttachment.path, clampedImageIndex) setPreviewContextMenu(CLOSED_IMAGE_CONTEXT_MENU) }, }, { key: 'open-lightbox', label: t('image.openLightbox'), onClick: () => { setPreviewContextMenu(CLOSED_IMAGE_CONTEXT_MENU) setLightboxOpen(true) }, }, ] : []} onClose={() => setPreviewContextMenu(CLOSED_IMAGE_CONTEXT_MENU)} />
) } function PlotPreviewSurface(input: { attachment: StudioFileAttachment | null | undefined result: StudioWorkResult | null canNavigate: boolean currentIndex: number total: number onOpen: () => void onContextMenu: (event: ReactMouseEvent) => void onPrev: () => void onNext: () => void }) { const { t } = useI18n() if (input.attachment?.mimeType?.startsWith('image/') || isImagePath(input.attachment?.path)) { return (
{input.canNavigate && ( <>
{String(input.currentIndex + 1).padStart(2, '0')} / {String(input.total).padStart(2, '0')}
)}
) => { if (event.button !== 0) { return } input.onOpen() }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() input.onOpen() } }} onContextMenu={input.onContextMenu} className="flex h-full w-full cursor-zoom-in items-center justify-center animate-fade-in-soft" title={t('image.openTitle')} > {input.attachment?.name
) } if (input.result?.kind === 'failure-report') { return (
{t('studio.renderFailed')}
) } return null } async function downloadPreviewAttachment(path: string, index: number): Promise { const response = await fetch(getAbsoluteUrl(path)) if (!response.ok) { throw new Error(`Failed to fetch preview image: ${response.status}`) } const blob = await response.blob() const blobUrl = URL.createObjectURL(blob) const link = document.createElement('a') link.href = blobUrl link.download = `plot-preview-${index + 1}.png` link.click() window.setTimeout(() => URL.revokeObjectURL(blobUrl), 1000) } function getAbsoluteUrl(path: string): string { if (/^(data:|https?:\/\/)/i.test(path)) { return path } return new URL(path, window.location.origin).toString() } function isPreviewAttachment(attachment: { path: string; mimeType?: string } | undefined) { return isImageAttachment(attachment) } 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 ( ) }