The mission page made its own output unreadable. Reviewing a research
brief meant scrolling a 300px <pre> nested inside a 260px run box nested
inside the page scroller (plus a 4th scroll region for the description) —
and the text was capped at 6,000 chars server-side with no way to fetch
the rest, so a 53kB brief showed ~11% of itself and silently dropped the
remainder. Eight flat tabs (overview/phases/tasks/team/live/artifacts/
benchmarks/pane) mixed lifecycle, work items, people, telemetry, outputs
and infra at one level, so nothing indicated where the deliverable lived.
Reader:
- GET /api/missions/{id}/documents lists every agent output (titles +
sizes, no bodies); GET .../documents/{run_id}/{index} returns one in
full. Scoped to the mission so a run id from elsewhere can't be read.
- MissionOutputReader: rail (documents grouped by phase) · document ·
outline (headings, click to jump). Exactly one scroll container per
column, never nested. Copy + download .md.
- MarkdownBlock gains fenced code blocks (agent output is full of ```rust,
previously mangled into paragraphs), h4-h6, heading anchors, and an
outlineOf() helper.
Information architecture:
- Three primary tabs with shallow sub-views: RUN (phases/tasks/live) ·
OUTPUT (documents/artifacts/benchmarks) · SETUP (overview/team/pane).
- PhaseRunsList shows a short excerpt with no inner scrollbar and points
at the reader for the full text.
- The header description is clipped, not scrollable; its full text now
has a home in Setup → Overview.
Missions list:
- /api/missions returns MissionListItem — Mission flattened plus
phases_total/phases_done/current_phase, so the JSON stays a strict
superset. Cards render a progress bar and "Coding · 1/2" instead of a
bare status dot.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
288 lines
9.4 KiB
TypeScript
288 lines
9.4 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[] }) {
|
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
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,
|
|
whiteSpace: "pre-wrap",
|
|
wordBreak: "break-word",
|
|
maxHeight: 260,
|
|
overflow: "auto",
|
|
}}
|
|
>
|
|
{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>
|
|
);
|
|
}
|