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:
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user