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]>
298 lines
10 KiB
TypeScript
298 lines
10 KiB
TypeScript
"use client";
|
|
|
|
// Per-phase list of topology_run rows shown on the mission phase card.
|
|
// Extracted from MissionCanvas to keep that file under the 1250-line
|
|
// budget. Owns the "show/hide activity" toggle for running runs and
|
|
// mounts PhaseRunStream on demand.
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { getRunOutput, type MissionRunSummary, type RunOutput } from "@/lib/api/missions";
|
|
import { PhaseRunStream } from "./PhaseRunStream";
|
|
|
|
const mono =
|
|
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
|
|
|
export function PhaseRunsList({ runs }: { runs: MissionRunSummary[] }) {
|
|
// 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;
|
|
return (
|
|
<div
|
|
style={{
|
|
marginTop: 6,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 4,
|
|
}}
|
|
>
|
|
{runs.map((r) => {
|
|
const isFailed = r.status === "failed";
|
|
const isTerminal = ["completed", "failed", "cancelled"].includes(r.status);
|
|
return (
|
|
<div
|
|
key={r.id}
|
|
style={{
|
|
padding: "6px 9px",
|
|
borderRadius: 6,
|
|
border: `1px solid ${
|
|
isFailed ? "rgba(255,138,122,.35)" : "rgba(255,255,255,.06)"
|
|
}`,
|
|
background: isFailed
|
|
? "rgba(255,138,122,.06)"
|
|
: "rgba(255,255,255,.02)",
|
|
fontFamily: mono,
|
|
fontSize: 11,
|
|
color: "#cfcfd5",
|
|
}}
|
|
>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
<span
|
|
style={{
|
|
color:
|
|
r.status === "failed"
|
|
? "#ff8a7a"
|
|
: r.status === "completed"
|
|
? "#5fd08a"
|
|
: r.status === "running"
|
|
? "#5ec8d8"
|
|
: "#8a8a92",
|
|
textTransform: "uppercase",
|
|
letterSpacing: ".08em",
|
|
fontSize: 10,
|
|
}}
|
|
>
|
|
run · {r.status}
|
|
</span>
|
|
<span style={{ color: "#6a6a72", fontSize: 10 }}>
|
|
{r.id.slice(0, 8)}
|
|
</span>
|
|
{isTerminal && r.finished_at && (
|
|
<span style={{ color: "#6a6a72", fontSize: 10 }}>
|
|
{new Date(r.finished_at).toLocaleTimeString()}
|
|
</span>
|
|
)}
|
|
{(r.status === "running" || isTerminal) && (
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setExpanded((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(r.id)) next.delete(r.id);
|
|
else next.add(r.id);
|
|
return next;
|
|
})
|
|
}
|
|
style={{
|
|
marginLeft: "auto",
|
|
background: "transparent",
|
|
border: `1px solid ${r.status === "running" ? "rgba(94,200,216,.35)" : "rgba(255,255,255,.18)"}`,
|
|
color: r.status === "running" ? "#5ec8d8" : "#c9c9d0",
|
|
fontSize: 10,
|
|
padding: "2px 8px",
|
|
borderRadius: 4,
|
|
cursor: "pointer",
|
|
fontFamily: mono,
|
|
}}
|
|
>
|
|
{expanded.has(r.id)
|
|
? "hide output"
|
|
: r.status === "running"
|
|
? "show activity"
|
|
: "show output"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{r.status === "running" && expanded.has(r.id) && (
|
|
<PhaseRunStream runId={r.id} />
|
|
)}
|
|
{isTerminal && expanded.has(r.id) && <RunOutputPanel runId={r.id} />}
|
|
{isFailed && r.error && (
|
|
<details style={{ marginTop: 4 }}>
|
|
<summary
|
|
style={{
|
|
cursor: "pointer",
|
|
color: "#ff8a7a",
|
|
fontSize: 11,
|
|
}}
|
|
>
|
|
{r.error.split("\n")[0].slice(0, 180) || "error"}
|
|
</summary>
|
|
<pre
|
|
style={{
|
|
margin: "6px 0 0",
|
|
padding: 8,
|
|
borderRadius: 5,
|
|
background: "rgba(0,0,0,.35)",
|
|
color: "#e0d0cf",
|
|
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",
|
|
wordBreak: "break-word",
|
|
}}
|
|
>
|
|
{r.error}
|
|
</pre>
|
|
</details>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** First few lines of a turn — enough to recognize it in the timeline,
|
|
* short enough not to need its own scrollbar. */
|
|
function excerpt(text: string, lines = 12): string {
|
|
const parts = text.split("\n");
|
|
if (parts.length <= lines) return text;
|
|
return `${parts.slice(0, lines).join("\n")}\n…`;
|
|
}
|
|
|
|
function RunOutputPanel({ runId }: { runId: string }) {
|
|
const [data, setData] = useState<RunOutput | null>(null);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
useEffect(() => {
|
|
let alive = true;
|
|
getRunOutput(runId)
|
|
.then((d) => {
|
|
if (alive) setData(d);
|
|
})
|
|
.catch((e) => {
|
|
if (alive) setErr(String(e));
|
|
});
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, [runId]);
|
|
if (err) {
|
|
return (
|
|
<div style={{ marginTop: 6, fontSize: 11, color: "#ff8a7a" }}>{err}</div>
|
|
);
|
|
}
|
|
if (!data) {
|
|
return (
|
|
<div style={{ marginTop: 6, fontSize: 11, color: "#6a6a72" }}>
|
|
loading output…
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div
|
|
style={{
|
|
marginTop: 6,
|
|
padding: 8,
|
|
borderRadius: 5,
|
|
background: "rgba(0,0,0,.35)",
|
|
fontFamily: mono,
|
|
fontSize: 11,
|
|
color: "#cfcfd5",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 8,
|
|
}}
|
|
>
|
|
<div style={{ display: "flex", gap: 12, color: "#8a8a92", fontSize: 10 }}>
|
|
<span>turns: {data.turns}</span>
|
|
<span>tokens: {data.tokens.toLocaleString()}</span>
|
|
<span>records: {data.records_count}</span>
|
|
<span>outputs: {data.outputs.length}</span>
|
|
</div>
|
|
{data.outputs.length === 0 ? (
|
|
<span style={{ color: "#6a6a72" }}>
|
|
Run completed but produced no output. The agents may have been unable
|
|
to reach their working directory or found nothing to act on.
|
|
</span>
|
|
) : (
|
|
data.outputs.map((o, i) => {
|
|
// First turn open by default so the operator sees something
|
|
// without a click; every subsequent turn collapses so the
|
|
// panel stays glanceable — click to expand each turn.
|
|
const firstLine =
|
|
o.preview
|
|
.split("\n")
|
|
.find((l) => l.trim().length > 0)
|
|
?.trim() ?? "";
|
|
const preview =
|
|
firstLine.length > 140 ? `${firstLine.slice(0, 140)}…` : firstLine;
|
|
return (
|
|
<details key={i} open={i === 0}>
|
|
<summary
|
|
style={{
|
|
cursor: "pointer",
|
|
color: "#5ec8d8",
|
|
fontSize: 10,
|
|
letterSpacing: ".06em",
|
|
textTransform: "uppercase",
|
|
padding: "3px 0",
|
|
listStyle: "revert",
|
|
}}
|
|
>
|
|
turn {i + 1}
|
|
{o.truncated
|
|
? ` · ${o.preview.length.toLocaleString()}/${o.full_len.toLocaleString()} chars`
|
|
: ""}
|
|
{preview && (
|
|
<span
|
|
style={{
|
|
color: "#8a8a92",
|
|
textTransform: "none",
|
|
letterSpacing: 0,
|
|
marginLeft: 8,
|
|
fontSize: 10,
|
|
}}
|
|
>
|
|
· {preview}
|
|
</span>
|
|
)}
|
|
</summary>
|
|
{/* A SHORT excerpt only — no inner scrollbar. This used to be
|
|
a 300px scroll box nested inside the 260px run box inside
|
|
the page scroller, which made long output unreadable. The
|
|
full document lives in the Output tab's reader. */}
|
|
<pre
|
|
style={{
|
|
margin: "4px 0 0",
|
|
padding: 6,
|
|
borderRadius: 4,
|
|
background: "rgba(0,0,0,.5)",
|
|
color: "#e0e0e5",
|
|
fontSize: 10.5,
|
|
whiteSpace: "pre-wrap",
|
|
wordBreak: "break-word",
|
|
}}
|
|
>
|
|
{excerpt(o.preview)}
|
|
</pre>
|
|
<span
|
|
style={{
|
|
display: "block",
|
|
marginTop: 4,
|
|
fontSize: 10,
|
|
color: "#6a6a72",
|
|
}}
|
|
>
|
|
{o.full_len.toLocaleString()} chars · open the{" "}
|
|
<strong style={{ color: "#7cd6e0", fontWeight: 600 }}>
|
|
Output
|
|
</strong>{" "}
|
|
tab to read this in full
|
|
</span>
|
|
</details>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
);
|
|
}
|