Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 148 additions & 7 deletions src/shell/RunDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
BackIcon,
ChartIcon,
ChevronDownIcon,
CompressIcon,
ExpandIcon,
FileIcon,
PlayIcon,
StopIcon,
Expand Down Expand Up @@ -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<HTMLDivElement | null>(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) {
Expand Down Expand Up @@ -337,14 +410,25 @@ const RunDetail = ({ runId }: { runId: string }) => {

{/* Viewport */}
<div
ref={viewportRef}
data-testid="run-viewport"
style={{
flex: 1,
position: "relative",
background: "var(--viewport)",
overflow: "hidden",
minHeight: 0,
}}
style={
immersive
? {
position: "fixed",
inset: 0,
zIndex: 1000,
background: "var(--viewport)",
overflow: "hidden",
}
: {
flex: 1,
position: "relative",
background: "var(--viewport)",
overflow: "hidden",
minHeight: 0,
}
}
>
{live ? (
<View visible pane />
Expand Down Expand Up @@ -470,6 +554,63 @@ const RunDetail = ({ runId }: { runId: string }) => {
</>
)}
</div>
{/* Stop stays reachable while the run screen chrome is hidden. */}
{expanded && live && (
<button
onClick={() => ui.stopRun()}
data-testid="viewport-stop"
title="Stop the run"
aria-label="Stop the run"
className="shell-danger-hover"
style={{
position: "absolute",
top: 14,
right: 58,
height: 32,
display: "inline-flex",
alignItems: "center",
gap: 7,
padding: "0 12px",
border: "none",
borderRadius: 9,
background: "var(--bad)",
color: "#fff",
fontWeight: 700,
fontSize: 12.5,
cursor: "pointer",
fontFamily: "inherit",
}}
>
<StopIcon />
Stop
</button>
)}
{/* Fullscreen toggle */}
<button
onClick={toggleFullscreen}
data-testid="viewport-fullscreen"
title={expanded ? "Exit full screen (Esc)" : "Full screen"}
aria-label={expanded ? "Exit full screen" : "Full screen"}
style={{
position: "absolute",
top: 14,
right: 16,
width: 32,
height: 32,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 0,
border: "1px solid rgba(255,255,255,0.18)",
borderRadius: 9,
background: "rgba(10,12,17,0.55)",
color: "rgba(255,255,255,0.85)",
cursor: "pointer",
backdropFilter: "blur(4px)",
}}
>
{expanded ? <CompressIcon /> : <ExpandIcon />}
</button>
</div>

{/* Console strip (collapsible: the header bar always stays visible,
Expand Down
14 changes: 14 additions & 0 deletions src/shell/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,20 @@ export const ChevronRightIcon = ({ size = 16 }: IconProps) =>
export const BackIcon = ({ size = 14 }: IconProps) =>
stroke(size, <path d="m15 18-6-6 6-6" />, 2);

export const ExpandIcon = ({ size = 15 }: IconProps) =>
stroke(
size,
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />,
2,
);

export const CompressIcon = ({ size = 15 }: IconProps) =>
stroke(
size,
<path d="M20 10h-6V4M4 14h6v6M14 10l7-7M10 14l-7 7" />,
2,
);

export const DotsIcon = ({ size = 16 }: IconProps) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<circle cx="5" cy="12" r="1.7" />
Expand Down
Loading