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]>
458 lines
14 KiB
TypeScript
458 lines
14 KiB
TypeScript
"use client";
|
||
|
||
// MissionOutputReader — the mission's reading surface.
|
||
//
|
||
// Agent phases produce 40–55kB markdown briefs per turn. Before this,
|
||
// the only way to see them was a 300px-tall <pre> nested inside a 260px
|
||
// run box inside the page scroller, showing the first 6,000 chars with
|
||
// no way to reach the rest. This replaces that with a document reader:
|
||
//
|
||
// left rail every document in the mission, grouped by phase
|
||
// right pane the selected document IN FULL, rendered as markdown
|
||
// outline that document's headings, click to jump
|
||
//
|
||
// Exactly ONE scroll container per column — no nesting. The rail and the
|
||
// document scroll independently; the page body never scrolls.
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { Copy, Download, FileText, Loader2 } from "lucide-react";
|
||
|
||
import {
|
||
getMissionDocument,
|
||
listMissionDocuments,
|
||
type MissionDocument,
|
||
type MissionPhase,
|
||
type PhaseKind,
|
||
} from "@/lib/api/missions";
|
||
import { MarkdownBlock, outlineOf } from "./MarkdownBlock";
|
||
|
||
const mono =
|
||
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
||
|
||
const PHASE_LABEL: Record<PhaseKind, string> = {
|
||
research: "Research",
|
||
coding: "Coding",
|
||
benchmark: "Benchmark",
|
||
security_scan: "Security scan",
|
||
};
|
||
|
||
/** `code_archeologist` → `Code Archeologist`. */
|
||
function humanRole(role: string): string {
|
||
return role
|
||
.split(/[_\s]+/)
|
||
.filter(Boolean)
|
||
.map((w) => w[0].toUpperCase() + w.slice(1))
|
||
.join(" ");
|
||
}
|
||
|
||
function sizeLabel(chars: number): string {
|
||
return chars >= 1000 ? `${Math.round(chars / 1000)}k` : `${chars}`;
|
||
}
|
||
|
||
type DocKey = string;
|
||
const keyOf = (d: Pick<MissionDocument, "run_id" | "index">): DocKey =>
|
||
`${d.run_id}:${d.index}`;
|
||
|
||
export function MissionOutputReader({
|
||
missionId,
|
||
phases,
|
||
visible,
|
||
}: {
|
||
missionId: string;
|
||
phases: MissionPhase[];
|
||
visible: boolean;
|
||
}) {
|
||
const [docs, setDocs] = useState<MissionDocument[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [selected, setSelected] = useState<DocKey | null>(null);
|
||
const [body, setBody] = useState<string>("");
|
||
const [bodyLoading, setBodyLoading] = useState(false);
|
||
const [copied, setCopied] = useState(false);
|
||
const docScroll = useRef<HTMLDivElement | null>(null);
|
||
|
||
const loadList = useCallback(async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const res = await listMissionDocuments(missionId);
|
||
setDocs(res.documents);
|
||
// Default to the newest document so the pane is never empty.
|
||
setSelected((prev) => {
|
||
if (prev && res.documents.some((d) => keyOf(d) === prev)) return prev;
|
||
const last = res.documents[res.documents.length - 1];
|
||
return last ? keyOf(last) : null;
|
||
});
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "failed to load documents");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [missionId]);
|
||
|
||
useEffect(() => {
|
||
if (!visible) return;
|
||
void loadList();
|
||
}, [visible, loadList]);
|
||
|
||
const selectedDoc = useMemo(
|
||
() => docs.find((d) => keyOf(d) === selected) ?? null,
|
||
[docs, selected],
|
||
);
|
||
|
||
// Fetch the selected document's full text.
|
||
useEffect(() => {
|
||
if (!visible || !selectedDoc) {
|
||
setBody("");
|
||
return;
|
||
}
|
||
let alive = true;
|
||
setBodyLoading(true);
|
||
getMissionDocument(missionId, selectedDoc.run_id, selectedDoc.index)
|
||
.then((d) => {
|
||
if (!alive) return;
|
||
setBody(d.body);
|
||
setError(null);
|
||
// A new document starts at the top, not wherever the last one sat.
|
||
docScroll.current?.scrollTo({ top: 0 });
|
||
})
|
||
.catch((e) => {
|
||
if (!alive) return;
|
||
setError(e instanceof Error ? e.message : "failed to load document");
|
||
setBody("");
|
||
})
|
||
.finally(() => {
|
||
if (alive) setBodyLoading(false);
|
||
});
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, [missionId, selectedDoc, visible]);
|
||
|
||
const outline = useMemo(() => (body ? outlineOf(body) : []), [body]);
|
||
|
||
// Group documents under their phase, in phase order. Documents whose
|
||
// phase is unknown (ad-hoc runs) collect under a trailing bucket.
|
||
const groups = useMemo(() => {
|
||
const byPhase = new Map<string, MissionDocument[]>();
|
||
for (const d of docs) {
|
||
const k = d.phase_id ?? "__unphased";
|
||
const list = byPhase.get(k);
|
||
if (list) list.push(d);
|
||
else byPhase.set(k, [d]);
|
||
}
|
||
const ordered = [...phases]
|
||
.sort((a, b) => a.order_idx - b.order_idx)
|
||
.filter((p) => byPhase.has(p.id))
|
||
.map((p) => ({
|
||
id: p.id,
|
||
label: `${PHASE_LABEL[p.kind] ?? p.kind}`,
|
||
docs: byPhase.get(p.id) ?? [],
|
||
}));
|
||
const loose = byPhase.get("__unphased");
|
||
if (loose?.length) {
|
||
ordered.push({
|
||
id: "__unphased",
|
||
label: "Other runs",
|
||
docs: loose,
|
||
});
|
||
}
|
||
return ordered;
|
||
}, [docs, phases]);
|
||
|
||
const jumpTo = useCallback((id: string) => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
}, []);
|
||
|
||
const copyBody = useCallback(async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(body);
|
||
setCopied(true);
|
||
window.setTimeout(() => setCopied(false), 1500);
|
||
} catch {
|
||
// Clipboard can be blocked; the download button is the fallback.
|
||
}
|
||
}, [body]);
|
||
|
||
const downloadBody = useCallback(() => {
|
||
if (!selectedDoc) return;
|
||
const blob = new Blob([body], { type: "text/markdown" });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `${selectedDoc.role}-${selectedDoc.index + 1}.md`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}, [body, selectedDoc]);
|
||
|
||
if (loading && docs.length === 0) {
|
||
return (
|
||
<div style={{ padding: 22, fontFamily: mono, fontSize: 11, color: "#5ec8d8" }}>
|
||
Loading documents…
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!loading && docs.length === 0) {
|
||
return (
|
||
<div style={{ padding: 22, display: "flex", flexDirection: "column", gap: 8 }}>
|
||
<span style={{ fontSize: 13, color: "#cfcfd5" }}>
|
||
No agent output yet.
|
||
</span>
|
||
<span style={{ fontSize: 12, color: "#8a8a92", lineHeight: 1.5 }}>
|
||
Documents appear here as each phase's agents finish their turns.
|
||
</span>
|
||
{error && (
|
||
<span style={{ fontSize: 12, color: "#ff8a7a" }}>{error}</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
minHeight: 0,
|
||
display: "grid",
|
||
// rail · document · outline. The outline collapses away on
|
||
// narrow viewports so the document keeps its reading width.
|
||
gridTemplateColumns: "230px minmax(0, 1fr) 200px",
|
||
alignItems: "stretch",
|
||
}}
|
||
>
|
||
{/* ── rail: every document, grouped by phase ── */}
|
||
<div
|
||
style={{
|
||
minHeight: 0,
|
||
overflowY: "auto",
|
||
borderRight: "1px solid rgba(255,255,255,.07)",
|
||
padding: "12px 8px",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 12,
|
||
}}
|
||
>
|
||
{groups.map((g) => (
|
||
<div key={g.id} style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||
<div
|
||
style={{
|
||
fontFamily: mono,
|
||
fontSize: 9.5,
|
||
letterSpacing: ".14em",
|
||
textTransform: "uppercase",
|
||
color: "#6a6a72",
|
||
padding: "0 6px 2px",
|
||
}}
|
||
>
|
||
{g.label} · {g.docs.length}
|
||
</div>
|
||
{g.docs.map((d) => {
|
||
const k = keyOf(d);
|
||
const active = k === selected;
|
||
return (
|
||
<button
|
||
key={k}
|
||
type="button"
|
||
onClick={() => setSelected(k)}
|
||
title={d.title}
|
||
style={{
|
||
textAlign: "left",
|
||
padding: "6px 8px",
|
||
borderRadius: 8,
|
||
border: `1px solid ${active ? "rgba(255,138,122,.45)" : "transparent"}`,
|
||
background: active ? "rgba(255,138,122,.08)" : "transparent",
|
||
cursor: "pointer",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 2,
|
||
minWidth: 0,
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
fontSize: 11.5,
|
||
color: active ? "#ff8a7a" : "#cfcfd5",
|
||
fontWeight: active ? 600 : 400,
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{humanRole(d.role)}
|
||
</span>
|
||
<span
|
||
style={{
|
||
fontFamily: mono,
|
||
fontSize: 9.5,
|
||
color: "#6a6a72",
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{d.node_id} · {sizeLabel(d.chars)} chars
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* ── document: the only place long-form content is read ── */}
|
||
<div
|
||
ref={docScroll}
|
||
style={{
|
||
minHeight: 0,
|
||
overflowY: "auto",
|
||
overflowX: "hidden",
|
||
padding: "18px 26px 60px",
|
||
}}
|
||
>
|
||
{error && (
|
||
<div style={{ marginBottom: 12, fontSize: 12, color: "#ff8a7a" }}>
|
||
{error}
|
||
</div>
|
||
)}
|
||
{selectedDoc && (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "flex-start",
|
||
gap: 10,
|
||
marginBottom: 14,
|
||
paddingBottom: 12,
|
||
borderBottom: "1px solid rgba(255,255,255,.07)",
|
||
}}
|
||
>
|
||
<FileText size={15} style={{ color: "#7cd6e0", flex: "none", marginTop: 3 }} />
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontSize: 15, color: "#f3f3f5", fontWeight: 600 }}>
|
||
{selectedDoc.title}
|
||
</div>
|
||
<div
|
||
style={{
|
||
fontFamily: mono,
|
||
fontSize: 10,
|
||
color: "#8a8a92",
|
||
marginTop: 3,
|
||
}}
|
||
>
|
||
{humanRole(selectedDoc.role)} · {selectedDoc.node_id} ·{" "}
|
||
{selectedDoc.chars.toLocaleString()} chars
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={copyBody}
|
||
disabled={!body}
|
||
title="Copy the full document"
|
||
style={readerBtn}
|
||
>
|
||
<Copy size={12} /> {copied ? "Copied" : "Copy"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={downloadBody}
|
||
disabled={!body}
|
||
title="Download as .md"
|
||
style={readerBtn}
|
||
>
|
||
<Download size={12} /> .md
|
||
</button>
|
||
</div>
|
||
)}
|
||
{bodyLoading ? (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
fontFamily: mono,
|
||
fontSize: 11,
|
||
color: "#5ec8d8",
|
||
}}
|
||
>
|
||
<Loader2 size={13} className="animate-spin" /> Loading document…
|
||
</div>
|
||
) : body ? (
|
||
<MarkdownBlock source={body} />
|
||
) : null}
|
||
</div>
|
||
|
||
{/* ── outline: headings of the open document ── */}
|
||
<div
|
||
style={{
|
||
minHeight: 0,
|
||
overflowY: "auto",
|
||
borderLeft: "1px solid rgba(255,255,255,.07)",
|
||
padding: "16px 10px",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 2,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
fontFamily: mono,
|
||
fontSize: 9.5,
|
||
letterSpacing: ".14em",
|
||
textTransform: "uppercase",
|
||
color: "#6a6a72",
|
||
padding: "0 6px 6px",
|
||
}}
|
||
>
|
||
Outline
|
||
</div>
|
||
{outline.length === 0 ? (
|
||
<span style={{ padding: "0 6px", fontSize: 11, color: "#6a6a72" }}>
|
||
No headings
|
||
</span>
|
||
) : (
|
||
outline.map((h) => (
|
||
<button
|
||
key={h.id}
|
||
type="button"
|
||
onClick={() => jumpTo(h.id)}
|
||
title={h.text}
|
||
style={{
|
||
textAlign: "left",
|
||
padding: "3px 6px",
|
||
paddingLeft: 6 + (h.level - 1) * 10,
|
||
borderRadius: 6,
|
||
border: "1px solid transparent",
|
||
background: "transparent",
|
||
cursor: "pointer",
|
||
color: h.level === 1 ? "#cfcfd5" : "#8a8a92",
|
||
fontSize: h.level === 1 ? 11.5 : 11,
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{h.text}
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const readerBtn: React.CSSProperties = {
|
||
flex: "none",
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: 5,
|
||
padding: "4px 9px",
|
||
borderRadius: 7,
|
||
border: "1px solid rgba(255,255,255,.10)",
|
||
background: "transparent",
|
||
color: "#a0a0a8",
|
||
fontFamily: mono,
|
||
fontSize: 10,
|
||
cursor: "pointer",
|
||
};
|