"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
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 = {
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): DocKey =>
`${d.run_id}:${d.index}`;
export function MissionOutputReader({
missionId,
phases,
visible,
}: {
missionId: string;
phases: MissionPhase[];
visible: boolean;
}) {
const [docs, setDocs] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [selected, setSelected] = useState(null);
const [body, setBody] = useState("");
const [bodyLoading, setBodyLoading] = useState(false);
const [copied, setCopied] = useState(false);
const docScroll = useRef(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();
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 (
Loading documents…
);
}
if (!loading && docs.length === 0) {
return (
No agent output yet.
Documents appear here as each phase's agents finish their turns.
{error && (
{error}
)}
);
}
return (
{/* ── rail: every document, grouped by phase ── */}
{groups.map((g) => (
{g.label} · {g.docs.length}
{g.docs.map((d) => {
const k = keyOf(d);
const active = k === selected;
return (
);
})}
))}
{/* ── document: the only place long-form content is read ── */}
{error && (
{error}
)}
{selectedDoc && (
{selectedDoc.title}
{humanRole(selectedDoc.role)} · {selectedDoc.node_id} ·{" "}
{selectedDoc.chars.toLocaleString()} chars
)}
{bodyLoading ? (
Loading document…
) : body ? (
) : null}
{/* ── outline: headings of the open document ── */}
Outline
{outline.length === 0 ? (
No headings
) : (
outline.map((h) => (
))
)}
);
}
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",
};