feat(missions): document reader + three-tab IA for the mission page

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]>
This commit is contained in:
Omar Sobh
2026-07-28 15:16:14 +02:00
co-authored by Claude Opus 5
parent d676a9e089
commit 0785ac9c79
10 changed files with 1178 additions and 69 deletions
@@ -0,0 +1,457 @@
"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&apos;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",
};