[eric] apps: History panel (timeline, restore, branch, live refetch on capture)

This commit is contained in:
ciregenz
2026-06-22 03:21:52 -07:00
parent 8ce98546ab
commit cf1ddbc80f
@@ -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<Props> = ({ 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<OutputVersion[]>([]);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [confirmId, setConfirmId] = useState<string | null>(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 (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 1.5, py: 1.25, gap: 1 }}>
<Typography sx={{ fontSize: '0.95rem', fontWeight: 600, color: c.text.primary }}>History</Typography>
<Button
size="small"
startIcon={<BookmarkAddOutlinedIcon sx={{ fontSize: 16 }} />}
onClick={handleSave}
disabled={saving || isAgentActive}
sx={{ textTransform: 'none', fontSize: '0.78rem', color: c.accent.primary, '&:hover': { bgcolor: `${c.accent.primary}12` } }}
>
{saving ? 'Saving' : 'Save this version'}
</Button>
</Box>
<Fade in={!!status} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
<Box sx={{
mx: 1.5, mb: 1, px: 1.25, py: 0.75, borderRadius: 2, fontSize: '0.8rem',
color: status?.kind === 'err' ? c.status.error : c.text.secondary,
bgcolor: status?.kind === 'err' ? `${c.status.error}14` : `${c.accent.primary}12`,
}}>
{status?.text}
</Box>
</Fade>
<Box sx={{ flex: 1, overflowY: 'auto', px: 1.5, pb: 2 }}>
{loading && versions.length === 0 ? (
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 6 }}>
<CircularProgress size={22} sx={{ color: c.text.tertiary }} />
</Box>
) : versions.length === 0 ? (
<Box sx={{ textAlign: 'center', pt: 6, px: 2 }}>
<HistoryIcon sx={{ fontSize: 34, color: c.text.tertiary, opacity: 0.5, mb: 1 }} />
<Typography sx={{ fontSize: '0.85rem', color: c.text.muted, lineHeight: 1.5 }}>
No history yet. Every time you change your app, we'll save a snapshot here so you can go back.
</Typography>
</Box>
) : (
<>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: c.accent.primary, flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.82rem', fontWeight: 600, color: c.text.secondary }}>Now (current)</Typography>
</Box>
{versions.map((v) => (
<Box
key={v.id}
sx={{
display: 'flex', gap: 1.25, p: 1, borderRadius: 2, alignItems: 'center',
border: `1px solid ${c.border.subtle}`, mb: 0.75, bgcolor: c.bg.surface,
'&:hover': { borderColor: c.border.medium },
}}
>
<Box sx={{
width: 64, height: 44, borderRadius: 1.5, flexShrink: 0, overflow: 'hidden',
bgcolor: c.bg.secondary, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{v.thumbnail
? <Box component="img" src={v.thumbnail} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'top left' }} />
: <HistoryIcon sx={{ fontSize: 18, color: c.text.tertiary, opacity: 0.5 }} />}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontSize: '0.84rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{describe(v)}
</Typography>
<Typography sx={{ fontSize: '0.74rem', color: c.text.muted }}>
{timeAgo(v.created_at)}{v.source === 'manual' ? ' · saved by you' : v.source === 'pre_restore' ? ' · auto-backup' : ''}
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
<Button
size="small"
startIcon={busyId === v.id ? <CircularProgress size={13} /> : <RestoreIcon sx={{ fontSize: 15 }} />}
onClick={() => setConfirmId(v.id)}
disabled={!!busyId || isAgentActive}
sx={{ textTransform: 'none', fontSize: '0.74rem', color: c.text.secondary, minWidth: 0, '&:hover': { bgcolor: `${c.text.primary}08` } }}
>
Restore
</Button>
<Button
size="small"
startIcon={<ContentCopyIcon sx={{ fontSize: 14 }} />}
onClick={() => handleBranch(v.id)}
disabled={!!busyId}
sx={{ textTransform: 'none', fontSize: '0.74rem', color: c.text.secondary, minWidth: 0, '&:hover': { bgcolor: `${c.text.primary}08` } }}
>
Save as new app
</Button>
</Box>
</Box>
))}
</>
)}
</Box>
<Dialog open={!!confirmTarget} onClose={() => setConfirmId(null)} PaperProps={{ sx: { borderRadius: 3, bgcolor: c.bg.surface, p: 0.5, maxWidth: 380 } }}>
<Box sx={{ p: 2.5 }}>
<Typography sx={{ fontSize: '0.95rem', fontWeight: 600, color: c.text.primary, mb: 1 }}>
Go back to this version?
</Typography>
<Typography sx={{ fontSize: '0.84rem', color: c.text.muted, lineHeight: 1.5, mb: 2.5 }}>
This brings your app back to how it was here. Your current version is saved first, so you can always come back.
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button onClick={() => setConfirmId(null)} sx={{ textTransform: 'none', color: c.text.secondary }}>Cancel</Button>
<Button
variant="contained" disableElevation
onClick={() => confirmTarget && handleRestore(confirmTarget.id)}
sx={{ textTransform: 'none', bgcolor: c.accent.primary, '&:hover': { bgcolor: c.accent.primary } }}
>
Go back to this version
</Button>
</Box>
</Box>
</Dialog>
</Box>
);
};
export default HistoryPanel;