Files
clawmates/frontend/src/components/dashboard/MissionOutputReader.tsx
T
Omar SobhandClaude Opus 5 0785ac9c79 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]>
2026-07-28 15:16:14 +02:00

458 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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",
};