fix(missions-ui): the results area could not scroll at all
The canvas host is a position:relative BLOCK, so flex:1 on MissionCanvas's root was inert and its height collapsed to its content. That starved the scroller beneath it — scrollHeight === clientHeight — so it never scrolled, and the overflow spilled past the page and was clipped by the host's overflow:hidden. Long results were rendered and then thrown away. Every sibling canvas already used position:absolute; inset:0; missions was the only one that did not. Measured after, on a brief 5x the viewport: one scroller, clientH 736 vs scrollH 3244, scrolling 0 -> 2508 (exactly scrollH - clientH, i.e. the true bottom), zero page overflow, tab strip pinned throughout. Also removed five nested scrollers (70vh on live events; maxHeight caps on run streams, phase summaries, artifact bodies and error traces). Those existed only to work around the missing height and would have become portholes onto the very content the operator is trying to read. The xterm pane keeps its bounded box — FitAddon needs one, and a terminal owning its scrollback is correct. Deleting the header's description peek reclaims 104px for results (header 256 -> 152px); the same text renders in full in Setup -> Overview, as the code's own comment noted. Streaming now follows only when already at the bottom, via a shared useStickToBottom hook replacing two byte-identical copies, plus a "jump to latest" pill neither had. Defaults collapse by mission state, and a remount key fixes scrollTop leaking between tabs — a bug that only appears once scrolling works. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4dec77ae6d
commit
5db695460f
@@ -10,8 +10,6 @@
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
@@ -41,6 +39,7 @@ import {
|
||||
} from "@/lib/api/missions";
|
||||
import { EditMissionModal } from "./EditMissionModal";
|
||||
import { MarkdownBlock } from "./MarkdownBlock";
|
||||
import { MissionTabScroller } from "./MissionTabScroller";
|
||||
import { MissionArtifacts } from "./MissionArtifacts";
|
||||
import { MissionLiveEvents } from "./MissionLiveEvents";
|
||||
import { MissionLivePane } from "./MissionLivePane";
|
||||
@@ -141,22 +140,6 @@ export function MissionCanvas({
|
||||
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||
const [runs, setRuns] = useState<MissionRunSummary[]>([]);
|
||||
const [lastLoadedAt, setLastLoadedAt] = useState<Date | null>(null);
|
||||
// Persist the collapsed state across mission switches so the operator
|
||||
// can keep the header hidden once they've read it.
|
||||
const [headerCollapsed, setHeaderCollapsed] = useState<boolean>(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.localStorage.getItem("cm.mission.headerCollapsed") === "1";
|
||||
});
|
||||
const toggleHeader = useCallback(() => {
|
||||
setHeaderCollapsed((v) => {
|
||||
const next = !v;
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem("cm.mission.headerCollapsed", next ? "1" : "0");
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!selectedId) {
|
||||
setMission(null);
|
||||
@@ -320,7 +303,7 @@ export function MissionCanvas({
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
position: "absolute", inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -336,20 +319,22 @@ export function MissionCanvas({
|
||||
}
|
||||
if (loading && !mission) {
|
||||
return (
|
||||
<div style={{ padding: 20, fontFamily: mono, color: "#5ec8d8" }}>
|
||||
<div style={{ position: "absolute", inset: 0, padding: 20, fontFamily: mono, color: "#5ec8d8" }}>
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: 20, color: "#ff8a7a", fontSize: 12 }}>{error}</div>
|
||||
<div style={{ position: "absolute", inset: 0, padding: 20, color: "#ff8a7a", fontSize: 12 }}>{error}</div>
|
||||
);
|
||||
}
|
||||
if (!mission) return null;
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||
// absolute-inset, NOT flex:1 — the canvas host is a position:relative BLOCK,
|
||||
// so flex:1 is inert here and collapses this root (and its scroller) to zero.
|
||||
<div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
flex: "none",
|
||||
@@ -531,44 +516,15 @@ export function MissionCanvas({
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
{/* Title only. The description peek that used to live here was ~92px of
|
||||
permanently-pinned chrome showing a masked, unreadable fragment of
|
||||
text that renders in full a click away in Setup → Overview. Deleting
|
||||
it gives that space to the results and removes one collapsible. */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<h1 style={{ margin: 0, fontSize: 20, color: "#f3f3f5", flex: 1, minWidth: 0 }}>
|
||||
{mission.title}
|
||||
</h1>
|
||||
{mission.description && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleHeader}
|
||||
title={headerCollapsed ? "Expand description" : "Collapse description"}
|
||||
aria-label={headerCollapsed ? "Expand description" : "Collapse description"}
|
||||
style={{
|
||||
...iconBtn,
|
||||
flex: "none",
|
||||
}}
|
||||
>
|
||||
{headerCollapsed ? <ChevronDown size={13} /> : <ChevronUp size={13} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{mission.description && !headerCollapsed && (
|
||||
// Clipped, NOT scrollable — a scroll container here was a fourth
|
||||
// nested scrollbar above the content area. The full text lives in
|
||||
// Setup → Overview.
|
||||
<div
|
||||
style={{
|
||||
marginTop: 4,
|
||||
maxHeight: 92,
|
||||
overflow: "hidden",
|
||||
paddingRight: 8,
|
||||
maskImage:
|
||||
"linear-gradient(to bottom, #000 60%, transparent 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to bottom, #000 60%, transparent 100%)",
|
||||
}}
|
||||
>
|
||||
<MarkdownBlock source={mission.description} />
|
||||
</div>
|
||||
)}
|
||||
{refineDiff && (
|
||||
<RefineDiffModal
|
||||
original={refineDiff.original}
|
||||
@@ -721,7 +677,11 @@ export function MissionCanvas({
|
||||
visible
|
||||
/>
|
||||
) : (
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 22 }}>
|
||||
// `key` remounts the scroller per tab. Without it every tab shares one DOM
|
||||
// node, so scrollTop leaks: scroll to the bottom of a long run list, switch
|
||||
// to Setup, and you land mid-page in unrelated content.
|
||||
// `stick` only on the streaming views — see MissionTabScroller.
|
||||
<MissionTabScroller key={`${tab}:${tab === "run" ? runSub : tab === "output" ? outputSub : setupSub}`} stick={tab === "run" && (runSub === "live" || runSub === "phases")}>
|
||||
{tab === "setup" && setupSub === "overview" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{mission.description && (
|
||||
@@ -841,7 +801,19 @@ export function MissionCanvas({
|
||||
<PhaseGoalStrip missionId={mission.id} phase={p} />
|
||||
<PhaseRunsList runs={runsByPhase.get(p.id) ?? []} />
|
||||
{(p.status === "completed" || p.status === "failed") && (
|
||||
<PhaseSummaryCard missionId={mission.id} phaseId={p.id} />
|
||||
// Which summary is worth reading right now: while the
|
||||
// mission runs, the operator is watching the live phase, so
|
||||
// finished ones fold away. Once it is over, the LAST phase
|
||||
// holds the outcome. A failure always opens.
|
||||
<PhaseSummaryCard
|
||||
missionId={mission.id}
|
||||
phaseId={p.id}
|
||||
defaultOpen={
|
||||
p.status === "failed" ||
|
||||
(mission.status !== "running" &&
|
||||
p.id === mission.phases[mission.phases.length - 1]?.id)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{mission.status === "running" || mission.status === "completed" ? (
|
||||
<div
|
||||
@@ -1157,7 +1129,7 @@ export function MissionCanvas({
|
||||
visible={tab === "setup" && setupSub === "pane"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</MissionTabScroller>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user