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]>
314 lines
8.7 KiB
TypeScript
314 lines
8.7 KiB
TypeScript
"use client";
|
|
|
|
// MarkdownBlock — tiny zero-dep Markdown renderer. Handles the subset
|
|
// the refiner emits: h1/h2/h3 headings, - / * bullets, 1. numbered
|
|
// lists, `**bold**`, `` `code` ``, blank-line-separated paragraphs.
|
|
// Not a general-purpose renderer — deliberately small to avoid a
|
|
// react-markdown dep for one canvas surface.
|
|
|
|
import React from "react";
|
|
|
|
const mono =
|
|
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
|
|
|
type Block =
|
|
| { kind: "h1" | "h2" | "h3"; text: string; id?: string }
|
|
| { kind: "p"; text: string }
|
|
| { kind: "ul"; 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");
|
|
const blocks: Block[] = [];
|
|
let i = 0;
|
|
while (i < lines.length) {
|
|
const line = lines[i];
|
|
const trimmed = line.trim();
|
|
if (!trimmed) {
|
|
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,6})\s+(.*)$/.exec(trimmed);
|
|
if (h) {
|
|
// 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,
|
|
id: headingId(text, blocks.length),
|
|
});
|
|
i++;
|
|
continue;
|
|
}
|
|
// Bullet list
|
|
if (/^[-*]\s+/.test(trimmed)) {
|
|
const items: string[] = [];
|
|
while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
|
|
items.push(lines[i].trim().replace(/^[-*]\s+/, ""));
|
|
i++;
|
|
}
|
|
blocks.push({ kind: "ul", items });
|
|
continue;
|
|
}
|
|
// Numbered list
|
|
if (/^\d+\.\s+/.test(trimmed)) {
|
|
const items: string[] = [];
|
|
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
|
|
items.push(lines[i].trim().replace(/^\d+\.\s+/, ""));
|
|
i++;
|
|
}
|
|
blocks.push({ kind: "ol", items });
|
|
continue;
|
|
}
|
|
// Paragraph — greedily accumulate until blank line or block boundary
|
|
const paraLines: string[] = [];
|
|
while (
|
|
i < lines.length &&
|
|
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())
|
|
) {
|
|
paraLines.push(lines[i].trim());
|
|
i++;
|
|
}
|
|
if (paraLines.length) blocks.push({ kind: "p", text: paraLines.join(" ") });
|
|
}
|
|
return blocks;
|
|
}
|
|
|
|
// Inline: **bold**, `code`. Simple sequential scan.
|
|
function renderInline(text: string): React.ReactNode[] {
|
|
const out: React.ReactNode[] = [];
|
|
const re = /(\*\*[^*]+\*\*|`[^`]+`)/g;
|
|
let last = 0;
|
|
let m: RegExpExecArray | null;
|
|
let key = 0;
|
|
while ((m = re.exec(text)) !== null) {
|
|
if (m.index > last) out.push(text.slice(last, m.index));
|
|
const tok = m[0];
|
|
if (tok.startsWith("**")) {
|
|
out.push(
|
|
<strong key={key++} style={{ color: "#f3f3f5" }}>
|
|
{tok.slice(2, -2)}
|
|
</strong>,
|
|
);
|
|
} else {
|
|
out.push(
|
|
<code
|
|
key={key++}
|
|
style={{
|
|
fontFamily: mono,
|
|
fontSize: 11.5,
|
|
padding: "1px 5px",
|
|
borderRadius: 4,
|
|
background: "rgba(255,255,255,.06)",
|
|
color: "#ffb44a",
|
|
}}
|
|
>
|
|
{tok.slice(1, -1)}
|
|
</code>,
|
|
);
|
|
}
|
|
last = m.index + tok.length;
|
|
}
|
|
if (last < text.length) out.push(text.slice(last));
|
|
return out;
|
|
}
|
|
|
|
export function MarkdownBlock({ source }: { source: string }) {
|
|
const blocks = React.useMemo(() => parse(source), [source]);
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 10,
|
|
color: "#cfcfd5",
|
|
fontSize: 13,
|
|
lineHeight: 1.55,
|
|
}}
|
|
>
|
|
{blocks.map((b, idx) => {
|
|
if (b.kind === "h1")
|
|
return (
|
|
<h1
|
|
key={idx}
|
|
id={b.id}
|
|
style={{
|
|
margin: "8px 0 2px",
|
|
fontSize: 17,
|
|
color: "#f3f3f5",
|
|
fontWeight: 600,
|
|
letterSpacing: ".01em",
|
|
}}
|
|
>
|
|
{renderInline(b.text)}
|
|
</h1>
|
|
);
|
|
if (b.kind === "h2")
|
|
return (
|
|
<h2
|
|
key={idx}
|
|
id={b.id}
|
|
style={{
|
|
margin: "10px 0 -2px",
|
|
fontSize: 12,
|
|
color: "#7cd6e0",
|
|
fontFamily: mono,
|
|
letterSpacing: ".14em",
|
|
textTransform: "uppercase",
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
{renderInline(b.text)}
|
|
</h2>
|
|
);
|
|
if (b.kind === "h3")
|
|
return (
|
|
<h3
|
|
key={idx}
|
|
id={b.id}
|
|
style={{
|
|
margin: "6px 0 -4px",
|
|
fontSize: 11.5,
|
|
color: "#a0a0a8",
|
|
fontFamily: mono,
|
|
letterSpacing: ".10em",
|
|
textTransform: "uppercase",
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
{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 }}>
|
|
{renderInline(b.text)}
|
|
</p>
|
|
);
|
|
if (b.kind === "ul")
|
|
return (
|
|
<ul
|
|
key={idx}
|
|
style={{
|
|
margin: 0,
|
|
paddingLeft: 18,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 4,
|
|
}}
|
|
>
|
|
{b.items.map((it, i) => (
|
|
<li key={i}>{renderInline(it)}</li>
|
|
))}
|
|
</ul>
|
|
);
|
|
if (b.kind === "ol")
|
|
return (
|
|
<ol
|
|
key={idx}
|
|
style={{
|
|
margin: 0,
|
|
paddingLeft: 20,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 4,
|
|
}}
|
|
>
|
|
{b.items.map((it, i) => (
|
|
<li key={i}>{renderInline(it)}</li>
|
|
))}
|
|
</ol>
|
|
);
|
|
return null;
|
|
})}
|
|
</div>
|
|
);
|
|
}
|