ci fixes: cargo fmt, eslint entities, max-lines split
ci / gates (push) Successful in 8s
ci / frontend (push) Successful in 26s
ci / rust (push) Successful in 4m25s
ci / e2e (push) Skipped
ci / publish (push) Successful in 2m46s

CI on 6ffbe97 failed on two auto-fixable gates. Both fixed:

  * cargo fmt --all — rustfmt applied across the surface touched
    by the last ~20 commits (world.rs, security_scan.rs,
    routes/{missions,nodes,terminal}.rs, fleet_herdr.rs,
    mission_workspace.rs, benchmark_runner.rs, mission_refiner.rs,
    lib.rs, tests/mission_orchestrator.rs, cm-db/repo/{missions,teams}.rs,
    bins/clawmates-node/src/main.rs)
  * eslint apostrophe escapes in HerdrSessions + MissionWizard
  * eslint max-lines: extracted EditMissionModal + RefineDiffModal
    (each ~200 LoC) into their own files. MissionCanvas drops from
    1424 to 1026, comfortably under both the 1250 eslint cap and the
    1500 CI budget.

New files:
  frontend/src/components/dashboard/EditMissionModal.tsx  (211 LoC)
  frontend/src/components/dashboard/RefineDiffModal.tsx   (208 LoC)

