diff --git a/src/shell/RunDetail.tsx b/src/shell/RunDetail.tsx index 88085c00..223c852d 100644 --- a/src/shell/RunDetail.tsx +++ b/src/shell/RunDetail.tsx @@ -15,6 +15,8 @@ import { BackIcon, ChartIcon, ChevronDownIcon, + CompressIcon, + ExpandIcon, FileIcon, PlayIcon, StopIcon, @@ -80,6 +82,77 @@ const RunDetail = ({ runId }: { runId: string }) => { return next; }); + // Fullscreen viewport: native Fullscreen API when available, an in-app + // fixed overlay ("immersive") when it is not (or the request is denied). + const viewportRef = useRef(null); + const [nativeFullscreen, setNativeFullscreen] = useState(false); + const [immersive, setImmersive] = useState(false); + const expanded = nativeFullscreen || immersive; + + useEffect(() => { + const onChange = () => + setNativeFullscreen( + document.fullscreenElement != null && + document.fullscreenElement === viewportRef.current, + ); + document.addEventListener("fullscreenchange", onChange); + return () => document.removeEventListener("fullscreenchange", onChange); + }, []); + + useEffect(() => { + if (!immersive) { + return; + } + // Native fullscreen exits on Esc by itself; the overlay needs a handler. + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setImmersive(false); + } + }; + window.addEventListener("keydown", onKey); + // The overlay resizes the viewport without a window resize; nudge the + // remaining window-resize listeners (the 3D canvas itself polls its + // container size every frame and doesn't need it). + window.dispatchEvent(new Event("resize")); + return () => { + window.removeEventListener("keydown", onKey); + window.dispatchEvent(new Event("resize")); + }; + }, [immersive]); + + // Leave fullscreen when the live run ends: native fullscreen's top layer + // hides everything outside the viewport (toasts included), and the + // completion UI lives outside it. + useEffect(() => { + if (live) { + return; + } + setImmersive(false); + if (document.fullscreenElement) { + void document.exitFullscreen().catch(() => {}); + } + }, [live]); + + const toggleFullscreen = () => { + const element = viewportRef.current; + if (!element) { + return; + } + if (nativeFullscreen) { + void document.exitFullscreen().catch(() => {}); + return; + } + if (immersive) { + setImmersive(false); + return; + } + if (typeof element.requestFullscreen === "function") { + element.requestFullscreen().catch(() => setImmersive(true)); + } else { + setImmersive(true); + } + }; + // Finished runs: persisted log + frame + output list from storage. useEffect(() => { if (live || !dirName) { @@ -337,14 +410,25 @@ const RunDetail = ({ runId }: { runId: string }) => { {/* Viewport */}
{live ? ( @@ -470,6 +554,63 @@ const RunDetail = ({ runId }: { runId: string }) => { )}
+ {/* Stop stays reachable while the run screen chrome is hidden. */} + {expanded && live && ( + + )} + {/* Fullscreen toggle */} + {/* Console strip (collapsible: the header bar always stays visible, diff --git a/src/shell/icons.tsx b/src/shell/icons.tsx index 681c7a99..5abb495f 100644 --- a/src/shell/icons.tsx +++ b/src/shell/icons.tsx @@ -251,6 +251,20 @@ export const ChevronRightIcon = ({ size = 16 }: IconProps) => export const BackIcon = ({ size = 14 }: IconProps) => stroke(size, , 2); +export const ExpandIcon = ({ size = 15 }: IconProps) => + stroke( + size, + , + 2, + ); + +export const CompressIcon = ({ size = 15 }: IconProps) => + stroke( + size, + , + 2, + ); + export const DotsIcon = ({ size = 16 }: IconProps) => (