missions sidebar: wrench toggle + multi-select bulk delete
ci / gates (push) Failing after 5s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped

Matches the AGENT tier's manage-mode pattern. Three states on the
sidebar toolbar:

  - default: [Wrench] [Refresh] [+]
  - select mode active: [Wrench] (highlighted) [Refresh] [+] + rows
    grow a checkbox on the left and click-toggles selection instead
    of opening the mission
  - selection made: [Wrench] [Trash · N] [Refresh] [+] where the
    trash pill shows the count and fires window.confirm → bulk
    DELETE /api/missions/{id} loop

On success: exits select mode, calls onDeleted with the ids, parent
Dashboard clears missionsSel if it was in the batch. Failures stay
selected + a red error line surfaces the count.

Reuses the CRUD backend added earlier (a1c… PATCH/DELETE) — no new
server work.
This commit is contained in:
Omar Sobh
2026-07-20 11:46:24 -07:00
parent a2bc06d313
commit d4efb0dba2
2 changed files with 132 additions and 5 deletions
@@ -6,9 +6,10 @@
// UX without breaking existing workflows.
import { useCallback, useEffect, useState } from "react";
import { Plus, RotateCw } from "lucide-react";
import { Plus, RotateCw, Trash2, Wrench } from "lucide-react";
import {
deleteMission,
listMissions,
type Mission,
type MissionStatus,
@@ -41,16 +42,23 @@ export function MissionsList({
onSelect,
refreshKey,
onCreated,
onDeleted,
}: {
selectedId: string | null;
onSelect: (id: string) => void;
refreshKey: number;
onCreated: (id: string) => void;
/** Called after bulk delete completes; parent clears selection if the
* currently-open mission was among the deleted rows. */
onDeleted?: (deletedIds: string[]) => void;
}) {
const [missions, setMissions] = useState<Mission[]>([]);
const [loading, setLoading] = useState(true);
const [wizardOpen, setWizardOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectMode, setSelectMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false);
const load = useCallback(async () => {
setError(null);
@@ -97,6 +105,77 @@ export function MissionsList({
MISSIONS · {missions.length}
</span>
<div style={{ display: "flex", gap: 6 }}>
<button
type="button"
onClick={() => {
setSelectMode((v) => {
if (v) setSelectedIds(new Set());
return !v;
});
}}
title={selectMode ? "Exit select mode" : "Select missions to manage"}
aria-label="Toggle select mode"
style={{
...iconBtn,
color: selectMode ? "#ff8a7a" : "#9a9aa2",
borderColor: selectMode
? "rgba(255,138,122,.5)"
: "rgba(255,255,255,.12)",
background: selectMode ? "rgba(255,138,122,.08)" : "transparent",
}}
>
<Wrench size={13} />
</button>
{selectMode && selectedIds.size > 0 && (
<button
type="button"
onClick={async () => {
if (bulkBusy) return;
const ids = Array.from(selectedIds);
const ok = window.confirm(
`Delete ${ids.length} mission${ids.length === 1 ? "" : "s"}? Cascades to phases, tasks, artifacts, and benchmark snapshots.`,
);
if (!ok) return;
setBulkBusy(true);
setError(null);
const failures: string[] = [];
for (const id of ids) {
try {
await deleteMission(id);
} catch (e) {
failures.push(id);
console.error(`delete ${id}`, e);
}
}
setBulkBusy(false);
const deletedIds = ids.filter((id) => !failures.includes(id));
setSelectedIds(new Set(failures));
if (failures.length === 0) setSelectMode(false);
else
setError(`${failures.length} of ${ids.length} deletes failed`);
onDeleted?.(deletedIds);
await load();
}}
disabled={bulkBusy}
title={`Delete ${selectedIds.size} selected`}
aria-label="Delete selected missions"
style={{
...iconBtn,
color: "#ff8a7a",
borderColor: "rgba(255,138,122,.5)",
background: "rgba(255,138,122,.1)",
opacity: bulkBusy ? 0.5 : 1,
width: "auto",
padding: "0 8px",
gap: 4,
fontFamily: mono,
fontSize: 11,
}}
>
<Trash2 size={12} />
{selectedIds.size}
</button>
)}
<button
type="button"
onClick={load}
@@ -162,13 +241,25 @@ export function MissionsList({
</div>
) : (
missions.map((m) => {
const active = selectedId === m.id;
const active = !selectMode && selectedId === m.id;
const picked = selectMode && selectedIds.has(m.id);
const badge = TEMPLATE_BADGE[m.template_kind] ?? TEMPLATE_BADGE.custom;
return (
<button
key={m.id}
type="button"
onClick={() => onSelect(m.id)}
onClick={() => {
if (selectMode) {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(m.id)) next.delete(m.id);
else next.add(m.id);
return next;
});
} else {
onSelect(m.id);
}
}}
style={{
width: "100%",
textAlign: "left",
@@ -177,13 +268,43 @@ export function MissionsList({
gap: 4,
padding: "10px 11px",
borderRadius: 9,
border: `1px solid ${active ? "rgba(94,200,216,.5)" : "rgba(255,255,255,.07)"}`,
background: active ? "rgba(94,200,216,.08)" : "#101013",
border: `1px solid ${
picked
? "rgba(255,138,122,.55)"
: active
? "rgba(94,200,216,.5)"
: "rgba(255,255,255,.07)"
}`,
background: picked
? "rgba(255,138,122,.1)"
: active
? "rgba(94,200,216,.08)"
: "#101013",
cursor: "pointer",
color: "#eaeaee",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
{selectMode && (
<span
aria-hidden
style={{
width: 12,
height: 12,
borderRadius: 3,
border: `1px solid ${picked ? "#ff8a7a" : "rgba(255,255,255,.25)"}`,
background: picked ? "#ff8a7a" : "transparent",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
color: "#101013",
fontSize: 10,
flex: "none",
}}
>
{picked ? "✓" : ""}
</span>
)}
<span
style={{
width: 7,