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
@@ -101,18 +101,51 @@ pub struct SecurityScanResponse {
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────
|
||||
|
||||
/// A mission plus the phase progress the list card needs. `mission` is
|
||||
/// flattened, so the JSON is a strict SUPERSET of `Mission` — existing
|
||||
/// consumers keep working and simply gain fields.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MissionListItem {
|
||||
#[serde(flatten)]
|
||||
pub mission: Mission,
|
||||
pub phases_total: i64,
|
||||
pub phases_done: i64,
|
||||
/// Kind of the phase currently running, if any.
|
||||
pub current_phase: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
State(state): State<AppState>,
|
||||
Authed(user): Authed,
|
||||
Query(q): Query<ListQuery>,
|
||||
) -> Result<Json<Vec<Mission>>, ApiError> {
|
||||
) -> Result<Json<Vec<MissionListItem>>, ApiError> {
|
||||
let rows = cm_db::repo::missions::list_by_workspace(
|
||||
&state.pool,
|
||||
user.workspace_id.as_uuid(),
|
||||
q.limit.clamp(1, 500),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(rows))
|
||||
// One extra grouped query for the whole page, not one per mission.
|
||||
let ids: Vec<Uuid> = rows.iter().map(|m| m.id).collect();
|
||||
let progress = cm_db::repo::missions::phase_progress(&state.pool, &ids).await?;
|
||||
let by_id: std::collections::HashMap<Uuid, (i64, i64, Option<String>)> = progress
|
||||
.into_iter()
|
||||
.map(|(id, total, done, running)| (id, (total, done, running)))
|
||||
.collect();
|
||||
Ok(Json(
|
||||
rows.into_iter()
|
||||
.map(|m| {
|
||||
let (phases_total, phases_done, current_phase) =
|
||||
by_id.get(&m.id).cloned().unwrap_or((0, 0, None));
|
||||
MissionListItem {
|
||||
mission: m,
|
||||
phases_total,
|
||||
phases_done,
|
||||
current_phase,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
@@ -697,3 +730,240 @@ pub async fn set_status(
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
Ok(Json(mission))
|
||||
}
|
||||
|
||||
// ── Output reader ────────────────────────────────────────────────
|
||||
//
|
||||
// The mission Output tab is a document reader, not a log tail. The
|
||||
// phase-card preview endpoint (`routes::topology::get_run_output`) caps
|
||||
// every turn at 6,000 chars, which shows only ~11% of a typical research
|
||||
// brief (they run 40–55kB) with no way to read the rest. These two routes
|
||||
// are the reader's data source: one lists every document in the mission
|
||||
// for the outline rail, the other returns one document in full.
|
||||
|
||||
/// One agent turn's output, as a readable document.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MissionDocument {
|
||||
pub run_id: Uuid,
|
||||
pub phase_id: Option<Uuid>,
|
||||
/// Index into the run's `checkpoint.outputs` array.
|
||||
pub index: usize,
|
||||
/// Topology node id (`n0`) — stable within the run's graph.
|
||||
pub node_id: String,
|
||||
/// The node's role (`code_archeologist`), i.e. what this agent was.
|
||||
pub role: String,
|
||||
/// Human title: the document's first markdown heading when it has
|
||||
/// one, else its first non-empty line.
|
||||
pub title: String,
|
||||
pub chars: usize,
|
||||
pub run_status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MissionDocumentsResponse {
|
||||
pub documents: Vec<MissionDocument>,
|
||||
}
|
||||
|
||||
/// Derive a display title from a document's own text: prefer the first
|
||||
/// markdown ATX heading, else the first non-empty line. Both are trimmed
|
||||
/// to keep the rail readable.
|
||||
fn document_title(body: &str, fallback: &str) -> String {
|
||||
const MAX: usize = 90;
|
||||
let heading = body
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| l.starts_with('#'))
|
||||
.map(|l| l.trim_start_matches('#').trim());
|
||||
let line = heading.or_else(|| body.lines().map(str::trim).find(|l| !l.is_empty()));
|
||||
match line {
|
||||
Some(l) if !l.is_empty() => {
|
||||
if l.chars().count() > MAX {
|
||||
format!("{}…", l.chars().take(MAX).collect::<String>())
|
||||
} else {
|
||||
l.to_string()
|
||||
}
|
||||
}
|
||||
_ => fallback.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a run's graph node index → (node_id, role). The reader labels each
|
||||
/// document by the agent that produced it; `checkpoint.outputs[i]`
|
||||
/// corresponds to `graph.nodes[i]` (the worker appends one output per
|
||||
/// step, in node order).
|
||||
fn nodes_of(graph: Option<&Value>) -> Vec<(String, String)> {
|
||||
graph
|
||||
.and_then(|g| g.get("nodes"))
|
||||
.and_then(|n| n.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.map(|n| {
|
||||
(
|
||||
n.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
n.get("role")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("agent")
|
||||
.to_string(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn outputs_of(checkpoint: Option<&Value>) -> Vec<String> {
|
||||
checkpoint
|
||||
.and_then(|c| c.get("outputs"))
|
||||
.and_then(|o| o.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.map(|v| match v {
|
||||
Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `GET /api/missions/{id}/documents` — every agent output in the mission,
|
||||
/// oldest run first, as a flat list the reader groups by phase. Bodies are
|
||||
/// NOT included; the rail only needs titles and sizes.
|
||||
pub async fn list_documents(
|
||||
State(state): State<AppState>,
|
||||
Authed(user): Authed,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<MissionDocumentsResponse>, ApiError> {
|
||||
let _ = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let source =
|
||||
cm_db::repo::topology_runs::documents_source_for_mission(&state.pool, id).await?;
|
||||
|
||||
let mut documents = Vec::new();
|
||||
for (run_id, phase_id, run_status, graph, checkpoint) in source {
|
||||
let nodes = nodes_of(graph.as_ref());
|
||||
for (index, body) in outputs_of(checkpoint.as_ref()).into_iter().enumerate() {
|
||||
let (node_id, role) = nodes
|
||||
.get(index)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| (format!("n{index}"), "agent".to_string()));
|
||||
let fallback = format!("Turn {}", index + 1);
|
||||
documents.push(MissionDocument {
|
||||
run_id,
|
||||
phase_id,
|
||||
index,
|
||||
node_id,
|
||||
title: document_title(&body, &fallback),
|
||||
role,
|
||||
chars: body.chars().count(),
|
||||
run_status: run_status.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Json(MissionDocumentsResponse { documents }))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MissionDocumentBody {
|
||||
pub run_id: Uuid,
|
||||
pub index: usize,
|
||||
pub role: String,
|
||||
pub title: String,
|
||||
/// The complete output text — untruncated, which is the whole point.
|
||||
pub body: String,
|
||||
pub chars: usize,
|
||||
}
|
||||
|
||||
/// `GET /api/missions/{id}/documents/{run_id}/{index}` — one document in
|
||||
/// full. Separate from the list so opening the Output tab doesn't pull
|
||||
/// every brief in the mission over the wire at once.
|
||||
pub async fn get_document(
|
||||
State(state): State<AppState>,
|
||||
Authed(user): Authed,
|
||||
Path((id, run_id, index)): Path<(Uuid, Uuid, usize)>,
|
||||
) -> Result<Json<MissionDocumentBody>, ApiError> {
|
||||
let _ = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
// Scope the run to the mission as well, so a valid run id from another
|
||||
// mission (or workspace) can't be read through this path.
|
||||
let source =
|
||||
cm_db::repo::topology_runs::documents_source_for_mission(&state.pool, id).await?;
|
||||
let (_, _, _, graph, checkpoint) = source
|
||||
.into_iter()
|
||||
.find(|(rid, _, _, _, _)| *rid == run_id)
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
|
||||
let body = outputs_of(checkpoint.as_ref())
|
||||
.into_iter()
|
||||
.nth(index)
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let role = nodes_of(graph.as_ref())
|
||||
.get(index)
|
||||
.map(|(_, r)| r.clone())
|
||||
.unwrap_or_else(|| "agent".to_string());
|
||||
let fallback = format!("Turn {}", index + 1);
|
||||
Ok(Json(MissionDocumentBody {
|
||||
run_id,
|
||||
index,
|
||||
title: document_title(&body, &fallback),
|
||||
role,
|
||||
chars: body.chars().count(),
|
||||
body,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn title_prefers_first_markdown_heading() {
|
||||
let body = "I'll start by exploring.\n\n# ClawHDF5 Research Report\n\ntext";
|
||||
assert_eq!(document_title(body, "Turn 1"), "ClawHDF5 Research Report");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_falls_back_to_first_nonempty_line() {
|
||||
let body = "\n\n Architecture notes for the io crate\nmore\n";
|
||||
assert_eq!(
|
||||
document_title(body, "Turn 1"),
|
||||
"Architecture notes for the io crate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_falls_back_to_label_when_empty() {
|
||||
assert_eq!(document_title(" \n\n", "Turn 3"), "Turn 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_is_truncated() {
|
||||
let body = format!("# {}", "x".repeat(200));
|
||||
let t = document_title(&body, "Turn 1");
|
||||
assert!(t.ends_with('…'));
|
||||
assert_eq!(t.chars().count(), 91);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nodes_and_outputs_are_positionally_aligned() {
|
||||
let graph = serde_json::json!({
|
||||
"nodes": [
|
||||
{"id": "n0", "role": "code_archeologist"},
|
||||
{"id": "n1", "role": "architecture_mapper"}
|
||||
]
|
||||
});
|
||||
let cp = serde_json::json!({ "outputs": ["first brief", "second brief"] });
|
||||
let nodes = nodes_of(Some(&graph));
|
||||
let outs = outputs_of(Some(&cp));
|
||||
assert_eq!(nodes[1], ("n1".into(), "architecture_mapper".into()));
|
||||
assert_eq!(outs[1], "second brief");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_graph_or_checkpoint_yields_no_documents() {
|
||||
assert!(nodes_of(None).is_empty());
|
||||
assert!(outputs_of(None).is_empty());
|
||||
assert!(outputs_of(Some(&serde_json::json!({}))).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user