Verified locally: cargo fmt --check clean, cargo check clean,
mission_orchestrator test 3/3 pass, tsc + eslint --quiet both silent.
This commit is contained in:
Omar Sobh
2026-07-20 12:03:43 -07:00
parent 6ffbe978b2
commit d8c8793c4a
20 changed files with 546 additions and 522 deletions
+9 -13
View File
@@ -509,10 +509,7 @@ async fn handle_frame(
// polls that pane's agent_status; `herdr_read` scrapes its recent // polls that pane's agent_status; `herdr_read` scrapes its recent
// transcript. Node just shells out to the `herdr` binary — the // transcript. Node just shells out to the `herdr` binary — the
// Herdr background daemon is expected to already be running. // Herdr background daemon is expected to already be running.
op @ ("herdr_dispatch" op @ ("herdr_dispatch" | "herdr_status" | "herdr_read" | "herdr_workspaces"
| "herdr_status"
| "herdr_read"
| "herdr_workspaces"
| "herdr_snapshot") => { | "herdr_snapshot") => {
if let Some(id) = v.get("id").and_then(Value::as_u64) { if let Some(id) = v.get("id").and_then(Value::as_u64) {
let (ok, output) = herdr_op(op, &v).await; let (ok, output) = herdr_op(op, &v).await;
@@ -675,11 +672,16 @@ fn spawn_container_pty(
/// specific agent container on this node. /// specific agent container on this node.
pub(crate) enum PtyTarget { pub(crate) enum PtyTarget {
Host, Host,
Container { container: String, session: String }, Container {
container: String,
session: String,
},
/// Custom argv (Herdr Live Pane uses this to spawn `herdr` directly /// Custom argv (Herdr Live Pane uses this to spawn `herdr` directly
/// so the browser xterm attaches straight into the node's Herdr TUI /// so the browser xterm attaches straight into the node's Herdr TUI
/// instead of a login shell). /// instead of a login shell).
Command { argv: Vec<String> }, Command {
argv: Vec<String>,
},
} }
impl PtyTarget { impl PtyTarget {
@@ -1008,13 +1010,7 @@ async fn herdr_op(op: &str, v: &Value) -> (bool, String) {
let escaped = prompt.replace('\'', "'\\''"); let escaped = prompt.replace('\'', "'\\''");
format!("{cli} '{escaped}'") format!("{cli} '{escaped}'")
}; };
let (rok, rout) = run(vec![ let (rok, rout) = run(vec!["pane".into(), "run".into(), pane_id.clone(), launch]).await;
"pane".into(),
"run".into(),
pane_id.clone(),
launch,
])
.await;
let payload = serde_json::json!({ let payload = serde_json::json!({
"pane_id": pane_id, "pane_id": pane_id,
"split": split_out, "split": split_out,
+1 -3
View File
@@ -235,9 +235,7 @@ async fn exec_target(
pool: &PgPool, pool: &PgPool,
mission_id: Uuid, mission_id: Uuid,
) -> Result<(String, std::path::PathBuf), String> { ) -> Result<(String, std::path::PathBuf), String> {
let repo_id: Option<Uuid> = sqlx::query_scalar( let repo_id: Option<Uuid> = sqlx::query_scalar("SELECT repo_id FROM missions WHERE id = $1")
"SELECT repo_id FROM missions WHERE id = $1",
)
.bind(mission_id) .bind(mission_id)
.fetch_optional(pool) .fetch_optional(pool)
.await .await
+16 -19
View File
@@ -56,10 +56,17 @@ pub async fn dispatch(
.call_timeout(node_id, "herdr_dispatch", args, 60) .call_timeout(node_id, "herdr_dispatch", args, 60)
.await?; .await?;
if !out.ok { if !out.ok {
return Err(format!("node rejected dispatch: {}", truncate(&out.output, 400))); return Err(format!(
"node rejected dispatch: {}",
truncate(&out.output, 400)
));
} }
let payload: Value = serde_json::from_str(&out.output) let payload: Value = serde_json::from_str(&out.output).map_err(|e| {
.map_err(|e| format!("dispatch payload not json: {e}: {}", truncate(&out.output, 200)))?; format!(
"dispatch payload not json: {e}: {}",
truncate(&out.output, 200)
)
})?;
let pane_id = payload let pane_id = payload
.get("pane_id") .get("pane_id")
.and_then(Value::as_str) .and_then(Value::as_str)
@@ -80,18 +87,9 @@ pub async fn dispatch(
/// Read the current agent state of a pane. Returns raw pane.get JSON /// Read the current agent state of a pane. Returns raw pane.get JSON
/// so the caller can inspect any field (agent, agent_status, cwd, /// so the caller can inspect any field (agent, agent_status, cwd,
/// process metadata). /// process metadata).
pub async fn status( pub async fn status(hub: Arc<NodeHub>, node_id: NodeId, pane_id: &str) -> Result<Value, String> {
hub: Arc<NodeHub>,
node_id: NodeId,
pane_id: &str,
) -> Result<Value, String> {
let out = hub let out = hub
.call_timeout( .call_timeout(node_id, "herdr_status", json!({ "pane_id": pane_id }), 20)
node_id,
"herdr_status",
json!({ "pane_id": pane_id }),
20,
)
.await?; .await?;
if !out.ok { if !out.ok {
return Err(format!("status failed: {}", truncate(&out.output, 300))); return Err(format!("status failed: {}", truncate(&out.output, 300)));
@@ -103,10 +101,7 @@ pub async fn status(
/// Fetch the full session snapshot from a node's Herdr daemon /// Fetch the full session snapshot from a node's Herdr daemon
/// (`herdr api snapshot`). Returns raw JSON so the frontend can render /// (`herdr api snapshot`). Returns raw JSON so the frontend can render
/// workspaces + tabs + panes + agent states without a schema hop. /// workspaces + tabs + panes + agent states without a schema hop.
pub async fn snapshot( pub async fn snapshot(hub: Arc<NodeHub>, node_id: NodeId) -> Result<Value, String> {
hub: Arc<NodeHub>,
node_id: NodeId,
) -> Result<Value, String> {
let out = hub let out = hub
.call_timeout(node_id, "herdr_snapshot", json!({}), 15) .call_timeout(node_id, "herdr_snapshot", json!({}), 15)
.await?; .await?;
@@ -156,7 +151,9 @@ pub async fn wait_for_completion(
let mut ever_working = false; let mut ever_working = false;
loop { loop {
if started.elapsed() > Duration::from_secs(timeout_secs) { if started.elapsed() > Duration::from_secs(timeout_secs) {
return Err(format!("pane {pane_id} did not complete in {timeout_secs}s")); return Err(format!(
"pane {pane_id} did not complete in {timeout_secs}s"
));
} }
let s = status(hub.clone(), node_id, pane_id).await?; let s = status(hub.clone(), node_id, pane_id).await?;
let state = s let state = s
+1 -4
View File
@@ -450,10 +450,7 @@ pub fn router(state: AppState) -> Router {
"/api/missions/{id}/status", "/api/missions/{id}/status",
axum::routing::patch(routes::missions::set_status), axum::routing::patch(routes::missions::set_status),
) )
.route( .route("/api/missions/{id}/refine", post(routes::missions::refine))
"/api/missions/{id}/refine",
post(routes::missions::refine),
)
.route( .route(
"/api/missions/{id}/herdr-dispatch", "/api/missions/{id}/herdr-dispatch",
post(routes::missions::herdr_dispatch), post(routes::missions::herdr_dispatch),
+8 -2
View File
@@ -34,7 +34,10 @@ pub async fn refine(
.map_err(|e| format!("load mission: {e}"))? .map_err(|e| format!("load mission: {e}"))?
.ok_or_else(|| "mission not found".to_string())?; .ok_or_else(|| "mission not found".to_string())?;
if mission.status != "draft" { if mission.status != "draft" {
return Err(format!("mission is {}, refine only allowed on draft", mission.status)); return Err(format!(
"mission is {}, refine only allowed on draft",
mission.status
));
} }
let raw = mission.description.unwrap_or_default(); let raw = mission.description.unwrap_or_default();
if raw.trim().is_empty() { if raw.trim().is_empty() {
@@ -50,7 +53,10 @@ pub async fn refine(
let refined = call_gemini(&mission.title, &mission.template_kind, &phase_kinds, &raw).await?; let refined = call_gemini(&mission.title, &mission.template_kind, &phase_kinds, &raw).await?;
Ok(RefineResult { original: raw, refined }) Ok(RefineResult {
original: raw,
refined,
})
} }
async fn call_gemini( async fn call_gemini(
+10 -8
View File
@@ -74,13 +74,7 @@ pub async fn ensure_checkout(
async fn clone(path: &std::path::Path, url: &str) -> Result<(), String> { async fn clone(path: &std::path::Path, url: &str) -> Result<(), String> {
let out = Command::new("git") let out = Command::new("git")
.args([ .args(["clone", "--depth", "1", url, &path.display().to_string()])
"clone",
"--depth",
"1",
url,
&path.display().to_string(),
])
.output() .output()
.await .await
.map_err(|e| format!("spawn git clone: {e}"))?; .map_err(|e| format!("spawn git clone: {e}"))?;
@@ -99,7 +93,15 @@ async fn clone(path: &std::path::Path, url: &str) -> Result<(), String> {
async fn fetch_and_reset(path: &std::path::Path, branch: &str) -> Result<(), String> { async fn fetch_and_reset(path: &std::path::Path, branch: &str) -> Result<(), String> {
let fetch = Command::new("git") let fetch = Command::new("git")
.args(["-C", &path.display().to_string(), "fetch", "--depth", "1", "origin", branch]) .args([
"-C",
&path.display().to_string(),
"fetch",
"--depth",
"1",
"origin",
branch,
])
.output() .output()
.await .await
.map_err(|e| format!("spawn git fetch: {e}"))?; .map_err(|e| format!("spawn git fetch: {e}"))?;
+1 -2
View File
@@ -431,8 +431,7 @@ pub async fn set_status(
.await?; .await?;
if prior.status == "draft" && body.status == "running" { if prior.status == "draft" && body.status == "running" {
if let Err(e) = if let Err(e) = crate::mission_orchestrator::on_launch(
crate::mission_orchestrator::on_launch(
&state.pool, &state.pool,
user.workspace_id, user.workspace_id,
user.user_id, user.user_id,
+2 -1
View File
@@ -364,7 +364,8 @@ async fn bridge_terminal(hub: Arc<NodeHub>, node_id: NodeId, socket: WebSocket)
} else { } else {
Some(c.command.as_slice()) Some(c.command.as_slice())
}; };
hub.open_pty(node_id, sid, cols, rows, None, None, cmd).await hub.open_pty(node_id, sid, cols, rows, None, None, cmd)
.await
} }
"webrtc_offer" => { "webrtc_offer" => {
hub.webrtc_offer( hub.webrtc_offer(
+9 -1
View File
@@ -402,7 +402,15 @@ async fn bridge_node(
match c.kind.as_str() { match c.kind.as_str() {
"resize" => hub.terminal_resize(node_id, sid, cols, rows).await, "resize" => hub.terminal_resize(node_id, sid, cols, rows).await,
"fallback" => { "fallback" => {
hub.open_pty(node_id, sid, cols, rows, Some(&container), Some(&session), None) hub.open_pty(
node_id,
sid,
cols,
rows,
Some(&container),
Some(&session),
None,
)
.await .await
} }
"webrtc_offer" => { "webrtc_offer" => {
-1
View File
@@ -586,4 +586,3 @@ pub async fn world_replay(
json!({ "events": events, "hours": hours, "count": rows.len() }), json!({ "events": events, "hours": hours, "count": rows.len() }),
)) ))
} }
+13 -7
View File
@@ -109,7 +109,10 @@ pub async fn run(pool: &PgPool, mission_id: Uuid, phase_id: Uuid) -> Result<usiz
// ── Per-tool runners ──────────────────────────────────────────── // ── Per-tool runners ────────────────────────────────────────────
async fn run_cargo_audit(container: &str, workdir: &std::path::Path) -> Result<Vec<Finding>, String> { async fn run_cargo_audit(
container: &str,
workdir: &std::path::Path,
) -> Result<Vec<Finding>, String> {
let out = docker_exec_json( let out = docker_exec_json(
container, container,
workdir, workdir,
@@ -293,9 +296,7 @@ async fn load_phase_config(pool: &PgPool, phase_id: Uuid) -> Result<Value, Strin
/// scanners need a source tree. Returning a clear error surfaces /// scanners need a source tree. Returning a clear error surfaces
/// that gap instead of silently reporting zero findings. /// that gap instead of silently reporting zero findings.
async fn exec_target(pool: &PgPool, mission_id: Uuid) -> Result<(String, PathBuf), String> { async fn exec_target(pool: &PgPool, mission_id: Uuid) -> Result<(String, PathBuf), String> {
let repo_id: Option<Uuid> = sqlx::query_scalar( let repo_id: Option<Uuid> = sqlx::query_scalar("SELECT repo_id FROM missions WHERE id = $1")
"SELECT repo_id FROM missions WHERE id = $1",
)
.bind(mission_id) .bind(mission_id)
.fetch_optional(pool) .fetch_optional(pool)
.await .await
@@ -311,7 +312,9 @@ async fn exec_target(pool: &PgPool, mission_id: Uuid) -> Result<(String, PathBuf
.unwrap_or_else(|_| "clawmates-runtime".to_string()); .unwrap_or_else(|_| "clawmates-runtime".to_string());
let root = std::env::var("CLAWMATES_MISSIONS_ROOT") let root = std::env::var("CLAWMATES_MISSIONS_ROOT")
.unwrap_or_else(|_| "/var/lib/clawmates-missions".to_string()); .unwrap_or_else(|_| "/var/lib/clawmates-missions".to_string());
let workdir = PathBuf::from(root).join(mission_id.to_string()).join("repo"); let workdir = PathBuf::from(root)
.join(mission_id.to_string())
.join("repo");
Ok((container, workdir)) Ok((container, workdir))
} }
@@ -335,7 +338,11 @@ async fn docker_exec_raw(
Ok(String::from_utf8_lossy(&out.stdout).into_owned()) Ok(String::from_utf8_lossy(&out.stdout).into_owned())
} }
async fn docker_exec_json(container: &str, workdir: &std::path::Path, cmd: &[String]) -> Result<Value, String> { async fn docker_exec_json(
container: &str,
workdir: &std::path::Path,
cmd: &[String],
) -> Result<Value, String> {
let raw = docker_exec_raw(container, workdir, cmd).await?; let raw = docker_exec_raw(container, workdir, cmd).await?;
let trimmed = raw.trim(); let trimmed = raw.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
@@ -353,4 +360,3 @@ fn static_tool_name(s: &str) -> &'static str {
_ => "unknown", _ => "unknown",
} }
} }
+1 -5
View File
@@ -97,10 +97,7 @@ async fn reap_stuck_runs(pool: &PgPool) -> Result<(), sqlx::Error> {
let _ = cm_db::repo::topology_runs::fail( let _ = cm_db::repo::topology_runs::fail(
pool, pool,
id, id,
&format!( &format!("reaped: no step records after {}s", REAP_STUCK_AFTER_SECS),
"reaped: no step records after {}s",
REAP_STUCK_AFTER_SECS
),
) )
.await; .await;
} }
@@ -312,4 +309,3 @@ async fn drive<E: TurnExecutor>(
}) })
.await .await
} }
+14 -14
View File
@@ -144,9 +144,8 @@ async fn on_launch_materializes_team_from_template() {
assert_eq!(stamped_risk, "medium"); assert_eq!(stamped_risk, "medium");
// One agent per role — three total. // One agent per role — three total.
let agent_count: i64 = sqlx::query_scalar( let agent_count: i64 =
"SELECT count(*)::bigint FROM team_members WHERE team_id = $1", sqlx::query_scalar("SELECT count(*)::bigint FROM team_members WHERE team_id = $1")
)
.bind(team_id) .bind(team_id)
.fetch_one(&pool) .fetch_one(&pool)
.await .await
@@ -178,12 +177,13 @@ async fn on_launch_materializes_team_from_template() {
.fetch_one(&pool) .fetch_one(&pool)
.await .await
.unwrap(); .unwrap();
assert_eq!(seeded_count, 3, "expected every claw to have seeded lineage"); assert_eq!(
seeded_count, 3,
"expected every claw to have seeded lineage"
);
// Mission row was updated to point at the new team. // Mission row was updated to point at the new team.
let bound_team_id: Uuid = sqlx::query_scalar( let bound_team_id: Uuid = sqlx::query_scalar("SELECT team_id FROM missions WHERE id = $1")
"SELECT team_id FROM missions WHERE id = $1",
)
.bind(mission_id) .bind(mission_id)
.fetch_one(&pool) .fetch_one(&pool)
.await .await
@@ -207,12 +207,14 @@ async fn on_launch_is_idempotent() {
.await .await
.unwrap() .unwrap()
.unwrap(); .unwrap();
assert_eq!(team_a, team_b, "second invocation should return the same team_id"); assert_eq!(
team_a, team_b,
"second invocation should return the same team_id"
);
// Still exactly three agents — no duplication. // Still exactly three agents — no duplication.
let agent_count: i64 = sqlx::query_scalar( let agent_count: i64 =
"SELECT count(*)::bigint FROM team_members WHERE team_id = $1", sqlx::query_scalar("SELECT count(*)::bigint FROM team_members WHERE team_id = $1")
)
.bind(team_a) .bind(team_a)
.fetch_one(&pool) .fetch_one(&pool)
.await .await
@@ -246,9 +248,7 @@ async fn on_launch_no_template_returns_none() {
assert!(result.is_none(), "no template + no team should return None"); assert!(result.is_none(), "no template + no team should return None");
// Mission stays with team_id NULL. // Mission stays with team_id NULL.
let team_id: Option<Uuid> = sqlx::query_scalar( let team_id: Option<Uuid> = sqlx::query_scalar("SELECT team_id FROM missions WHERE id = $1")
"SELECT team_id FROM missions WHERE id = $1",
)
.bind(mission_id) .bind(mission_id)
.fetch_one(&pool) .fetch_one(&pool)
.await .await
+1 -5
View File
@@ -297,11 +297,7 @@ pub async fn update_meta(
/// Hard-delete a mission. Cascades via FKs on mission_phases / /// Hard-delete a mission. Cascades via FKs on mission_phases /
/// mission_tasks / mission_artifacts / benchmark_snapshots (all /// mission_tasks / mission_artifacts / benchmark_snapshots (all
/// declared ON DELETE CASCADE in 0047). /// declared ON DELETE CASCADE in 0047).
pub async fn delete( pub async fn delete(pool: &PgPool, id: Uuid, workspace_id: Uuid) -> Result<u64, DbError> {
pool: &PgPool,
id: Uuid,
workspace_id: Uuid,
) -> Result<u64, DbError> {
let r = sqlx::query("DELETE FROM missions WHERE id = $1 AND workspace_id = $2") let r = sqlx::query("DELETE FROM missions WHERE id = $1 AND workspace_id = $2")
.bind(id) .bind(id)
.bind(workspace_id) .bind(workspace_id)
-1
View File
@@ -317,4 +317,3 @@ pub async fn set_team_runtime_config(
.await?; .await?;
Ok(()) Ok(())
} }
@@ -0,0 +1,211 @@
"use client";
// Modal for editing an in-flight mission's title + description. Only
// available when the mission is in 'draft' status (backend enforces
// too; the button is hidden past draft in MissionCanvas).
import { useState } from "react";
import { updateMission, type MissionDetail } from "@/lib/api/missions";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
export function EditMissionModal({
mission,
onClose,
onSaved,
}: {
mission: MissionDetail;
onClose: () => void;
onSaved: () => void;
}) {
const [title, setTitle] = useState(mission.title);
const [description, setDescription] = useState(mission.description ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const dirty =
title.trim() !== mission.title || description !== (mission.description ?? "");
const save = async () => {
if (!dirty) {
onClose();
return;
}
if (!title.trim()) {
setError("title is required");
return;
}
setBusy(true);
setError(null);
try {
await updateMission(mission.id, {
title: title.trim() !== mission.title ? title.trim() : undefined,
description:
description !== (mission.description ?? "") ? description : undefined,
});
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "save failed");
} finally {
setBusy(false);
}
};
return (
<div
role="dialog"
aria-modal
onClick={onClose}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,.55)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 900,
padding: 24,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "min(620px, 100%)",
background: "#141419",
borderRadius: 12,
border: "1px solid rgba(255,255,255,.08)",
display: "flex",
flexDirection: "column",
}}
>
<div
style={{
padding: "12px 18px",
borderBottom: "1px solid rgba(255,255,255,.06)",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<span
style={{
fontFamily: mono,
fontSize: 10.5,
letterSpacing: ".14em",
color: "#7cd6e0",
textTransform: "uppercase",
}}
>
Edit mission
</span>
</div>
<div style={{ padding: 18, display: "flex", flexDirection: "column", gap: 12 }}>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span
style={{
fontFamily: mono,
fontSize: 10,
letterSpacing: ".12em",
color: "#a0a0a8",
textTransform: "uppercase",
}}
>
Title
</span>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={busy}
autoFocus
style={{
padding: "8px 12px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.1)",
background: "#0a0a0d",
color: "#f3f3f5",
fontSize: 14,
}}
/>
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span
style={{
fontFamily: mono,
fontSize: 10,
letterSpacing: ".12em",
color: "#a0a0a8",
textTransform: "uppercase",
}}
>
Description (Markdown; use Refine to structure)
</span>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={busy}
rows={12}
style={{
padding: "8px 12px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.1)",
background: "#0a0a0d",
color: "#f3f3f5",
fontFamily: mono,
fontSize: 12,
resize: "vertical",
minHeight: 160,
}}
/>
</label>
{error && (
<div style={{ color: "#ff8a7a", fontSize: 12 }}>{error}</div>
)}
</div>
<div
style={{
padding: "12px 18px",
borderTop: "1px solid rgba(255,255,255,.06)",
display: "flex",
gap: 8,
justifyContent: "flex-end",
}}
>
<button
type="button"
onClick={onClose}
disabled={busy}
style={{
padding: "6px 14px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.1)",
background: "transparent",
color: "#a0a0a8",
fontSize: 12,
cursor: "pointer",
opacity: busy ? 0.5 : 1,
}}
>
Cancel
</button>
<button
type="button"
onClick={save}
disabled={busy || !dirty}
style={{
padding: "6px 14px",
borderRadius: 8,
border: "1px solid rgba(127,208,160,.5)",
background: "rgba(127,208,160,.12)",
color: "#7fd0a0",
fontSize: 12,
cursor: dirty ? "pointer" : "not-allowed",
opacity: busy || !dirty ? 0.5 : 1,
}}
>
{busy ? "Saving…" : "Save"}
</button>
</div>
</div>
</div>
);
}
@@ -147,7 +147,7 @@ export function HerdrSessions() {
<p style={{ fontSize: 13, color: "#8a8a92", margin: "6px 0 22px", lineHeight: 1.5 }}> <p style={{ fontSize: 13, color: "#8a8a92", margin: "6px 0 22px", lineHeight: 1.5 }}>
Every online fleet node is running a persistent Herdr daemon. This view Every online fleet node is running a persistent Herdr daemon. This view
shows their live workspaces + agent states. Click Open to render the shows their live workspaces + agent states. Click Open to render the
node's Herdr TUI in your browser (WebRTC direct where possible). node&apos;s Herdr TUI in your browser (WebRTC direct where possible).
</p> </p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))", gap: 14 }}> <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))", gap: 14 }}>
@@ -19,7 +19,6 @@ import {
setMissionStatus, setMissionStatus,
triggerBenchmark, triggerBenchmark,
triggerSecurityScan, triggerSecurityScan,
updateMission,
type MissionDetail, type MissionDetail,
type MissionStatus, type MissionStatus,
type PhaseKind, type PhaseKind,
@@ -28,9 +27,11 @@ import {
type TaskStatus, type TaskStatus,
type TemplateKind, type TemplateKind,
} from "@/lib/api/missions"; } from "@/lib/api/missions";
import { EditMissionModal } from "./EditMissionModal";
import { MarkdownBlock } from "./MarkdownBlock"; import { MarkdownBlock } from "./MarkdownBlock";
import { MissionLivePane } from "./MissionLivePane"; import { MissionLivePane } from "./MissionLivePane";
import { MissionWizard } from "./MissionWizard"; import { MissionWizard } from "./MissionWizard";
import { RefineDiffModal } from "./RefineDiffModal";
const mono = const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace"; "ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
@@ -988,405 +989,6 @@ function Empty({ label }: { label: string }) {
} }
function EditMissionModal({
mission,
onClose,
onSaved,
}: {
mission: MissionDetail;
onClose: () => void;
onSaved: () => void;
}) {
const [title, setTitle] = useState(mission.title);
const [description, setDescription] = useState(mission.description ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const dirty =
title.trim() !== mission.title || description !== (mission.description ?? "");
const save = async () => {
if (!dirty) {
onClose();
return;
}
if (!title.trim()) {
setError("title is required");
return;
}
setBusy(true);
setError(null);
try {
await updateMission(mission.id, {
title: title.trim() !== mission.title ? title.trim() : undefined,
description:
description !== (mission.description ?? "") ? description : undefined,
});
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "save failed");
} finally {
setBusy(false);
}
};
return (
<div
role="dialog"
aria-modal
onClick={onClose}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,.55)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 900,
padding: 24,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "min(620px, 100%)",
background: "#141419",
borderRadius: 12,
border: "1px solid rgba(255,255,255,.08)",
display: "flex",
flexDirection: "column",
}}
>
<div
style={{
padding: "12px 18px",
borderBottom: "1px solid rgba(255,255,255,.06)",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<span
style={{
fontFamily: mono,
fontSize: 10.5,
letterSpacing: ".14em",
color: "#7cd6e0",
textTransform: "uppercase",
}}
>
Edit mission
</span>
</div>
<div style={{ padding: 18, display: "flex", flexDirection: "column", gap: 12 }}>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span
style={{
fontFamily: mono,
fontSize: 10,
letterSpacing: ".12em",
color: "#a0a0a8",
textTransform: "uppercase",
}}
>
Title
</span>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={busy}
autoFocus
style={{
padding: "8px 12px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.1)",
background: "#0a0a0d",
color: "#f3f3f5",
fontSize: 14,
}}
/>
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span
style={{
fontFamily: mono,
fontSize: 10,
letterSpacing: ".12em",
color: "#a0a0a8",
textTransform: "uppercase",
}}
>
Description (Markdown; use Refine to structure)
</span>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={busy}
rows={12}
style={{
padding: "8px 12px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.1)",
background: "#0a0a0d",
color: "#f3f3f5",
fontFamily: mono,
fontSize: 12,
resize: "vertical",
minHeight: 160,
}}
/>
</label>
{error && (
<div style={{ color: "#ff8a7a", fontSize: 12 }}>{error}</div>
)}
</div>
<div
style={{
padding: "12px 18px",
borderTop: "1px solid rgba(255,255,255,.06)",
display: "flex",
gap: 8,
justifyContent: "flex-end",
}}
>
<button
type="button"
onClick={onClose}
disabled={busy}
style={{
padding: "6px 14px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.1)",
background: "transparent",
color: "#a0a0a8",
fontSize: 12,
cursor: "pointer",
opacity: busy ? 0.5 : 1,
}}
>
Cancel
</button>
<button
type="button"
onClick={save}
disabled={busy || !dirty}
style={{
padding: "6px 14px",
borderRadius: 8,
border: "1px solid rgba(127,208,160,.5)",
background: "rgba(127,208,160,.12)",
color: "#7fd0a0",
fontSize: 12,
cursor: dirty ? "pointer" : "not-allowed",
opacity: busy || !dirty ? 0.5 : 1,
}}
>
{busy ? "Saving…" : "Save"}
</button>
</div>
</div>
</div>
);
}
function RefineDiffModal({
original,
refined,
busy,
onAccept,
onCancel,
onUndoAfterAccept,
}: {
original: string;
refined: string;
busy: boolean;
onAccept: () => void;
onCancel: () => void;
onUndoAfterAccept: () => void;
}) {
return (
<div
role="dialog"
aria-modal
onClick={onCancel}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,.6)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 900,
padding: 24,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "min(1100px, 100%)",
height: "min(720px, calc(100vh - 48px))",
background: "#141419",
borderRadius: 12,
border: "1px solid rgba(255,255,255,.08)",
display: "flex",
flexDirection: "column",
}}
>
<div
style={{
padding: "12px 18px",
borderBottom: "1px solid rgba(255,255,255,.06)",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<span
style={{
fontFamily: mono,
fontSize: 10.5,
letterSpacing: ".14em",
color: "#ffb44a",
textTransform: "uppercase",
}}
>
Refine — review before/after
</span>
<span style={{ flex: 1 }} />
<button
type="button"
onClick={onUndoAfterAccept}
disabled={busy}
title="Restore the original description (drops the refinement)"
style={{
padding: "5px 12px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.1)",
background: "transparent",
color: "#a0a0a8",
fontSize: 12,
cursor: "pointer",
opacity: busy ? 0.5 : 1,
}}
>
Restore original
</button>
<button
type="button"
onClick={onCancel}
disabled={busy}
style={{
padding: "5px 12px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.1)",
background: "transparent",
color: "#a0a0a8",
fontSize: 12,
cursor: "pointer",
opacity: busy ? 0.5 : 1,
}}
>
Cancel
</button>
<button
type="button"
onClick={onAccept}
disabled={busy}
style={{
padding: "5px 14px",
borderRadius: 8,
border: "1px solid rgba(127,208,160,.5)",
background: "rgba(127,208,160,.12)",
color: "#7fd0a0",
fontSize: 12,
cursor: "pointer",
opacity: busy ? 0.5 : 1,
}}
>
{busy ? "Applying…" : "Accept"}
</button>
</div>
<div
style={{
flex: 1,
minHeight: 0,
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 1,
background: "rgba(255,255,255,.06)",
}}
>
<DiffPane
title="Before"
color="#8a8a92"
body={original}
renderAsMarkdown={false}
/>
<DiffPane
title="After"
color="#7fd0a0"
body={refined}
renderAsMarkdown
/>
</div>
</div>
</div>
);
}
function DiffPane({
title,
color,
body,
renderAsMarkdown,
}: {
title: string;
color: string;
body: string;
renderAsMarkdown: boolean;
}) {
return (
<div
style={{
background: "#0e0e12",
display: "flex",
flexDirection: "column",
minHeight: 0,
}}
>
<div
style={{
padding: "8px 14px",
borderBottom: "1px solid rgba(255,255,255,.05)",
fontFamily: mono,
fontSize: 10,
letterSpacing: ".14em",
color,
textTransform: "uppercase",
}}
>
{title}
</div>
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 14 }}>
{renderAsMarkdown ? (
<MarkdownBlock source={body} />
) : (
<pre
style={{
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
fontFamily: mono,
fontSize: 12,
color: "#cfcfd5",
lineHeight: 1.55,
}}
>
{body}
</pre>
)}
</div>
</div>
);
}
const iconBtn: React.CSSProperties = { const iconBtn: React.CSSProperties = {
width: 30, width: 30,
@@ -431,7 +431,7 @@ export function MissionWizard({
</div> </div>
<div style={hintStyle}> <div style={hintStyle}>
Executes in a Herdr pane on a fleet node, using that Executes in a Herdr pane on a fleet node, using that
node's local CLI (claude / kimi / codex). Operator-visible, node&apos;s local CLI (claude / kimi / codex). Operator-visible,
live pane view in the mission canvas. live pane view in the mission canvas.
</div> </div>
{runtimeKind === "local_herdr" && ( {runtimeKind === "local_herdr" && (
@@ -0,0 +1,211 @@
"use client";
// Diff modal for the mission Refine flow. Shows the original
// description (raw) side-by-side with the LLM-refined version
// (rendered as Markdown). Accept / Cancel / Restore-original.
import { MarkdownBlock } from "./MarkdownBlock";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
export function RefineDiffModal({
original,
refined,
busy,
onAccept,
onCancel,
onUndoAfterAccept,
}: {
original: string;
refined: string;
busy: boolean;
onAccept: () => void;
onCancel: () => void;
onUndoAfterAccept: () => void;
}) {
return (
<div
role="dialog"
aria-modal
onClick={onCancel}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,.6)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 900,
padding: 24,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "min(1100px, 100%)",
height: "min(720px, calc(100vh - 48px))",
background: "#141419",
borderRadius: 12,
border: "1px solid rgba(255,255,255,.08)",
display: "flex",
flexDirection: "column",
}}
>
<div
style={{
padding: "12px 18px",
borderBottom: "1px solid rgba(255,255,255,.06)",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<span
style={{
fontFamily: mono,
fontSize: 10.5,
letterSpacing: ".14em",
color: "#ffb44a",
textTransform: "uppercase",
}}
>
Refine — review before/after
</span>
<span style={{ flex: 1 }} />
<button
type="button"
onClick={onUndoAfterAccept}
disabled={busy}
title="Restore the original description (drops the refinement)"
style={{
padding: "5px 12px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.1)",
background: "transparent",
color: "#a0a0a8",
fontSize: 12,
cursor: "pointer",
opacity: busy ? 0.5 : 1,
}}
>
Restore original
</button>
<button
type="button"
onClick={onCancel}
disabled={busy}
style={{
padding: "5px 12px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.1)",
background: "transparent",
color: "#a0a0a8",
fontSize: 12,
cursor: "pointer",
opacity: busy ? 0.5 : 1,
}}
>
Cancel
</button>
<button
type="button"
onClick={onAccept}
disabled={busy}
style={{
padding: "5px 14px",
borderRadius: 8,
border: "1px solid rgba(127,208,160,.5)",
background: "rgba(127,208,160,.12)",
color: "#7fd0a0",
fontSize: 12,
cursor: "pointer",
opacity: busy ? 0.5 : 1,
}}
>
{busy ? "Applying…" : "Accept"}
</button>
</div>
<div
style={{
flex: 1,
minHeight: 0,
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 1,
background: "rgba(255,255,255,.06)",
}}
>
<DiffPane
title="Before"
color="#8a8a92"
body={original}
renderAsMarkdown={false}
/>
<DiffPane
title="After"
color="#7fd0a0"
body={refined}
renderAsMarkdown
/>
</div>
</div>
</div>
);
}
function DiffPane({
title,
color,
body,
renderAsMarkdown,
}: {
title: string;
color: string;
body: string;
renderAsMarkdown: boolean;
}) {
return (
<div
style={{
background: "#0e0e12",
display: "flex",
flexDirection: "column",
minHeight: 0,
}}
>
<div
style={{
padding: "8px 14px",
borderBottom: "1px solid rgba(255,255,255,.05)",
fontFamily: mono,
fontSize: 10,
letterSpacing: ".14em",
color,
textTransform: "uppercase",
}}
>
{title}
</div>
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 14 }}>
{renderAsMarkdown ? (
<MarkdownBlock source={body} />
) : (
<pre
style={{
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
fontFamily: mono,
fontSize: 12,
color: "#cfcfd5",
lineHeight: 1.55,
}}
>
{body}
</pre>
)}
</div>
</div>
);
}