diff --git a/frontend/src/app/pages/Views/HistoryPanel.tsx b/frontend/src/app/pages/Views/HistoryPanel.tsx new file mode 100644 index 00000000..b4e0eeaf --- /dev/null +++ b/frontend/src/app/pages/Views/HistoryPanel.tsx @@ -0,0 +1,253 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import CircularProgress from '@mui/material/CircularProgress'; +import Dialog from '@mui/material/Dialog'; +import Fade from '@mui/material/Fade'; +import RestoreIcon from '@mui/icons-material/Restore'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import HistoryIcon from '@mui/icons-material/History'; +import BookmarkAddOutlinedIcon from '@mui/icons-material/BookmarkAddOutlined'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { + OutputVersion, + fetchOutputVersions, + captureOutputVersion, + restoreOutputVersion, + branchOutputVersion, + fetchOutputs, +} from '@/shared/state/outputsSlice'; + +function timeAgo(iso: string): string { + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return ''; + const s = Math.max(0, Math.round((Date.now() - then) / 1000)); + if (s < 60) return 'just now'; + const m = Math.round(s / 60); + if (m < 60) return `${m} minute${m === 1 ? '' : 's'} ago`; + const h = Math.round(m / 60); + if (h < 24) return `${h} hour${h === 1 ? '' : 's'} ago`; + const d = Math.round(h / 24); + if (d < 7) return `${d} day${d === 1 ? '' : 's'} ago`; + return new Date(iso).toLocaleDateString(); +} + +function describe(v: OutputVersion): string { + const label = v.label?.trim(); + if (label) return label; + if (v.source === 'manual') return 'Saved version'; + return 'Updated the app'; +} + +interface Props { + outputId: string; + /** Disable changes while the builder is mid-edit, so a restore can't race a write. */ + isAgentActive?: boolean; + /** Default name for a manual save (the user's last request makes a nice one). */ + saveLabel?: string; + /** Fired after a branch so the parent can open / surface the new copy. */ + onBranched?: (newId: string) => void; + /** Fired after a restore so the editor can refresh its files + preview now. */ + onRestored?: () => void; +} + +const HistoryPanel: React.FC = ({ outputId, isAgentActive, saveLabel, onBranched, onRestored }) => { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + // Refetch when a build (or manual save) captures a new version while we're open. + const captureSignal = useAppSelector((s) => s.outputs.captureSignal[outputId] ?? 0); + + const [versions, setVersions] = useState([]); + const [loading, setLoading] = useState(true); + const [busyId, setBusyId] = useState(null); + const [saving, setSaving] = useState(false); + const [confirmId, setConfirmId] = useState(null); + const [status, setStatus] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null); + const [reloadKey, setReloadKey] = useState(0); + + const mountedRef = useRef(true); + useEffect(() => () => { mountedRef.current = false; }, []); + + const flash = useCallback((kind: 'ok' | 'err', text: string) => { + if (!mountedRef.current) return; + setStatus({ kind, text }); + window.setTimeout(() => { if (mountedRef.current) setStatus(null); }, 3500); + }, []); + + // alive-guarded so a slower fetch for a previous outputId (or after close) can't + // overwrite the list. Handlers bump reloadKey to refetch. + useEffect(() => { + let alive = true; + setLoading(true); + dispatch(fetchOutputVersions(outputId)).unwrap() + .then((list) => { if (alive) setVersions(list); }) + .catch(() => { /* a missing history just shows the empty state */ }) + .finally(() => { if (alive) setLoading(false); }); + return () => { alive = false; }; + }, [dispatch, outputId, reloadKey, captureSignal]); + + const handleSave = useCallback(async () => { + setSaving(true); + try { + await dispatch(captureOutputVersion({ id: outputId, source: 'manual', label: saveLabel || '' })).unwrap(); + setReloadKey((k) => k + 1); + flash('ok', 'Saved this version.'); + } catch { + flash('err', "Couldn't save this version. Try again."); + } finally { + if (mountedRef.current) setSaving(false); + } + }, [dispatch, outputId, saveLabel, flash]); + + const handleRestore = useCallback(async (versionId: string) => { + setConfirmId(null); + setBusyId(versionId); + try { + await dispatch(restoreOutputVersion({ id: outputId, versionId })).unwrap(); + setReloadKey((k) => k + 1); + onRestored?.(); + flash('ok', 'Brought your app back to this version.'); + } catch (e) { + flash('err', e instanceof Error ? e.message : 'Could not restore that version.'); + } finally { + if (mountedRef.current) setBusyId(null); + } + }, [dispatch, outputId, flash, onRestored]); + + const handleBranch = useCallback(async (versionId: string) => { + setBusyId(versionId); + try { + const newId = await dispatch(branchOutputVersion({ id: outputId, versionId })).unwrap(); + await dispatch(fetchOutputs()); + flash('ok', 'Saved as a new app.'); + onBranched?.(newId); + } catch { + flash('err', "Couldn't make a copy. Try again."); + } finally { + if (mountedRef.current) setBusyId(null); + } + }, [dispatch, outputId, onBranched, flash]); + + const confirmTarget = versions.find((v) => v.id === confirmId) || null; + + return ( + + + History + + + + + + {status?.text} + + + + + {loading && versions.length === 0 ? ( + + + + ) : versions.length === 0 ? ( + + + + No history yet. Every time you change your app, we'll save a snapshot here so you can go back. + + + ) : ( + <> + + + Now (current) + + {versions.map((v) => ( + + + {v.thumbnail + ? + : } + + + + {describe(v)} + + + {timeAgo(v.created_at)}{v.source === 'manual' ? ' · saved by you' : v.source === 'pre_restore' ? ' · auto-backup' : ''} + + + + + + + + ))} + + )} + + + setConfirmId(null)} PaperProps={{ sx: { borderRadius: 3, bgcolor: c.bg.surface, p: 0.5, maxWidth: 380 } }}> + + + Go back to this version? + + + This brings your app back to how it was here. Your current version is saved first, so you can always come back. + + + + + + + + + ); +}; + +export default HistoryPanel;