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
@@ -197,13 +197,13 @@ export function MissionArtifacts({
|
|||||||
</div>
|
</div>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<div
|
||||||
|
// No maxHeight — this body is only rendered because the operator
|
||||||
|
// opened it to read it. The tab scroller handles the length.
|
||||||
style={{
|
style={{
|
||||||
border: "1px solid rgba(255,255,255,.06)",
|
border: "1px solid rgba(255,255,255,.06)",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
background: "#0a0a0d",
|
background: "#0a0a0d",
|
||||||
padding: 16,
|
padding: 16,
|
||||||
maxHeight: 640,
|
|
||||||
overflowY: "auto",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
@@ -10,8 +10,6 @@
|
|||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
Pencil,
|
Pencil,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -41,6 +39,7 @@ import {
|
|||||||
} from "@/lib/api/missions";
|
} from "@/lib/api/missions";
|
||||||
import { EditMissionModal } from "./EditMissionModal";
|
import { EditMissionModal } from "./EditMissionModal";
|
||||||
import { MarkdownBlock } from "./MarkdownBlock";
|
import { MarkdownBlock } from "./MarkdownBlock";
|
||||||
|
import { MissionTabScroller } from "./MissionTabScroller";
|
||||||
import { MissionArtifacts } from "./MissionArtifacts";
|
import { MissionArtifacts } from "./MissionArtifacts";
|
||||||
import { MissionLiveEvents } from "./MissionLiveEvents";
|
import { MissionLiveEvents } from "./MissionLiveEvents";
|
||||||
import { MissionLivePane } from "./MissionLivePane";
|
import { MissionLivePane } from "./MissionLivePane";
|
||||||
@@ -141,22 +140,6 @@ export function MissionCanvas({
|
|||||||
const [deleteBusy, setDeleteBusy] = useState(false);
|
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||||
const [runs, setRuns] = useState<MissionRunSummary[]>([]);
|
const [runs, setRuns] = useState<MissionRunSummary[]>([]);
|
||||||
const [lastLoadedAt, setLastLoadedAt] = useState<Date | null>(null);
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
if (!selectedId) {
|
if (!selectedId) {
|
||||||
setMission(null);
|
setMission(null);
|
||||||
@@ -320,7 +303,7 @@ export function MissionCanvas({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
position: "absolute", inset: 0,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
@@ -336,20 +319,22 @@ export function MissionCanvas({
|
|||||||
}
|
}
|
||||||
if (loading && !mission) {
|
if (loading && !mission) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 20, fontFamily: mono, color: "#5ec8d8" }}>
|
<div style={{ position: "absolute", inset: 0, padding: 20, fontFamily: mono, color: "#5ec8d8" }}>
|
||||||
Loading…
|
Loading…
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
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;
|
if (!mission) return null;
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
flex: "none",
|
flex: "none",
|
||||||
@@ -531,44 +516,15 @@ export function MissionCanvas({
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</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 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
<h1 style={{ margin: 0, fontSize: 20, color: "#f3f3f5", flex: 1, minWidth: 0 }}>
|
<h1 style={{ margin: 0, fontSize: 20, color: "#f3f3f5", flex: 1, minWidth: 0 }}>
|
||||||
{mission.title}
|
{mission.title}
|
||||||
</h1>
|
</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>
|
</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 && (
|
{refineDiff && (
|
||||||
<RefineDiffModal
|
<RefineDiffModal
|
||||||
original={refineDiff.original}
|
original={refineDiff.original}
|
||||||
@@ -721,7 +677,11 @@ export function MissionCanvas({
|
|||||||
visible
|
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" && (
|
{tab === "setup" && setupSub === "overview" && (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
{mission.description && (
|
{mission.description && (
|
||||||
@@ -841,7 +801,19 @@ export function MissionCanvas({
|
|||||||
<PhaseGoalStrip missionId={mission.id} phase={p} />
|
<PhaseGoalStrip missionId={mission.id} phase={p} />
|
||||||
<PhaseRunsList runs={runsByPhase.get(p.id) ?? []} />
|
<PhaseRunsList runs={runsByPhase.get(p.id) ?? []} />
|
||||||
{(p.status === "completed" || p.status === "failed") && (
|
{(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" ? (
|
{mission.status === "running" || mission.status === "completed" ? (
|
||||||
<div
|
<div
|
||||||
@@ -1157,7 +1129,7 @@ export function MissionCanvas({
|
|||||||
visible={tab === "setup" && setupSub === "pane"}
|
visible={tab === "setup" && setupSub === "pane"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</MissionTabScroller>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ export function MissionLiveEvents({
|
|||||||
const [feed, setFeed] = useState<FeedItem[]>([]);
|
const [feed, setFeed] = useState<FeedItem[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const seq = useRef(0);
|
const seq = useRef(0);
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
// Poll the run list every 5s while there's any activity — cheap and
|
// Poll the run list every 5s while there's any activity — cheap and
|
||||||
// lets a newly-enqueued run auto-attach without a manual refresh.
|
// lets a newly-enqueued run auto-attach without a manual refresh.
|
||||||
@@ -130,16 +129,15 @@ export function MissionLiveEvents({
|
|||||||
};
|
};
|
||||||
}, [runIds, visible]);
|
}, [runIds, visible]);
|
||||||
|
|
||||||
// Auto-scroll to bottom on new events (unless the operator scrolled up).
|
// Following the tail is no longer this component's job: it has no scroller of
|
||||||
useEffect(() => {
|
// its own now, so the mission tab scroller does it (useStickToBottom).
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
|
||||||
if (nearBottom) el.scrollTop = el.scrollHeight;
|
|
||||||
}, [feed.length]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 10, height: "70vh" }}>
|
// No height here. `70vh` used to be a workaround for MissionCanvas's root
|
||||||
|
// having no height at all; now that it does, a fixed height would cap this
|
||||||
|
// pane below the space available AND make it a scroller inside the tab
|
||||||
|
// scroller — the nesting MissionCanvas explicitly forbids.
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -165,11 +163,7 @@ export function MissionLiveEvents({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
|
||||||
minHeight: 0,
|
|
||||||
overflowY: "auto",
|
|
||||||
padding: 12,
|
padding: 12,
|
||||||
background: "#0a0a0d",
|
background: "#0a0a0d",
|
||||||
border: "1px solid rgba(255,255,255,.06)",
|
border: "1px solid rgba(255,255,255,.06)",
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// The ONE scroller for a mission tab's body.
|
||||||
|
//
|
||||||
|
// It exists as a component rather than a bare <div> for two reasons:
|
||||||
|
// 1. It owns the scroll ref, so tail-following can act on the real scroller
|
||||||
|
// without prop-drilling a ref down into MissionLiveEvents / PhaseRunStream
|
||||||
|
// (which used to scroll their own little boxes — nested scrollers).
|
||||||
|
// 2. It gives the "jump to latest" affordance somewhere to live.
|
||||||
|
//
|
||||||
|
// `stick` is opt-in per tab: only streaming views want the view yanked to the
|
||||||
|
// newest content. Auto-jumping while someone reads Setup → Overview would be
|
||||||
|
// obnoxious, so that tab passes stick={false}.
|
||||||
|
|
||||||
|
import { ArrowDown } from "lucide-react";
|
||||||
|
|
||||||
|
import { useStickToBottom } from "@/lib/live/useStickToBottom";
|
||||||
|
|
||||||
|
const mono =
|
||||||
|
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
||||||
|
|
||||||
|
export function MissionTabScroller({
|
||||||
|
stick = false,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
stick?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { scrollRef, contentRef, following, jumpToLatest } =
|
||||||
|
useStickToBottom(stick);
|
||||||
|
|
||||||
|
return (
|
||||||
|
// position:relative so the pill can anchor to the scroller's viewport
|
||||||
|
// rather than to the (much taller) scrolled content.
|
||||||
|
<div style={{ flex: 1, minHeight: 0, position: "relative", display: "flex" }}>
|
||||||
|
<div ref={scrollRef} style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 22 }}>
|
||||||
|
<div ref={contentRef}>{children}</div>
|
||||||
|
</div>
|
||||||
|
{stick && !following ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={jumpToLatest}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 16,
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "7px 13px",
|
||||||
|
borderRadius: 999,
|
||||||
|
border: "1px solid rgba(124,214,224,.45)",
|
||||||
|
background: "rgba(10,10,13,.92)",
|
||||||
|
color: "#7cd6e0",
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 10.5,
|
||||||
|
letterSpacing: ".08em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
cursor: "pointer",
|
||||||
|
boxShadow: "0 6px 20px rgba(0,0,0,.5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowDown aria-hidden size={12} />
|
||||||
|
Jump to latest
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -194,7 +194,10 @@ export function PhaseGoalStrip({
|
|||||||
gap: 4,
|
gap: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{evals.map((e) => (
|
{/* Latest 10 only. A phase that looped many times would otherwise
|
||||||
|
push the rest of the page down with mostly-identical passes;
|
||||||
|
the recent ones are the ones that explain the outcome. */}
|
||||||
|
{evals.slice(-10).map((e) => (
|
||||||
<li
|
<li
|
||||||
key={e.iteration}
|
key={e.iteration}
|
||||||
style={{ fontSize: 11, color: "#8a8a92", lineHeight: 1.4 }}
|
style={{ fontSize: 11, color: "#8a8a92", lineHeight: 1.4 }}
|
||||||
@@ -207,6 +210,12 @@ export function PhaseGoalStrip({
|
|||||||
— {e.reason}
|
— {e.reason}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
{evals.length > 10 && (
|
||||||
|
<li style={{ fontSize: 11, color: "#6a6a72", listStyle: "none", marginLeft: -16 }}>
|
||||||
|
+{evals.length - 10} earlier pass
|
||||||
|
{evals.length - 10 === 1 ? "" : "es"} not shown
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
</ol>
|
</ol>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export function PhaseRunStream({ runId }: { runId: string }) {
|
|||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [terminal, setTerminal] = useState<string | null>(null);
|
const [terminal, setTerminal] = useState<string | null>(null);
|
||||||
const seq = useRef(0);
|
const seq = useRef(0);
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const es = new EventSource(`/api/topology-runs/${runId}/events`);
|
const es = new EventSource(`/api/topology-runs/${runId}/events`);
|
||||||
@@ -79,16 +78,14 @@ export function PhaseRunStream({ runId }: { runId: string }) {
|
|||||||
};
|
};
|
||||||
}, [runId]);
|
}, [runId]);
|
||||||
|
|
||||||
useEffect(() => {
|
// Tail-following moved to the mission tab scroller (useStickToBottom) — this
|
||||||
const el = scrollRef.current;
|
// component no longer owns a scroller to follow.
|
||||||
if (!el) return;
|
|
||||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
|
||||||
if (nearBottom) el.scrollTop = el.scrollHeight;
|
|
||||||
}, [events.length]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// No maxHeight: a 260px porthole onto a live agent stream is the "can't read
|
||||||
|
// the results" complaint in miniature. This is only mounted for a running,
|
||||||
|
// explicitly-expanded run, so it lets the tab scroller do the scrolling.
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
|
||||||
style={{
|
style={{
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
padding: 8,
|
padding: 8,
|
||||||
@@ -97,8 +94,6 @@ export function PhaseRunStream({ runId }: { runId: string }) {
|
|||||||
color: "#cfcfd5",
|
color: "#cfcfd5",
|
||||||
fontSize: 10.5,
|
fontSize: 10.5,
|
||||||
fontFamily: mono,
|
fontFamily: mono,
|
||||||
maxHeight: 260,
|
|
||||||
overflow: "auto",
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: 3,
|
gap: 3,
|
||||||
|
|||||||
@@ -13,7 +13,16 @@ const mono =
|
|||||||
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
||||||
|
|
||||||
export function PhaseRunsList({ runs }: { runs: MissionRunSummary[] }) {
|
export function PhaseRunsList({ runs }: { runs: MissionRunSummary[] }) {
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
// Open exactly ONE run by default, and only when it is worth looking at:
|
||||||
|
// still running (there is live output) or failed (it needs attention).
|
||||||
|
// Everything else stays closed — N stacked open runs was a large part of
|
||||||
|
// "too many open sections".
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(() => {
|
||||||
|
const newest = runs[runs.length - 1];
|
||||||
|
return newest && (newest.status === "running" || newest.status === "failed")
|
||||||
|
? new Set([newest.id])
|
||||||
|
: new Set<string>();
|
||||||
|
});
|
||||||
if (runs.length === 0) return null;
|
if (runs.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -124,10 +133,11 @@ export function PhaseRunsList({ runs }: { runs: MissionRunSummary[] }) {
|
|||||||
background: "rgba(0,0,0,.35)",
|
background: "rgba(0,0,0,.35)",
|
||||||
color: "#e0d0cf",
|
color: "#e0d0cf",
|
||||||
fontSize: 10.5,
|
fontSize: 10.5,
|
||||||
|
// pre-wrap + break-word already handle long lines; a
|
||||||
|
// maxHeight would truncate the stack trace the operator
|
||||||
|
// opened this <details> specifically to read.
|
||||||
whiteSpace: "pre-wrap",
|
whiteSpace: "pre-wrap",
|
||||||
wordBreak: "break-word",
|
wordBreak: "break-word",
|
||||||
maxHeight: 260,
|
|
||||||
overflow: "auto",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{r.error}
|
{r.error}
|
||||||
|
|||||||
@@ -19,17 +19,29 @@ const mono =
|
|||||||
export function PhaseSummaryCard({
|
export function PhaseSummaryCard({
|
||||||
missionId,
|
missionId,
|
||||||
phaseId,
|
phaseId,
|
||||||
|
defaultOpen = true,
|
||||||
}: {
|
}: {
|
||||||
missionId: string;
|
missionId: string;
|
||||||
phaseId: string;
|
phaseId: string;
|
||||||
|
/**
|
||||||
|
* Whether this card starts expanded when the operator has no stored
|
||||||
|
* preference. Callers pass false for phases that are not the one worth
|
||||||
|
* reading right now, so a mission with many phases does not open as a wall
|
||||||
|
* of stacked summaries. An explicit stored choice always wins over this.
|
||||||
|
*/
|
||||||
|
defaultOpen?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [summary, setSummary] = useState<PhaseSummary | null>(null);
|
const [summary, setSummary] = useState<PhaseSummary | null>(null);
|
||||||
const [waiting, setWaiting] = useState(true);
|
const [waiting, setWaiting] = useState(true);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const collapseKey = `${COLLAPSE_KEY_PREFIX}${phaseId}`;
|
const collapseKey = `${COLLAPSE_KEY_PREFIX}${phaseId}`;
|
||||||
const [collapsed, setCollapsed] = useState<boolean>(() => {
|
const [collapsed, setCollapsed] = useState<boolean>(() => {
|
||||||
if (typeof window === "undefined") return false;
|
if (typeof window === "undefined") return !defaultOpen;
|
||||||
return window.localStorage.getItem(collapseKey) === "1";
|
const stored = window.localStorage.getItem(collapseKey);
|
||||||
|
// Only "1"/"0" count as a real choice; anything else means "never set".
|
||||||
|
if (stored === "1") return true;
|
||||||
|
if (stored === "0") return false;
|
||||||
|
return !defaultOpen;
|
||||||
});
|
});
|
||||||
const toggleCollapsed = useCallback(() => {
|
const toggleCollapsed = useCallback(() => {
|
||||||
setCollapsed((v) => {
|
setCollapsed((v) => {
|
||||||
@@ -327,13 +339,9 @@ function Section({
|
|||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
// Several Sections render per card; a maxHeight here gave each one its
|
||||||
display: "flex",
|
// own scrollbar — the worst nesting in the tree. Size to content.
|
||||||
flexDirection: "column",
|
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||||
gap: 4,
|
|
||||||
maxHeight: 280,
|
|
||||||
overflow: "auto",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{items.map((it, i) => (
|
{items.map((it, i) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Follow a growing feed, but only while the reader is already at the bottom.
|
||||||
|
//
|
||||||
|
// Extracted from two byte-identical copies (MissionLiveEvents, PhaseRunStream)
|
||||||
|
// that each scrolled their OWN little box. Those boxes are gone — they were
|
||||||
|
// nested scrollers inside the mission tab scroller — so following now has to act
|
||||||
|
// on whatever scroller actually owns the content.
|
||||||
|
//
|
||||||
|
// Growth is detected with a ResizeObserver on the content wrapper rather than a
|
||||||
|
// `[feed.length]` dependency: after un-nesting, the thing that grows is several
|
||||||
|
// components below the scroller (live events, phase run streams, artifact
|
||||||
|
// bodies), and there is no single length to depend on. The observer fires for
|
||||||
|
// any growing descendant with no plumbing.
|
||||||
|
//
|
||||||
|
// The 80px threshold is inherited from the original implementations: it is the
|
||||||
|
// slack that keeps "at the bottom" true when a partially-rendered row or a
|
||||||
|
// sub-pixel layout shift leaves the scroller a few pixels short.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const NEAR_BOTTOM_PX = 80;
|
||||||
|
|
||||||
|
export type StickToBottom = {
|
||||||
|
/** Attach to the scrolling element. */
|
||||||
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
/** Attach to the element that WRAPS the growing content. */
|
||||||
|
contentRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
/** False once the reader has scrolled up — surface a "jump to latest" affordance. */
|
||||||
|
following: boolean;
|
||||||
|
/** Re-attach and jump to the newest content. */
|
||||||
|
jumpToLatest: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useStickToBottom(enabled = true): StickToBottom {
|
||||||
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [following, setFollowing] = useState(true);
|
||||||
|
// Mirrors `following` for use inside observer callbacks without making them a
|
||||||
|
// dependency — re-subscribing the observer on every scroll would be wasteful
|
||||||
|
// and would drop resize events in the gap.
|
||||||
|
const followingRef = useRef(true);
|
||||||
|
|
||||||
|
const setFollow = useCallback((v: boolean) => {
|
||||||
|
followingRef.current = v;
|
||||||
|
setFollowing((prev) => (prev === v ? prev : v));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const jumpToLatest = useCallback(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
setFollow(true);
|
||||||
|
}, [setFollow]);
|
||||||
|
|
||||||
|
// Reader intent: scrolling away from the bottom detaches, scrolling back
|
||||||
|
// re-attaches. Passive — this never calls preventDefault.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el || !enabled) return;
|
||||||
|
const onScroll = () => {
|
||||||
|
const near =
|
||||||
|
el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX;
|
||||||
|
setFollow(near);
|
||||||
|
};
|
||||||
|
el.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => el.removeEventListener("scroll", onScroll);
|
||||||
|
}, [enabled, setFollow]);
|
||||||
|
|
||||||
|
// Content grew: pin to the new bottom, but only if we were already there.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
const content = contentRef.current;
|
||||||
|
if (!el || !content || !enabled) return;
|
||||||
|
if (typeof ResizeObserver === "undefined") return;
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
if (followingRef.current) el.scrollTop = el.scrollHeight;
|
||||||
|
});
|
||||||
|
ro.observe(content);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
return { scrollRef, contentRef, following, jumpToLatest };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user