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
@@ -44,6 +44,7 @@ import { EditMissionModal } from "./EditMissionModal";
import { MarkdownBlock } from "./MarkdownBlock";
import { MissionLiveEvents } from "./MissionLiveEvents";
import { MissionLivePane } from "./MissionLivePane";
import { MissionOutputReader } from "./MissionOutputReader";
import { MissionTeamTab } from "./MissionTeamTab";
import { MissionWizard } from "./MissionWizard";
import { PhaseRunsList } from "./PhaseRunsList";
@@ -89,15 +90,19 @@ const TEMPLATE_LABEL: Record<TemplateKind, string> = {
custom: "Custom",
};
type Tab =
| "overview"
| "phases"
| "tasks"
| "team"
| "live"
| "artifacts"
| "benchmarks"
| "pane";
// Three primary tabs, each with a shallow segmented sub-view. The old
// shape was eight flat tabs (overview/phases/tasks/team/live/artifacts/
// benchmarks/pane) that mixed lifecycle, work items, people, telemetry,
// outputs and infra at one level — so nothing told you where the actual
// deliverable lived (it was buried under phases → run → turn).
//
// RUN what is happening · phases · tasks · live
// OUTPUT what came out of it · documents · artifacts · benchmarks
// SETUP how it is configured · overview · team · pane
type Tab = "run" | "output" | "setup";
type RunSub = "phases" | "tasks" | "live";
type OutputSub = "documents" | "artifacts" | "benchmarks";
type SetupSub = "overview" | "team" | "pane";
export function MissionCanvas({
selectedId,
@@ -118,7 +123,10 @@ export function MissionCanvas({
const [mission, setMission] = useState<MissionDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>("overview");
const [tab, setTab] = useState<Tab>("run");
const [runSub, setRunSub] = useState<RunSub>("phases");
const [outputSub, setOutputSub] = useState<OutputSub>("documents");
const [setupSub, setSetupSub] = useState<SetupSub>("overview");
const [launching, setLaunching] = useState(false);
const [refining, setRefining] = useState(false);
const [refineDiff, setRefineDiff] = useState<RefineResult | null>(null);
@@ -540,12 +548,19 @@ export function MissionCanvas({
)}
</div>
{mission.description && !headerCollapsed && (
// Clipped, NOT scrollable — a scroll container here was a fourth
// nested scrollbar above the content area. The full text lives in
// Setup → Overview.
<div
style={{
marginTop: 4,
maxHeight: "38vh",
overflowY: "auto",
maxHeight: 92,
overflow: "hidden",
paddingRight: 8,
maskImage:
"linear-gradient(to bottom, #000 60%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(to bottom, #000 60%, transparent 100%)",
}}
>
<MarkdownBlock source={mission.description} />
@@ -582,52 +597,97 @@ export function MissionCanvas({
}}
/>
)}
{/* Primary tabs — three, not eight. */}
<div
style={{
display: "flex",
gap: 4,
marginTop: 4,
marginTop: 6,
overflowX: "auto",
paddingBottom: 2,
scrollbarWidth: "thin",
}}
>
{(
[
"overview",
"phases",
"tasks",
"team",
"live",
"artifacts",
"benchmarks",
...(mission.runtime_kind === "local_herdr" ? (["pane"] as const) : []),
] as Tab[]
).map((t) => {
{(["run", "output", "setup"] as Tab[]).map((t) => {
const active = tab === t;
const badge =
t === "tasks"
? mission.tasks.length
: t === "artifacts"
? mission.artifacts.length
: t === "phases"
? mission.phases.length
: t === "benchmarks"
? mission.benchmarks.length
: null;
return (
<button
key={t}
type="button"
onClick={() => setTab(t)}
style={{
padding: "5px 12px",
padding: "6px 16px",
borderRadius: 8,
border: `1px solid ${active ? "rgba(255,138,122,.5)" : "rgba(255,255,255,.08)"}`,
background: active ? "rgba(255,138,122,.08)" : "transparent",
color: active ? "#ff8a7a" : "#a0a0a8",
fontFamily: mono,
fontSize: 11,
letterSpacing: ".10em",
textTransform: "uppercase",
fontWeight: active ? 600 : 400,
cursor: "pointer",
flex: "none",
whiteSpace: "nowrap",
}}
>
{t}
</button>
);
})}
</div>
{/* Sub-view for the active tab. */}
<div
style={{
display: "flex",
gap: 3,
marginTop: 6,
overflowX: "auto",
paddingBottom: 2,
scrollbarWidth: "thin",
}}
>
{(tab === "run"
? ([
["phases", mission.phases.length],
["tasks", mission.tasks.length],
["live", null],
] as Array<[string, number | null]>)
: tab === "output"
? ([
["documents", null],
["artifacts", mission.artifacts.length],
["benchmarks", mission.benchmarks.length],
] as Array<[string, number | null]>)
: ([
["overview", null],
["team", null],
...(mission.runtime_kind === "local_herdr"
? ([["pane", null]] as Array<[string, number | null]>)
: []),
] as Array<[string, number | null]>)
).map(([sub, badge]) => {
const current =
tab === "run" ? runSub : tab === "output" ? outputSub : setupSub;
const active = current === sub;
return (
<button
key={sub}
type="button"
onClick={() => {
if (tab === "run") setRunSub(sub as RunSub);
else if (tab === "output") setOutputSub(sub as OutputSub);
else setSetupSub(sub as SetupSub);
}}
style={{
padding: "3px 10px",
borderRadius: 999,
border: "1px solid transparent",
background: active ? "rgba(255,255,255,.07)" : "transparent",
color: active ? "#e0e0e5" : "#8a8a92",
fontFamily: mono,
fontSize: 10,
letterSpacing: ".08em",
textTransform: "uppercase",
cursor: "pointer",
@@ -638,16 +698,9 @@ export function MissionCanvas({
whiteSpace: "nowrap",
}}
>
{t}
{sub}
{badge !== null && (
<span
style={{
fontSize: 10,
color: active ? "#ff8a7a" : "#8a8a92",
}}
>
{badge}
</span>
<span style={{ fontSize: 9.5, color: "#6a6a72" }}>{badge}</span>
)}
</button>
);
@@ -655,9 +708,44 @@ export function MissionCanvas({
</div>
</div>
{/* The documents reader manages its own columns + scrolling, so it
renders full-bleed. Everything else lives in one padded scroller
— never a scroll container inside a scroll container. */}
{tab === "output" && outputSub === "documents" ? (
<MissionOutputReader
missionId={mission.id}
phases={mission.phases}
visible
/>
) : (
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 22 }}>
{tab === "overview" && (
{tab === "setup" && setupSub === "overview" && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{mission.description && (
<div
style={{
padding: 14,
borderRadius: 10,
border: "1px solid rgba(255,255,255,.07)",
background: "#101014",
marginBottom: 4,
}}
>
<div
style={{
fontFamily: mono,
fontSize: 9.5,
letterSpacing: ".14em",
textTransform: "uppercase",
color: "#6a6a72",
marginBottom: 8,
}}
>
Brief
</div>
<MarkdownBlock source={mission.description} />
</div>
)}
<FieldRow k="Template" v={TEMPLATE_LABEL[mission.template_kind] ?? mission.template_kind} />
<FieldRow k="Status" v={mission.status} />
<FieldRow k="Team" v={mission.team_id ?? "(auto-provision on launch)"} />
@@ -679,7 +767,7 @@ export function MissionCanvas({
</div>
)}
{tab === "phases" && (
{tab === "run" && runSub === "phases" && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{orderedPhases.length === 0 ? (
<Empty label="no phases" />
@@ -841,7 +929,7 @@ export function MissionCanvas({
</div>
)}
{tab === "tasks" && (
{tab === "run" && runSub === "tasks" && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{mission.tasks.length === 0 ? (
<Empty label="no tasks yet — task-card parser lands in Slice 5" />
@@ -910,7 +998,7 @@ export function MissionCanvas({
</div>
)}
{tab === "team" && (
{tab === "setup" && setupSub === "team" && (
<MissionTeamTab
missionId={mission.id}
teamId={mission.team_id}
@@ -918,11 +1006,11 @@ export function MissionCanvas({
/>
)}
{tab === "live" && (
<MissionLiveEvents missionId={mission.id} visible={tab === "live"} />
{tab === "run" && runSub === "live" && (
<MissionLiveEvents missionId={mission.id} visible={tab === "run" && runSub === "live"} />
)}
{tab === "artifacts" && (
{tab === "output" && outputSub === "artifacts" && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{mission.artifacts.length === 0 ? (
<Empty label="no artifacts yet — phases produce them as they run" />
@@ -1018,7 +1106,7 @@ export function MissionCanvas({
</div>
)}
{tab === "benchmarks" && (
{tab === "output" && outputSub === "benchmarks" && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{mission.benchmarks.length === 0 ? (
<Empty label="no benchmark snapshots yet — trigger a baseline via /api/missions/{id}/benchmark or a workflow with benchmark = { mode = &quot;before_after&quot; }" />
@@ -1151,13 +1239,14 @@ export function MissionCanvas({
</div>
)}
{tab === "pane" && mission.runtime_kind === "local_herdr" && (
{tab === "setup" && setupSub === "pane" && mission.runtime_kind === "local_herdr" && (
<MissionLivePane
nodeId={mission.target_node_id}
visible={tab === "pane"}
visible={tab === "setup" && setupSub === "pane"}
/>
)}
</div>
)}
</div>
);
}