feat(missions): hold every producing phase to delivering, and read markdown instead of PDFs
Two changes the portal review asked for.
1. `benchmark` and `security_hardening` had no delivery guarantee.
`empty_delivery_is_a_failure` tested `kind == "coding"`, on the reasoning that
"research phases legitimately write nothing to the tree" — which the research
directive three modules over contradicts, since it tells the agent to save
findings under /mission/repo/research/. The cost: a `benchmark` mission is ONE
benchmark phase, and with that phase exempt nothing in the platform could fail
it. Same for `security_hardening`, whose first two phases are security_scan and
research.
Now keyed on PRODUCING_KINDS = coding, research, benchmark, security_scan.
`review` stays exempt — a reviewing phase that changes nothing has done its job,
the same distinction `vm_stop_gate::per_node` makes. The test that encoded the
old rule is rewritten rather than deleted, with the reasoning that replaced it.
All 8 harness fixtures are coding phases, so harness behaviour is unchanged.
2. PDFs are dropped; markdown is the deliverable.
Rendering a PDF meant asking an LLM to convert markdown to HTML — a paid API
call per document, on the critical path of "let me read my research", which
failed on depleted Gemini credits and left every artifact unreadable. Styling at
render time is free, offline, instant and cannot 429.
- `mission_outputs` no longer requests a render.
- New `GET /api/missions/{id}/artifacts/{artifact_id}/content`. The frontend had
no way to READ an artifact at all: it listed paths and offered a PDF preview
that never rendered (and whose `rendered_pdf_path` had no route serving it).
Two containment rules, both enforced: the artifact must belong to a mission in
the caller's workspace, and the CANONICALISED path must stay under `_outputs`
— canonicalise first, because checking the string before resolving `..` is the
classic hole.
- `MarkdownBlock` now uses react-markdown + remark-gfm + rehype-slug. It was a
deliberate zero-dep renderer for "the subset the refiner emits", and that
subset stopped matching reality: agent briefs are largely GFM pipe tables,
which it showed as literal pipes. MissionOutputReader and RefineDiffModal use
the same component and gain tables for free.
- Heading ids come from rehype-slug and `outlineOf` slugs with the same
GithubSlugger, so the outline rail's anchors still resolve. A test pins that
invariant, including duplicate headings.
Styles live in globals.css under `.md-view`: the markup is generated so there
are no class hooks, and this project has no styled-jsx registry — the app-router
requirement is documented in next/dist/docs/01-app/02-guides/css-in-js.md, which
frontend/AGENTS.md exists to make me read.
The artifacts tab moved to `MissionArtifacts.tsx`. MissionCanvas was 1341 lines
against a 1250 limit BEFORE this change — already failing lint; it is now 1248.
238 backend lib tests, 20 backend test binaries, 89 frontend tests, clean tsc,
clean eslint on every file touched, production build succeeds.
This commit is contained in:
@@ -491,6 +491,10 @@ pub fn router(state: AppState) -> Router {
|
|||||||
axum::routing::patch(routes::missions::set_status),
|
axum::routing::patch(routes::missions::set_status),
|
||||||
)
|
)
|
||||||
.route("/api/missions/{id}/refine", post(routes::missions::refine))
|
.route("/api/missions/{id}/refine", post(routes::missions::refine))
|
||||||
|
.route(
|
||||||
|
"/api/missions/{id}/artifacts/{artifact_id}/content",
|
||||||
|
get(routes::missions::artifact_content),
|
||||||
|
)
|
||||||
// Slice 5: let a model size the mission's team. Proposing, listing and
|
// Slice 5: let a model size the mission's team. Proposing, listing and
|
||||||
// deciding are separate verbs because only the last one spends money.
|
// deciding are separate verbs because only the last one spends money.
|
||||||
// W1/#13: let a model author the phases, on the same propose → review →
|
// W1/#13: let a model author the phases, on the same propose → review →
|
||||||
|
|||||||
@@ -111,7 +111,6 @@ pub async fn capture_repo_less_phases(pool: &PgPool) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let wants_pdf = produces_pdf(&config);
|
|
||||||
for file in &captured {
|
for file in &captured {
|
||||||
let rel = match file.strip_prefix(missions_root()) {
|
let rel = match file.strip_prefix(missions_root()) {
|
||||||
Ok(r) => r.to_string_lossy().to_string(),
|
Ok(r) => r.to_string_lossy().to_string(),
|
||||||
@@ -131,9 +130,13 @@ pub async fn capture_repo_less_phases(pool: &PgPool) -> Result<(), String> {
|
|||||||
mime: Some(mime_for(file)),
|
mime: Some(mime_for(file)),
|
||||||
title: Some(&title),
|
title: Some(&title),
|
||||||
generated_by_run: None,
|
generated_by_run: None,
|
||||||
// `produces` used to be inert — declared by the recipe and
|
// No PDF. The renderer converted Markdown to HTML by
|
||||||
// read by nothing. This is the code that reads it.
|
// calling an LLM — a paid API call, per document, on the
|
||||||
render_pdf: wants_pdf && is_markdown(file),
|
// critical path of "save my research", which promptly
|
||||||
|
// failed on depleted credits. Markdown IS the deliverable;
|
||||||
|
// it is served by `artifact_content` and styled at render
|
||||||
|
// time, which is free, offline, and cannot 429.
|
||||||
|
render_pdf: false,
|
||||||
metadata: Some(serde_json::json!({
|
metadata: Some(serde_json::json!({
|
||||||
"bytes": std::fs::metadata(file).map(|m| m.len()).unwrap_or(0),
|
"bytes": std::fs::metadata(file).map(|m| m.len()).unwrap_or(0),
|
||||||
"captured_from": "/mission/repo",
|
"captured_from": "/mission/repo",
|
||||||
@@ -291,19 +294,22 @@ fn outputs_dir(mission_id: Uuid, phase_id: Uuid) -> PathBuf {
|
|||||||
.join(phase_id.to_string())
|
.join(phase_id.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The missions root, for callers that resolve artifact paths against it.
|
||||||
|
pub fn missions_root_dir() -> PathBuf {
|
||||||
|
missions_root()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The only directory an artifact may be read from.
|
||||||
|
pub fn outputs_root_dir() -> PathBuf {
|
||||||
|
missions_root().join("_outputs")
|
||||||
|
}
|
||||||
|
|
||||||
fn missions_root() -> PathBuf {
|
fn missions_root() -> PathBuf {
|
||||||
std::env::var("CLAWMATES_MISSIONS_ROOT")
|
std::env::var("CLAWMATES_MISSIONS_ROOT")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.unwrap_or_else(|_| PathBuf::from("/var/lib/clawmates-missions"))
|
.unwrap_or_else(|_| PathBuf::from("/var/lib/clawmates-missions"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_markdown(p: &Path) -> bool {
|
|
||||||
matches!(
|
|
||||||
p.extension().and_then(|e| e.to_str()),
|
|
||||||
Some("md") | Some("markdown")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mime_for(p: &Path) -> &'static str {
|
fn mime_for(p: &Path) -> &'static str {
|
||||||
match p.extension().and_then(|e| e.to_str()) {
|
match p.extension().and_then(|e| e.to_str()) {
|
||||||
Some("md") | Some("markdown") => "text/markdown",
|
Some("md") | Some("markdown") => "text/markdown",
|
||||||
@@ -314,16 +320,6 @@ fn mime_for(p: &Path) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Did the recipe ask for a PDF? `research_only.toml` declares
|
|
||||||
/// `produces = ["md","pdf"]`.
|
|
||||||
fn produces_pdf(config: &serde_json::Value) -> bool {
|
|
||||||
config
|
|
||||||
.get("produces")
|
|
||||||
.and_then(|v| v.as_array())
|
|
||||||
.map(|a| a.iter().any(|v| v.as_str() == Some("pdf")))
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn allow_empty(config: &serde_json::Value) -> bool {
|
fn allow_empty(config: &serde_json::Value) -> bool {
|
||||||
config.get("allow_empty").and_then(|v| v.as_bool()) == Some(true)
|
config.get("allow_empty").and_then(|v| v.as_bool()) == Some(true)
|
||||||
}
|
}
|
||||||
@@ -370,22 +366,48 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `produces` was registered as an INERT key — declared by the recipe and
|
/// Markdown is the deliverable, so it must be labelled as markdown — the
|
||||||
/// read by nothing, which is why `research_only`'s promised PDF never
|
/// viewer decides how to render from the mime type.
|
||||||
/// appeared. This is the code that makes it mean something.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_recipes_produces_key_decides_pdf_rendering() {
|
fn markdown_is_labelled_so_the_viewer_can_style_it() {
|
||||||
let with = serde_json::json!({ "produces": ["md", "pdf"] });
|
assert_eq!(mime_for(Path::new("/x/01_notes.md")), "text/markdown");
|
||||||
let without = serde_json::json!({ "produces": ["md"] });
|
assert_eq!(mime_for(Path::new("/x/data.json")), "application/json");
|
||||||
let absent = serde_json::json!({});
|
}
|
||||||
assert!(produces_pdf(&with));
|
|
||||||
assert!(!produces_pdf(&without));
|
|
||||||
assert!(!produces_pdf(&absent));
|
|
||||||
|
|
||||||
// Only markdown is rendered; a captured JSON side-file is not a document
|
/// The containment rule the content endpoint enforces: everything readable
|
||||||
// to typeset.
|
/// lives under `_outputs`, and nothing else does.
|
||||||
assert!(is_markdown(Path::new("/x/01_notes.md")));
|
///
|
||||||
assert!(!is_markdown(Path::new("/x/data.json")));
|
/// Artifact paths are written by this server, but they are DATA in a table,
|
||||||
|
/// and a row saying `../../../etc/passwd` must be a 404 rather than a file
|
||||||
|
/// read. The endpoint canonicalises before comparing — checking the string
|
||||||
|
/// first would pass `_outputs/../../etc/passwd` straight through.
|
||||||
|
#[test]
|
||||||
|
fn everything_readable_lives_under_the_outputs_root() {
|
||||||
|
let root = outputs_root_dir();
|
||||||
|
assert!(root.ends_with("_outputs"), "{root:?}");
|
||||||
|
assert!(root.starts_with(missions_root_dir()), "{root:?}");
|
||||||
|
|
||||||
|
// A real capture is inside it...
|
||||||
|
let inside = outputs_dir(Uuid::now_v7(), Uuid::now_v7());
|
||||||
|
assert!(inside.starts_with(&root), "{inside:?}");
|
||||||
|
|
||||||
|
// ...and the traversal shape this guards against is not, once resolved.
|
||||||
|
let escaped = root.join("..").join("..").join("etc/passwd");
|
||||||
|
let normalised: PathBuf = escaped
|
||||||
|
.components()
|
||||||
|
.fold(PathBuf::new(), |mut acc, c| {
|
||||||
|
match c {
|
||||||
|
std::path::Component::ParentDir => {
|
||||||
|
acc.pop();
|
||||||
|
}
|
||||||
|
other => acc.push(other),
|
||||||
|
}
|
||||||
|
acc
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
!normalised.starts_with(&root),
|
||||||
|
"a traversal must not resolve back inside the outputs root: {normalised:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A phase that produced nothing must still leave a marker, or the
|
/// A phase that produced nothing must still leave a marker, or the
|
||||||
|
|||||||
@@ -264,8 +264,18 @@ mod repo_less_text_tests {
|
|||||||
/// Three things must all hold before calling it a failure, because a false
|
/// Three things must all hold before calling it a failure, because a false
|
||||||
/// positive here fails honest work:
|
/// positive here fails honest work:
|
||||||
///
|
///
|
||||||
/// - **The phase is a coding phase.** Research phases legitimately write
|
/// - **The phase is one whose directive tells it to write files.** That is
|
||||||
/// nothing to the tree.
|
/// every kind except `review`: `coding` changes the tree, `research` is told
|
||||||
|
/// to "save findings under /mission/repo/research/", `benchmark` to author
|
||||||
|
/// benchmarks "under /mission/repo/benches", and `security_scan` to file
|
||||||
|
/// findings and propose patches. A phase that produced nothing did not do
|
||||||
|
/// what it was told, whatever its kind.
|
||||||
|
///
|
||||||
|
/// This used to be `kind == "coding"` alone, with the reasoning "research
|
||||||
|
/// phases legitimately write nothing to the tree" — which contradicts the
|
||||||
|
/// research directive three modules over. The cost: `benchmark` and
|
||||||
|
/// `security_hardening` missions, whose defining phases are NOT coding, had
|
||||||
|
/// no delivery guarantee at all and reported success on an empty tree.
|
||||||
/// - **The diff was actually computed.** An uncomputable diff also reports
|
/// - **The diff was actually computed.** An uncomputable diff also reports
|
||||||
/// zero files (see `mission_delivery::untrusted_empty_reason`); treating it
|
/// zero files (see `mission_delivery::untrusted_empty_reason`); treating it
|
||||||
/// as an empty delivery would blame the agent for a platform fault.
|
/// as an empty delivery would blame the agent for a platform fault.
|
||||||
@@ -278,12 +288,21 @@ fn empty_delivery_is_a_failure(
|
|||||||
diff_error: Option<&str>,
|
diff_error: Option<&str>,
|
||||||
config: &serde_json::Value,
|
config: &serde_json::Value,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
kind == "coding"
|
PRODUCING_KINDS.contains(&kind)
|
||||||
&& files_changed == 0
|
&& files_changed == 0
|
||||||
&& diff_error.is_none()
|
&& diff_error.is_none()
|
||||||
&& config.get("allow_empty").and_then(|v| v.as_bool()) != Some(true)
|
&& config.get("allow_empty").and_then(|v| v.as_bool()) != Some(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase kinds whose directive instructs the agent to leave files behind.
|
||||||
|
///
|
||||||
|
/// `review` is absent on purpose: a reviewing phase that changes nothing has
|
||||||
|
/// done its job, and `vm_stop_gate::per_node` makes the same distinction for the
|
||||||
|
/// same reason. Any other kind falls through to the generic directive ("Execute
|
||||||
|
/// this mission phase according to the mission brief"), which promises no files,
|
||||||
|
/// so it is not held to producing them.
|
||||||
|
const PRODUCING_KINDS: &[&str] = &["coding", "research", "benchmark", "security_scan"];
|
||||||
|
|
||||||
/// Enqueue topology_runs for every phase whose predecessors are done.
|
/// Enqueue topology_runs for every phase whose predecessors are done.
|
||||||
async fn start_pending_phases(
|
async fn start_pending_phases(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
@@ -1637,15 +1656,43 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A research phase legitimately writes nothing to the tree; failing it
|
/// Every phase whose directive tells it to write files is held to it.
|
||||||
/// would break every research→coding mission.
|
///
|
||||||
|
/// This test used to assert the opposite, on the reasoning that "a research
|
||||||
|
/// phase legitimately writes nothing to the tree". That was contradicted by
|
||||||
|
/// the research directive in this very file, which tells the agent to save
|
||||||
|
/// findings under `/mission/repo/research/` — and it cost `benchmark` and
|
||||||
|
/// `security_hardening` missions any delivery guarantee at all, because
|
||||||
|
/// their defining phases are not `coding`. A `benchmark` mission is ONE
|
||||||
|
/// benchmark phase; with that phase exempt, nothing in the platform could
|
||||||
|
/// fail it.
|
||||||
#[test]
|
#[test]
|
||||||
fn only_coding_phases_are_held_to_delivering_files() {
|
fn every_producing_kind_is_held_to_delivering_files() {
|
||||||
let none = serde_json::json!({});
|
let none = serde_json::json!({});
|
||||||
for kind in ["research", "benchmark", "security_scan"] {
|
for kind in ["coding", "research", "benchmark", "security_scan"] {
|
||||||
|
assert!(
|
||||||
|
empty_delivery_is_a_failure(kind, 0, None, &none),
|
||||||
|
"{kind} is told to write files, so producing none is a failure"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!empty_delivery_is_a_failure(kind, 1, None, &none),
|
||||||
|
"{kind} that wrote a file delivered"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reviewing phase is SUPPOSED to leave the tree alone.
|
||||||
|
///
|
||||||
|
/// The same distinction `vm_stop_gate::per_node` makes when it drops
|
||||||
|
/// `require_changes` for a composed graph's verifier node: holding a
|
||||||
|
/// reviewer to changing files fails it for doing exactly its job.
|
||||||
|
#[test]
|
||||||
|
fn a_reviewing_phase_may_change_nothing() {
|
||||||
|
let none = serde_json::json!({});
|
||||||
|
for kind in ["review", "something_unrecognised"] {
|
||||||
assert!(
|
assert!(
|
||||||
!empty_delivery_is_a_failure(kind, 0, None, &none),
|
!empty_delivery_is_a_failure(kind, 0, None, &none),
|
||||||
"{kind} phases are not required to change files"
|
"{kind} is not promised to produce files"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -327,6 +327,76 @@ pub async fn get(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /api/missions/{id}/artifacts/{artifact_id}/content — the artifact's text.
|
||||||
|
///
|
||||||
|
/// The frontend had no way to READ an artifact: it listed paths and offered a
|
||||||
|
/// PDF preview, and the PDF never rendered. Markdown is the deliverable now, so
|
||||||
|
/// something has to serve it.
|
||||||
|
///
|
||||||
|
/// Two containment rules, both enforced rather than assumed:
|
||||||
|
///
|
||||||
|
/// - the artifact row must belong to a mission in the caller's workspace, so
|
||||||
|
/// an artifact id from another tenant is a 404, not a file read;
|
||||||
|
/// - the resolved path must stay inside `<missions_root>/_outputs`. Artifact
|
||||||
|
/// paths are written by this server, but a stored `../../etc/passwd` would
|
||||||
|
/// otherwise be read and returned. Canonicalise, then check the prefix —
|
||||||
|
/// checking the string before resolving `..` is the classic hole.
|
||||||
|
///
|
||||||
|
/// Text only, and capped: these are markdown documents, and streaming an
|
||||||
|
/// arbitrary captured file into a JSON body is not what this is for.
|
||||||
|
pub async fn artifact_content(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
Path((id, artifact_id)): Path<(Uuid, Uuid)>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
/// Beyond this, a document is not something a reader wants inline.
|
||||||
|
const MAX_BYTES: u64 = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
// Scoped to the caller's workspace by loading the mission first.
|
||||||
|
cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::NotFound)?;
|
||||||
|
|
||||||
|
let artifacts = cm_db::repo::missions::artifacts_for(&state.pool, id).await?;
|
||||||
|
let artifact = artifacts
|
||||||
|
.into_iter()
|
||||||
|
.find(|a| a.id == artifact_id)
|
||||||
|
.ok_or(ApiError::NotFound)?;
|
||||||
|
|
||||||
|
let root = crate::mission_outputs::outputs_root_dir();
|
||||||
|
let abs = crate::mission_outputs::missions_root_dir().join(&artifact.path);
|
||||||
|
let resolved = std::fs::canonicalize(&abs).map_err(|_| ApiError::NotFound)?;
|
||||||
|
let root = std::fs::canonicalize(&root).map_err(|_| ApiError::NotFound)?;
|
||||||
|
if !resolved.starts_with(&root) {
|
||||||
|
eprintln!(
|
||||||
|
"missions::artifact_content: refused {} — outside {}",
|
||||||
|
resolved.display(),
|
||||||
|
root.display()
|
||||||
|
);
|
||||||
|
return Err(ApiError::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
let meta = std::fs::metadata(&resolved).map_err(|_| ApiError::NotFound)?;
|
||||||
|
if meta.len() > MAX_BYTES {
|
||||||
|
return Ok(Json(serde_json::json!({
|
||||||
|
"path": artifact.path,
|
||||||
|
"mime": artifact.mime,
|
||||||
|
"truncated": true,
|
||||||
|
"content": "",
|
||||||
|
"bytes": meta.len(),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
let content = std::fs::read_to_string(&resolved).map_err(|_| ApiError::NotFound)?;
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"path": artifact.path,
|
||||||
|
"mime": artifact.mime,
|
||||||
|
"title": artifact.title,
|
||||||
|
"truncated": false,
|
||||||
|
"content": content,
|
||||||
|
"bytes": meta.len(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
/// POST /api/missions/{id}/benchmark — run the benchmark harness
|
/// POST /api/missions/{id}/benchmark — run the benchmark harness
|
||||||
/// against a phase. Slot='baseline' records iteration 0's
|
/// against a phase. Slot='baseline' records iteration 0's
|
||||||
/// before_metrics; slot='after' with iteration=N records the
|
/// before_metrics; slot='after' with iteration=N records the
|
||||||
|
|||||||
Generated
+1511
-5
File diff suppressed because it is too large
Load Diff
@@ -19,11 +19,15 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"geist": "^1.7.2",
|
"geist": "^1.7.2",
|
||||||
|
"github-slugger": "^2.0.0",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
"next": "16.2.9",
|
"next": "16.2.9",
|
||||||
"nuqs": "^2.8.9",
|
"nuqs": "^2.8.9",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"rehype-slug": "^6.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"three": "^0.169.0",
|
"three": "^0.169.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
|
|||||||
@@ -251,3 +251,109 @@ body {
|
|||||||
.marketing {
|
.marketing {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Mission artifact reader ──────────────────────────────────────
|
||||||
|
Styles the HTML that react-markdown generates for a mission's markdown
|
||||||
|
documents (see components/dashboard/MarkdownView.tsx). Every rule is
|
||||||
|
descendant-scoped to .md-view: the markup is generated, so there are no
|
||||||
|
class hooks to target, and unscoped element selectors would restyle the
|
||||||
|
whole dashboard. */
|
||||||
|
.md-view {
|
||||||
|
color: #d8d8de;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.68;
|
||||||
|
/* Research documents are read, not skimmed. A bounded measure keeps lines
|
||||||
|
comfortable; wide content (tables, code) scrolls inside its own box. */
|
||||||
|
max-width: 78ch;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.md-view > :first-child { margin-top: 0; }
|
||||||
|
.md-view h1, .md-view h2, .md-view h3, .md-view h4 {
|
||||||
|
color: #f3f3f5;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin: 1.6em 0 0.6em;
|
||||||
|
}
|
||||||
|
.md-view h1 {
|
||||||
|
font-size: 21px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.09);
|
||||||
|
padding-bottom: 0.35em;
|
||||||
|
}
|
||||||
|
.md-view h2 {
|
||||||
|
font-size: 17px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
padding-bottom: 0.3em;
|
||||||
|
}
|
||||||
|
.md-view h3 { font-size: 15px; }
|
||||||
|
.md-view h4 { font-size: 13.5px; color: #c8c8d0; }
|
||||||
|
.md-view p { margin: 0.85em 0; }
|
||||||
|
.md-view a {
|
||||||
|
color: #7cd6e0;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid rgba(124, 214, 224, 0.35);
|
||||||
|
}
|
||||||
|
.md-view a:hover { border-bottom-color: #7cd6e0; }
|
||||||
|
.md-view strong { color: #f3f3f5; font-weight: 600; }
|
||||||
|
.md-view ul, .md-view ol { margin: 0.8em 0; padding-left: 1.5em; }
|
||||||
|
.md-view li { margin: 0.32em 0; }
|
||||||
|
.md-view li::marker { color: #7cd6e0; }
|
||||||
|
.md-view code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
color: #e6d9a8;
|
||||||
|
}
|
||||||
|
.md-view pre {
|
||||||
|
background: #0a0a0d;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
/* Inside a block the chip styling would double the border and re-tint text. */
|
||||||
|
.md-view pre code {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
color: #d8d8de;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.md-view blockquote {
|
||||||
|
margin: 1em 0;
|
||||||
|
padding: 0.1em 0 0.1em 1em;
|
||||||
|
border-left: 3px solid rgba(124, 214, 224, 0.5);
|
||||||
|
color: #a8a8b2;
|
||||||
|
}
|
||||||
|
/* Agents write GFM tables constantly — this is the main payoff of remark-gfm.
|
||||||
|
`display: block` so a wide table scrolls itself instead of the page. */
|
||||||
|
.md-view table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 1.1em 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.md-view th, .md-view td {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||||
|
padding: 7px 10px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.md-view th {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: #f3f3f5;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.md-view tr:nth-child(even) td { background: rgba(255, 255, 255, 0.015); }
|
||||||
|
.md-view hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.09);
|
||||||
|
margin: 1.8em 0;
|
||||||
|
}
|
||||||
|
.md-view img { max-width: 100%; border-radius: 6px; }
|
||||||
|
.md-view input[type="checkbox"] { margin-right: 6px; }
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { MarkdownBlock, outlineOf } from "./MarkdownBlock";
|
||||||
|
|
||||||
|
describe("MarkdownBlock", () => {
|
||||||
|
/**
|
||||||
|
* The reason this renderer was replaced. Agent research briefs are largely GFM
|
||||||
|
* pipe tables; the previous zero-dep renderer had no table support and showed
|
||||||
|
* them as literal pipe characters — the least readable form of the most
|
||||||
|
* structured content in the document.
|
||||||
|
*/
|
||||||
|
it("renders GFM tables as tables, not as pipe characters", () => {
|
||||||
|
render(
|
||||||
|
<MarkdownBlock
|
||||||
|
source={[
|
||||||
|
"| Section | What it adds |",
|
||||||
|
"| --- | --- |",
|
||||||
|
"| SIMD | Fletcher32 |",
|
||||||
|
].join("\n")}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "Section" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("cell", { name: "Fletcher32" })).toBeInTheDocument();
|
||||||
|
expect(document.body.textContent).not.toContain("| Section |");
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The outline rail scrolls to `#id`. If `outlineOf` and the rendered headings
|
||||||
|
* disagree by even one, every link below the divergence lands on the wrong
|
||||||
|
* section — and it fails silently, because scrolling to a missing anchor just
|
||||||
|
* does nothing.
|
||||||
|
*/
|
||||||
|
it("gives the outline the same ids as the rendered headings", () => {
|
||||||
|
const md = [
|
||||||
|
"# Overview",
|
||||||
|
"text",
|
||||||
|
"## Details",
|
||||||
|
"more",
|
||||||
|
"## Details",
|
||||||
|
"duplicate heading on purpose",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const { container } = render(<MarkdownBlock source={md} />);
|
||||||
|
const renderedIds = Array.from(container.querySelectorAll("h1, h2, h3")).map(
|
||||||
|
(h) => h.id,
|
||||||
|
);
|
||||||
|
expect(outlineOf(md).map((h) => h.id)).toEqual(renderedIds);
|
||||||
|
// And duplicates must be distinguished, or both links go to the first one.
|
||||||
|
expect(new Set(renderedIds).size).toBe(renderedIds.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `# comment` inside a fenced block is code. Counting it would put shell
|
||||||
|
* comments in the outline AND shift every later duplicate suffix, breaking the
|
||||||
|
* ids the test above pins.
|
||||||
|
*/
|
||||||
|
it("does not treat comments inside code fences as headings", () => {
|
||||||
|
const md = ["# Real", "```sh", "# not a heading", "```", "## Also real"].join(
|
||||||
|
"\n",
|
||||||
|
);
|
||||||
|
expect(outlineOf(md).map((h) => h.text)).toEqual(["Real", "Also real"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,313 +1,90 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// MarkdownBlock — tiny zero-dep Markdown renderer. Handles the subset
|
// MarkdownBlock — the mission reading surface's markdown renderer.
|
||||||
// the refiner emits: h1/h2/h3 headings, - / * bullets, 1. numbered
|
//
|
||||||
// lists, `**bold**`, `` `code` ``, blank-line-separated paragraphs.
|
// This was a deliberately tiny zero-dep renderer covering "the subset the
|
||||||
// Not a general-purpose renderer — deliberately small to avoid a
|
// refiner emits": h1-h3, bullets, `**bold**`, `` `code` ``, fenced blocks. That
|
||||||
// react-markdown dep for one canvas surface.
|
// subset stopped matching reality. Agent research output is full of GFM PIPE
|
||||||
|
// TABLES — the ClawHDF5 briefs were largely tables ("| Section | What it adds |")
|
||||||
|
// — and a renderer without table support shows them as literal pipe characters,
|
||||||
|
// which is the least readable possible form of the most structured content.
|
||||||
|
//
|
||||||
|
// It now delegates to react-markdown + remark-gfm: tables, task lists,
|
||||||
|
// strikethrough and autolinks, plus correct handling of the nesting cases (a
|
||||||
|
// list inside a blockquote, emphasis inside a heading) that a line-oriented
|
||||||
|
// parser gets wrong.
|
||||||
|
//
|
||||||
|
// `outlineOf` keeps its signature — MissionOutputReader's outline rail depends
|
||||||
|
// on it. `headingId` is gone: ids now come from rehype-slug, because the outline
|
||||||
|
// and the rendered headings MUST agree and hand-counting ordinals across two
|
||||||
|
// code paths is exactly how they drift.
|
||||||
|
//
|
||||||
|
// Visual styling lives in globals.css under `.md-view`: this markup is
|
||||||
|
// generated, so there are no class hooks to target, and the project has no
|
||||||
|
// styled-jsx registry (next/dist/docs/01-app/02-guides/css-in-js.md).
|
||||||
|
|
||||||
import React from "react";
|
import GithubSlugger from "github-slugger";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import rehypeSlug from "rehype-slug";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
const mono =
|
/**
|
||||||
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
* The h1-h3 headings of a document, in order, for the outline rail.
|
||||||
|
*
|
||||||
type Block =
|
* Slugged with the same GithubSlugger that rehype-slug uses on the rendered
|
||||||
| { kind: "h1" | "h2" | "h3"; text: string; id?: string }
|
* headings, so `#h-id` anchors resolve. Two details keep the two in step:
|
||||||
| { kind: "p"; text: string }
|
*
|
||||||
| { kind: "ul"; items: string[] }
|
* - **Fence-aware.** A `# comment` inside a ```sh block is code, not a
|
||||||
| { kind: "ol"; items: string[] }
|
* heading. rehype-slug never sees it because it is a code node, so counting
|
||||||
| { kind: "code"; lang: string; text: string };
|
* it here would desynchronise every following duplicate suffix.
|
||||||
|
* - **All levels feed the slugger, only h1-h3 are returned.** GithubSlugger's
|
||||||
/** Stable slug for a heading, so the reader's outline can scroll to it. */
|
* duplicate counter is per document ("intro", "intro-1"); if an h4 shares a
|
||||||
export function headingId(text: string, ordinal: number): string {
|
* title with an h3 and only one side counted it, the suffixes would diverge.
|
||||||
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(
|
export function outlineOf(
|
||||||
md: string,
|
md: string,
|
||||||
): Array<{ id: string; text: string; level: 1 | 2 | 3 }> {
|
): Array<{ id: string; text: string; level: 1 | 2 | 3 }> {
|
||||||
return parse(md).flatMap((b, idx) =>
|
const slugger = new GithubSlugger();
|
||||||
b.kind === "h1" || b.kind === "h2" || b.kind === "h3"
|
const out: Array<{ id: string; text: string; level: 1 | 2 | 3 }> = [];
|
||||||
? [
|
let inFence = false;
|
||||||
{
|
for (const raw of md.replace(/\r\n/g, "\n").split("\n")) {
|
||||||
id: b.id ?? headingId(b.text, idx),
|
const line = raw.trim();
|
||||||
text: b.text,
|
if (/^```/.test(line)) {
|
||||||
level: Number(b.kind.slice(1)) as 1 | 2 | 3,
|
inFence = !inFence;
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
continue;
|
||||||
}
|
}
|
||||||
// Fenced code block. Agent output is full of ```rust / ```toml
|
if (inFence) continue;
|
||||||
// blocks; without this they render as mangled paragraphs.
|
const h = /^(#{1,6})\s+(.+)$/.exec(line);
|
||||||
const fence = /^```([A-Za-z0-9_+-]*)\s*$/.exec(trimmed);
|
if (!h) continue;
|
||||||
if (fence) {
|
// Strip inline markup so the rail shows "Crate graph", not "**Crate graph**"
|
||||||
const lang = fence[1] ?? "";
|
// — and so the slug matches what rehype-slug computes from rendered text.
|
||||||
const body: string[] = [];
|
const text = h[2].replace(/[*_`]/g, "").trim();
|
||||||
i++;
|
const id = slugger.slug(text);
|
||||||
while (i < lines.length && !/^```\s*$/.test(lines[i].trim())) {
|
const level = h[1].length;
|
||||||
body.push(lines[i]);
|
if (level <= 3) out.push({ id, text, level: level as 1 | 2 | 3 });
|
||||||
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MarkdownBlock({ source }: { source: string }) {
|
export function MarkdownBlock({ source }: { source: string }) {
|
||||||
const blocks = React.useMemo(() => parse(source), [source]);
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="md-view">
|
||||||
style={{
|
<ReactMarkdown
|
||||||
display: "flex",
|
remarkPlugins={[remarkGfm]}
|
||||||
flexDirection: "column",
|
rehypePlugins={[rehypeSlug]}
|
||||||
gap: 10,
|
components={{
|
||||||
color: "#cfcfd5",
|
// Agent briefs cite sources. Opening in-tab would lose the mission
|
||||||
fontSize: 13,
|
// view; `noopener` because a bare `target=_blank` hands the opened
|
||||||
lineHeight: 1.55,
|
// page a handle back to this one.
|
||||||
}}
|
a: ({ href, children }) => (
|
||||||
>
|
<a href={href} target="_blank" rel="noreferrer noopener">
|
||||||
{blocks.map((b, idx) => {
|
{children}
|
||||||
if (b.kind === "h1")
|
</a>
|
||||||
return (
|
),
|
||||||
<h1
|
}}
|
||||||
key={idx}
|
>
|
||||||
id={b.id}
|
{source}
|
||||||
style={{
|
</ReactMarkdown>
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// MissionArtifacts — the mission's captured FILES, and a reader for them.
|
||||||
|
//
|
||||||
|
// Distinct from MissionOutputReader, which shows agent turn output: that is the
|
||||||
|
// agent's ACCOUNT of the work, this is the work. For a repo-less research
|
||||||
|
// mission these artifacts are the only durable result — see
|
||||||
|
// `cm-api/src/mission_outputs.rs`.
|
||||||
|
//
|
||||||
|
// Bodies are fetched on demand. The mission detail carries paths only, and a
|
||||||
|
// research phase can leave dozens of documents.
|
||||||
|
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { FileText } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getArtifactContent,
|
||||||
|
type ArtifactContent,
|
||||||
|
type MissionDetail,
|
||||||
|
} from "@/lib/api/missions";
|
||||||
|
import { MarkdownBlock } from "./MarkdownBlock";
|
||||||
|
|
||||||
|
const mono =
|
||||||
|
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
||||||
|
|
||||||
|
export function MissionArtifacts({
|
||||||
|
mission,
|
||||||
|
secondaryBtn,
|
||||||
|
}: {
|
||||||
|
mission: MissionDetail;
|
||||||
|
secondaryBtn: React.CSSProperties;
|
||||||
|
}) {
|
||||||
|
const missionId = mission.id;
|
||||||
|
const artifacts = mission.artifacts;
|
||||||
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
|
const [text, setText] = useState<ArtifactContent | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open one artifact, fetching its text. Clicking the open one closes it.
|
||||||
|
*
|
||||||
|
* Errors surface in place rather than being swallowed: a file the server
|
||||||
|
* refuses to read (outside `_outputs`, or reaped with its mission) has to say
|
||||||
|
* so, or the panel sits empty and reads as a slow load that never finishes.
|
||||||
|
*/
|
||||||
|
const open = useCallback(
|
||||||
|
async (artifactId: string) => {
|
||||||
|
if (openId === artifactId) {
|
||||||
|
setOpenId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpenId(artifactId);
|
||||||
|
setText(null);
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setText(await getArtifactContent(missionId, artifactId));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "could not read this artifact");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[missionId, openId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (artifacts.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ fontFamily: mono, fontSize: 11.5, color: "#8a8a92", padding: 12 }}>
|
||||||
|
no artifacts yet — phases produce them as they run
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
{artifacts.map((a) => {
|
||||||
|
const isOpen = openId === a.id;
|
||||||
|
return (
|
||||||
|
<div key={a.id} style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 11,
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "1px solid rgba(255,255,255,.07)",
|
||||||
|
background: "#101014",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileText size={16} style={{ color: "#7cd6e0", flex: "none" }} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13, color: "#f3f3f5", fontWeight: 500 }}>
|
||||||
|
{a.title ?? a.path.split("/").pop() ?? a.path}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: mono, fontSize: 10.5, color: "#8a8a92" }}>
|
||||||
|
{a.kind} · {a.path}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => open(a.id)}
|
||||||
|
style={{ ...secondaryBtn, padding: "5px 10px", fontSize: 11 }}
|
||||||
|
>
|
||||||
|
{isOpen ? "Hide" : "Read"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid rgba(255,255,255,.06)",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "#0a0a0d",
|
||||||
|
padding: 16,
|
||||||
|
maxHeight: 640,
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ fontFamily: mono, fontSize: 11.5, color: "#8a8a92" }}>
|
||||||
|
loading…
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div style={{ fontFamily: mono, fontSize: 11.5, color: "#ff8a7a" }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : text?.truncated ? (
|
||||||
|
<div style={{ fontFamily: mono, fontSize: 11.5, color: "#8a8a92" }}>
|
||||||
|
too large to display inline ({text.bytes.toLocaleString()} bytes)
|
||||||
|
</div>
|
||||||
|
) : text ? (
|
||||||
|
<MarkdownBlock source={text.content} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
FileText,
|
|
||||||
Pencil,
|
Pencil,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -42,6 +41,7 @@ import {
|
|||||||
} from "@/lib/api/missions";
|
} from "@/lib/api/missions";
|
||||||
import { EditMissionModal } from "./EditMissionModal";
|
import { EditMissionModal } from "./EditMissionModal";
|
||||||
import { MarkdownBlock } from "./MarkdownBlock";
|
import { MarkdownBlock } from "./MarkdownBlock";
|
||||||
|
import { MissionArtifacts } from "./MissionArtifacts";
|
||||||
import { MissionLiveEvents } from "./MissionLiveEvents";
|
import { MissionLiveEvents } from "./MissionLiveEvents";
|
||||||
import { MissionLivePane } from "./MissionLivePane";
|
import { MissionLivePane } from "./MissionLivePane";
|
||||||
import { MissionOutputReader } from "./MissionOutputReader";
|
import { MissionOutputReader } from "./MissionOutputReader";
|
||||||
@@ -135,7 +135,7 @@ export function MissionCanvas({
|
|||||||
const [refineDiff, setRefineDiff] = useState<RefineResult | null>(null);
|
const [refineDiff, setRefineDiff] = useState<RefineResult | null>(null);
|
||||||
const [accepting, setAccepting] = useState(false);
|
const [accepting, setAccepting] = useState(false);
|
||||||
const [phaseBusy, setPhaseBusy] = useState<string | null>(null);
|
const [phaseBusy, setPhaseBusy] = useState<string | null>(null);
|
||||||
const [pdfPreviewId, setPdfPreviewId] = useState<string | null>(null);
|
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [deleteBusy, setDeleteBusy] = useState(false);
|
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||||
@@ -1016,101 +1016,8 @@ export function MissionCanvas({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "output" && outputSub === "artifacts" && (
|
{tab === "output" && outputSub === "artifacts" && (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
<MissionArtifacts mission={mission} secondaryBtn={secondaryBtn} />
|
||||||
{mission.artifacts.length === 0 ? (
|
|
||||||
<Empty label="no artifacts yet — phases produce them as they run" />
|
|
||||||
) : (
|
|
||||||
mission.artifacts.map((a) => {
|
|
||||||
const isPreviewing = pdfPreviewId === a.id;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={a.id}
|
|
||||||
style={{ display: "flex", flexDirection: "column", gap: 8 }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: 11,
|
|
||||||
borderRadius: 10,
|
|
||||||
border: "1px solid rgba(255,255,255,.07)",
|
|
||||||
background: "#101014",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FileText size={16} style={{ color: "#7cd6e0", flex: "none" }} />
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{ fontSize: 13, color: "#f3f3f5", fontWeight: 500 }}>
|
|
||||||
{a.title ?? a.path.split("/").pop() ?? a.path}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontFamily: mono, fontSize: 10.5, color: "#8a8a92" }}>
|
|
||||||
{a.kind} · {a.path}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{a.rendered_pdf_path ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setPdfPreviewId(isPreviewing ? null : a.id)
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
...secondaryBtn,
|
|
||||||
padding: "5px 10px",
|
|
||||||
fontSize: 11,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isPreviewing ? "Hide" : "Preview"}
|
|
||||||
</button>
|
|
||||||
<a
|
|
||||||
href={a.rendered_pdf_path}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
style={{
|
|
||||||
...secondaryBtn,
|
|
||||||
padding: "5px 10px",
|
|
||||||
fontSize: 11,
|
|
||||||
textDecoration: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Open
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
) : a.render_pdf_status !== "skip" ? (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: mono,
|
|
||||||
fontSize: 10.5,
|
|
||||||
color:
|
|
||||||
a.render_pdf_status === "failed"
|
|
||||||
? "#ff8a7a"
|
|
||||||
: "#8a8a92",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
pdf: {a.render_pdf_status}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{isPreviewing && a.rendered_pdf_path && (
|
|
||||||
<iframe
|
|
||||||
src={a.rendered_pdf_path}
|
|
||||||
title={a.title ?? a.path}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: 640,
|
|
||||||
border: "1px solid rgba(255,255,255,.06)",
|
|
||||||
borderRadius: 8,
|
|
||||||
background: "#0a0a0d",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "output" && outputSub === "benchmarks" && (
|
{tab === "output" && outputSub === "benchmarks" && (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
{mission.benchmarks.length === 0 ? (
|
{mission.benchmarks.length === 0 ? (
|
||||||
|
|||||||
@@ -342,6 +342,23 @@ export const getMissionDocument = (
|
|||||||
`/api/missions/${id}/documents/${runId}/${index}`,
|
`/api/missions/${id}/documents/${runId}/${index}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** One artifact's text. Artifacts are FILES the phase left behind, which is a
|
||||||
|
* different thing from `MissionDocument` — that is an agent's turn output, its
|
||||||
|
* account of the work rather than the work. Both are worth reading. */
|
||||||
|
export interface ArtifactContent {
|
||||||
|
path: string;
|
||||||
|
mime: string | null;
|
||||||
|
title: string | null;
|
||||||
|
/** Empty when `truncated`; the file is too large to read inline. */
|
||||||
|
content: string;
|
||||||
|
truncated: boolean;
|
||||||
|
bytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One artifact's text, fetched on demand — the list carries no bodies. */
|
||||||
|
export const getArtifactContent = (id: string, artifactId: string) =>
|
||||||
|
api<ArtifactContent>(`/api/missions/${id}/artifacts/${artifactId}/content`);
|
||||||
|
|
||||||
export interface PhaseSummarySource {
|
export interface PhaseSummarySource {
|
||||||
title?: string;
|
title?: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user