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
@@ -12,10 +12,38 @@ const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
type Block =
| { kind: "h1" | "h2" | "h3"; text: string }
| { kind: "h1" | "h2" | "h3"; text: string; id?: string }
| { kind: "p"; text: string }
| { kind: "ul"; items: string[] }
| { kind: "ol"; items: string[] };
| { kind: "ol"; items: string[] }
| { kind: "code"; lang: string; text: string };
/** Stable slug for a heading, so the reader's outline can scroll to it. */
export function headingId(text: string, ordinal: number): string {
const slug = text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
return `h-${ordinal}-${slug || "section"}`;
}
/** The headings of a document, for an outline rail. */
export function outlineOf(
md: string,
): Array<{ id: string; text: string; level: 1 | 2 | 3 }> {
return parse(md).flatMap((b, idx) =>
b.kind === "h1" || b.kind === "h2" || b.kind === "h3"
? [
{
id: b.id ?? headingId(b.text, idx),
text: b.text,
level: Number(b.kind.slice(1)) as 1 | 2 | 3,
},
]
: [],
);
}
function parse(md: string): Block[] {
const lines = md.replace(/\r\n/g, "\n").split("\n");
@@ -28,13 +56,32 @@ function parse(md: string): Block[] {
i++;
continue;
}
// Fenced code block. Agent output is full of ```rust / ```toml
// blocks; without this they render as mangled paragraphs.
const fence = /^```([A-Za-z0-9_+-]*)\s*$/.exec(trimmed);
if (fence) {
const lang = fence[1] ?? "";
const body: string[] = [];
i++;
while (i < lines.length && !/^```\s*$/.test(lines[i].trim())) {
body.push(lines[i]);
i++;
}
i++; // consume the closing fence (or run off the end on an unclosed block)
blocks.push({ kind: "code", lang, text: body.join("\n") });
continue;
}
// Headings
const h = /^(#{1,3})\s+(.*)$/.exec(trimmed);
const h = /^(#{1,6})\s+(.*)$/.exec(trimmed);
if (h) {
const level = h[1].length as 1 | 2 | 3;
// h4-h6 are rare in agent output; render them as h3 rather than
// dropping the text into a paragraph.
const level = Math.min(h[1].length, 3) as 1 | 2 | 3;
const text = h[2];
blocks.push({
kind: (`h${level}` as "h1" | "h2" | "h3"),
text: h[2],
text,
id: headingId(text, blocks.length),
});
i++;
continue;
@@ -64,7 +111,8 @@ function parse(md: string): Block[] {
while (
i < lines.length &&
lines[i].trim() &&
!/^(#{1,3})\s+/.test(lines[i].trim()) &&
!/^(#{1,6})\s+/.test(lines[i].trim()) &&
!/^```/.test(lines[i].trim()) &&
!/^[-*]\s+/.test(lines[i].trim()) &&
!/^\d+\.\s+/.test(lines[i].trim())
) {
@@ -133,6 +181,7 @@ export function MarkdownBlock({ source }: { source: string }) {
return (
<h1
key={idx}
id={b.id}
style={{
margin: "8px 0 2px",
fontSize: 17,
@@ -148,6 +197,7 @@ export function MarkdownBlock({ source }: { source: string }) {
return (
<h2
key={idx}
id={b.id}
style={{
margin: "10px 0 -2px",
fontSize: 12,
@@ -165,6 +215,7 @@ export function MarkdownBlock({ source }: { source: string }) {
return (
<h3
key={idx}
id={b.id}
style={{
margin: "6px 0 -4px",
fontSize: 11.5,
@@ -178,6 +229,43 @@ export function MarkdownBlock({ source }: { source: string }) {
{renderInline(b.text)}
</h3>
);
if (b.kind === "code")
return (
<pre
key={idx}
style={{
margin: 0,
padding: "10px 12px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.07)",
background: "rgba(0,0,0,.45)",
color: "#e0e0e5",
fontFamily: mono,
fontSize: 11.5,
lineHeight: 1.5,
// Code is the one thing that may scroll sideways; the
// page itself must never scroll horizontally.
overflowX: "auto",
whiteSpace: "pre",
}}
>
{b.lang && (
<span
style={{
display: "block",
marginBottom: 6,
fontSize: 9.5,
letterSpacing: ".12em",
textTransform: "uppercase",
color: "#6a6a72",
}}
>
{b.lang}
</span>
)}
<code>{b.text}</code>
</pre>
);
if (b.kind === "p")
return (
<p key={idx} style={{ margin: 0 }}>