diff --git a/frontend/src/components/ProgressView.tsx b/frontend/src/components/ProgressView.tsx index 3921f16..f78072e 100644 --- a/frontend/src/components/ProgressView.tsx +++ b/frontend/src/components/ProgressView.tsx @@ -4,16 +4,41 @@ import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import LinearProgress from "@mui/material/LinearProgress"; import Paper from "@mui/material/Paper"; +import Slider from "@mui/material/Slider"; import Typography from "@mui/material/Typography"; import { cancelRun } from "../api"; type StreamEvent = | { type: "progress"; percent: number } | { type: "log"; message: string } + | { + type: "image"; + data_url: string; + caption: string | null; + timestamp: string; + } | { type: "error"; message: string } | { type: "done"; message: string } | { type: "cancelled"; message: string }; +type StreamImage = { + dataUrl: string; + caption: string | null; + timestamp: string; +}; + +function formatStreamTime(iso: string): string { + return new Date(iso).toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +function isNearBottom(el: HTMLElement, threshold = 48) { + return el.scrollHeight - el.scrollTop - el.clientHeight <= threshold; +} + export function ProgressView({ runId, onReset, @@ -23,14 +48,32 @@ export function ProgressView({ }) { const [percent, setPercent] = useState(0); const [logs, setLogs] = useState([]); + const [images, setImages] = useState([]); + const [imageIndex, setImageIndex] = useState(0); const [done, setDone] = useState(null); const [cancelled, setCancelled] = useState(null); const [error, setError] = useState(null); const [cancelPending, setCancelPending] = useState(false); const [cancelError, setCancelError] = useState(null); - const bottomRef = useRef(null); + const logContainerRef = useRef(null); + const stickToBottomRef = useRef(true); + const stickToLatestImageRef = useRef(true); + + function handleLogScroll() { + const el = logContainerRef.current; + if (!el) return; + stickToBottomRef.current = isNearBottom(el); + } + + function handleImageSliderChange(_: Event, value: number | number[]) { + const index = value as number; + setImageIndex(index); + stickToLatestImageRef.current = index === images.length - 1; + } useEffect(() => { + stickToBottomRef.current = true; + stickToLatestImageRef.current = true; const es = new EventSource(`/api/runs/${runId}/stream`); es.onmessage = (e) => { try { @@ -38,7 +81,22 @@ export function ProgressView({ if (ev.type === "progress") setPercent(Math.min(100, Math.max(0, Math.ceil(ev.percent)))); else if (ev.type === "log") setLogs((x) => [...x, ev.message]); - else if (ev.type === "error") { + else if (ev.type === "image") { + setImages((prev) => { + const next = [ + ...prev, + { + dataUrl: ev.data_url, + caption: ev.caption, + timestamp: ev.timestamp, + }, + ]; + if (stickToLatestImageRef.current) { + setImageIndex(next.length - 1); + } + return next; + }); + } else if (ev.type === "error") { setError(ev.message); es.close(); } else if (ev.type === "cancelled") { @@ -57,10 +115,13 @@ export function ProgressView({ }, [runId]); useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + const el = logContainerRef.current; + if (!el || !stickToBottomRef.current) return; + el.scrollTop = el.scrollHeight; }, [logs]); const canCancel = !done && !error && !cancelled && !cancelPending; + const currentImage = images[imageIndex]; return ( @@ -80,14 +141,6 @@ export function ProgressView({ {error} )} - {done && ( - - {done} - - )} {cancelled && ( )} + + {currentImage ? ( + <> + + {currentImage.caption && ( + + {currentImage.caption} + + )} + {images.length > 1 && ( + formatStreamTime(images[i].timestamp)} + /> + )} + + ) : ( + + No charts yet + + )} + + {done && ( + + {done} + + )} (
{line}
))} -