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:
co-authored by
Claude Opus 5
parent
d676a9e089
commit
0785ac9c79
@@ -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 = "before_after" }" />
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user