mission canvas: add / edit / delete toolbar controls

Top-right toolbar grows three CRUD controls per your request:

  - Plus (always visible) — opens MissionWizard, selects the new
    mission on create
  - Pencil (draft-only) — opens EditMissionModal for title +
    description; PATCHes /api/missions/{id}
  - Trash (always visible) — window.confirm then DELETEs; sidebar
    selection clears via new onDeleted callback

Backend:
  - cm-db::repo::missions::update_meta(id, ws, title?, description?)
    — COALESCE-based partial patch
  - cm-db::repo::missions::delete(id, ws) — hard delete, cascades
    via FKs on phases/tasks/artifacts/benchmark_snapshots
  - PATCH /api/missions/{id} (draft-only) + DELETE /api/missions/{id}

Frontend:
  - lib/api/missions — updateMission + deleteMission clients
  - MissionCanvas — three toolbar buttons, EditMissionModal
    (title + textarea for description), local wizard state
  - Dashboard — passes onSelect + onDeleted so sidebar reacts to
    create + delete without stale selection

Edit is draft-only (backend enforces + button hidden past draft) so
in-flight missions can't have their brief mutated out from under
running agents. Delete is unconditional — operator responsibility to
Cancel first if a run is live.
This commit is contained in:
Omar Sobh
2026-07-20 08:32:52 -07:00
parent 4c32799906
commit 1f0117e35a
6 changed files with 408 additions and 2 deletions
+6 -1
View File
@@ -435,7 +435,12 @@ pub fn router(state: AppState) -> Router {
"/api/missions", "/api/missions",
get(routes::missions::list).post(routes::missions::create), get(routes::missions::list).post(routes::missions::create),
) )
.route("/api/missions/{id}", get(routes::missions::get)) .route(
"/api/missions/{id}",
get(routes::missions::get)
.patch(routes::missions::update_meta)
.delete(routes::missions::delete),
)
.route( .route(
"/api/missions/{id}/status", "/api/missions/{id}/status",
axum::routing::patch(routes::missions::set_status), axum::routing::patch(routes::missions::set_status),
+57
View File
@@ -287,6 +287,63 @@ pub async fn set_description(
Ok(Json(mission)) Ok(Json(mission))
} }
#[derive(Debug, Deserialize)]
pub struct UpdateMissionRequest {
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub description: Option<String>,
}
/// PATCH /api/missions/{id} — edit title + description. Draft-only.
pub async fn update_meta(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
Json(body): Json<UpdateMissionRequest>,
) -> Result<Json<Mission>, ApiError> {
let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
if mission.status != "draft" {
return Err(ApiError::BadRequest);
}
let title = body
.title
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let description = body.description.as_deref();
cm_db::repo::missions::update_meta(
&state.pool,
id,
user.workspace_id.as_uuid(),
title,
description,
)
.await?;
let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
Ok(Json(mission))
}
/// DELETE /api/missions/{id} — hard-delete. Allowed in any status;
/// the operator is expected to Cancel first if a run is in flight
/// (cascades will still fire either way).
pub async fn delete(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, ApiError> {
let deleted =
cm_db::repo::missions::delete(&state.pool, id, user.workspace_id.as_uuid()).await?;
if deleted == 0 {
return Err(ApiError::NotFound);
}
Ok(Json(serde_json::json!({ "deleted": true })))
}
pub async fn set_status( pub async fn set_status(
State(state): State<AppState>, State(state): State<AppState>,
Authed(user): Authed, Authed(user): Authed,
+41
View File
@@ -252,6 +252,47 @@ pub async fn set_description(
Ok(()) Ok(())
} }
/// Patch title + description in one shot. Either field `None` = leave
/// as-is (uses COALESCE so partial edits don't clobber the other).
pub async fn update_meta(
pool: &PgPool,
id: Uuid,
workspace_id: Uuid,
title: Option<&str>,
description: Option<&str>,
) -> Result<(), DbError> {
sqlx::query(
"UPDATE missions
SET title = COALESCE($3, title),
description = COALESCE($4, description),
updated_at = now()
WHERE id = $1 AND workspace_id = $2",
)
.bind(id)
.bind(workspace_id)
.bind(title)
.bind(description)
.execute(pool)
.await?;
Ok(())
}
/// Hard-delete a mission. Cascades via FKs on mission_phases /
/// mission_tasks / mission_artifacts / benchmark_snapshots (all
/// declared ON DELETE CASCADE in 0047).
pub async fn delete(
pool: &PgPool,
id: Uuid,
workspace_id: Uuid,
) -> Result<u64, DbError> {
let r = sqlx::query("DELETE FROM missions WHERE id = $1 AND workspace_id = $2")
.bind(id)
.bind(workspace_id)
.execute(pool)
.await?;
Ok(r.rows_affected())
}
pub async fn set_status( pub async fn set_status(
pool: &PgPool, pool: &PgPool,
id: Uuid, id: Uuid,
@@ -828,6 +828,14 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
selectedId={missionsSel} selectedId={missionsSel}
refreshKey={missionsRefresh} refreshKey={missionsRefresh}
onChanged={() => setMissionsRefresh((n) => n + 1)} onChanged={() => setMissionsRefresh((n) => n + 1)}
onSelect={(id) => {
setMissionsSel(id);
setMissionsRefresh((n) => n + 1);
}}
onDeleted={() => {
setMissionsSel(null);
setMissionsRefresh((n) => n + 1);
}}
/> />
) : isRepos ? ( ) : isRepos ? (
<RepoCanvas selectedId={repoSel} refreshKey={repoRefresh} /> <RepoCanvas selectedId={repoSel} refreshKey={repoRefresh} />
@@ -9,15 +9,17 @@
// This replaces ResearchCanvas + LoopsCanvas after Slice 9's cutover. // This replaces ResearchCanvas + LoopsCanvas after Slice 9's cutover.
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { FileText, Play, RefreshCw, Sparkles } from "lucide-react"; import { FileText, Pencil, Play, Plus, RefreshCw, Sparkles, Trash2 } from "lucide-react";
import { import {
deleteMission,
getMission, getMission,
refineMission, refineMission,
setMissionDescription, setMissionDescription,
setMissionStatus, setMissionStatus,
triggerBenchmark, triggerBenchmark,
triggerSecurityScan, triggerSecurityScan,
updateMission,
type MissionDetail, type MissionDetail,
type MissionStatus, type MissionStatus,
type PhaseKind, type PhaseKind,
@@ -27,6 +29,7 @@ import {
type TemplateKind, type TemplateKind,
} from "@/lib/api/missions"; } from "@/lib/api/missions";
import { MarkdownBlock } from "./MarkdownBlock"; import { MarkdownBlock } from "./MarkdownBlock";
import { MissionWizard } from "./MissionWizard";
const mono = const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace"; "ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
@@ -73,10 +76,14 @@ export function MissionCanvas({
selectedId, selectedId,
refreshKey, refreshKey,
onChanged, onChanged,
onSelect,
onDeleted,
}: { }: {
selectedId: string | null; selectedId: string | null;
refreshKey: number; refreshKey: number;
onChanged: () => void; onChanged: () => void;
onSelect?: (id: string) => void;
onDeleted?: () => void;
}) { }) {
const [mission, setMission] = useState<MissionDetail | null>(null); const [mission, setMission] = useState<MissionDetail | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -88,6 +95,9 @@ export function MissionCanvas({
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 [pdfPreviewId, setPdfPreviewId] = useState<string | null>(null);
const [wizardOpen, setWizardOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [deleteBusy, setDeleteBusy] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
if (!selectedId) { if (!selectedId) {
@@ -294,6 +304,57 @@ export function MissionCanvas({
{TEMPLATE_LABEL[mission.template_kind] ?? mission.template_kind} {TEMPLATE_LABEL[mission.template_kind] ?? mission.template_kind}
</span> </span>
<div style={{ marginLeft: "auto", display: "flex", gap: 6 }}> <div style={{ marginLeft: "auto", display: "flex", gap: 6 }}>
<button
type="button"
onClick={() => setWizardOpen(true)}
title="Create a new mission"
aria-label="Add mission"
style={iconBtn}
>
<Plus size={13} />
</button>
{mission.status === "draft" && (
<button
type="button"
onClick={() => setEditOpen(true)}
title="Edit title + description"
aria-label="Edit mission"
style={iconBtn}
>
<Pencil size={13} />
</button>
)}
<button
type="button"
onClick={async () => {
if (deleteBusy) return;
const ok = window.confirm(
`Delete mission "${mission.title}"? This cascades to its phases, tasks, artifacts, and benchmark snapshots.`,
);
if (!ok) return;
setDeleteBusy(true);
try {
await deleteMission(mission.id);
onDeleted?.();
onChanged();
} catch (e) {
setError(e instanceof Error ? e.message : "delete failed");
} finally {
setDeleteBusy(false);
}
}}
disabled={deleteBusy}
title="Delete this mission"
aria-label="Delete mission"
style={{
...iconBtn,
color: "#ff8a7a",
borderColor: "rgba(255,138,122,.35)",
opacity: deleteBusy ? 0.5 : 1,
}}
>
<Trash2 size={13} />
</button>
{mission.status === "draft" && ( {mission.status === "draft" && (
<button <button
type="button" type="button"
@@ -355,6 +416,27 @@ export function MissionCanvas({
onUndoAfterAccept={undoRefine} onUndoAfterAccept={undoRefine}
/> />
)} )}
{wizardOpen && (
<MissionWizard
onClose={() => setWizardOpen(false)}
onCreated={(id) => {
setWizardOpen(false);
onSelect?.(id);
onChanged();
}}
/>
)}
{editOpen && (
<EditMissionModal
mission={mission}
onClose={() => setEditOpen(false)}
onSaved={async () => {
setEditOpen(false);
onChanged();
await load();
}}
/>
)}
<div style={{ display: "flex", gap: 4, marginTop: 4 }}> <div style={{ display: "flex", gap: 4, marginTop: 4 }}>
{(["overview", "phases", "tasks", "artifacts", "benchmarks"] as Tab[]).map((t) => { {(["overview", "phases", "tasks", "artifacts", "benchmarks"] as Tab[]).map((t) => {
const active = tab === t; const active = tab === t;
@@ -888,6 +970,205 @@ 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({ function RefineDiffModal({
original, original,
refined, refined,
+14
View File
@@ -203,6 +203,20 @@ export interface RefineResult {
export const refineMission = (id: string) => export const refineMission = (id: string) =>
api<RefineResult>(`/api/missions/${id}/refine`, { method: "POST" }); api<RefineResult>(`/api/missions/${id}/refine`, { method: "POST" });
export interface UpdateMissionMetaRequest {
title?: string;
description?: string;
}
export const updateMission = (id: string, patch: UpdateMissionMetaRequest) =>
api<Mission>(`/api/missions/${id}`, {
method: "PATCH",
body: JSON.stringify(patch),
});
export const deleteMission = (id: string) =>
api<{ deleted: boolean }>(`/api/missions/${id}`, { method: "DELETE" });
export const setMissionDescription = (id: string, description: string) => export const setMissionDescription = (id: string, description: string) =>
api<Mission>(`/api/missions/${id}/description`, { api<Mission>(`/api/missions/${id}/description`, {
method: "PATCH", method: "PATCH",