missions sidebar: wrench toggle + multi-select bulk delete
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:
@@ -731,6 +731,12 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
setMissionsSel(id);
|
setMissionsSel(id);
|
||||||
setMissionsRefresh((n) => n + 1);
|
setMissionsRefresh((n) => n + 1);
|
||||||
}}
|
}}
|
||||||
|
onDeleted={(ids) => {
|
||||||
|
if (missionsSel && ids.includes(missionsSel)) {
|
||||||
|
setMissionsSel(null);
|
||||||
|
}
|
||||||
|
setMissionsRefresh((n) => n + 1);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -6,9 +6,10 @@
|
|||||||
// UX without breaking existing workflows.
|
// UX without breaking existing workflows.
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Plus, RotateCw } from "lucide-react";
|
import { Plus, RotateCw, Trash2, Wrench } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
deleteMission,
|
||||||
listMissions,
|
listMissions,
|
||||||
type Mission,
|
type Mission,
|
||||||
type MissionStatus,
|
type MissionStatus,
|
||||||
@@ -41,16 +42,23 @@ export function MissionsList({
|
|||||||
onSelect,
|
onSelect,
|
||||||
refreshKey,
|
refreshKey,
|
||||||
onCreated,
|
onCreated,
|
||||||
|
onDeleted,
|
||||||
}: {
|
}: {
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
refreshKey: number;
|
refreshKey: number;
|
||||||
onCreated: (id: string) => void;
|
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 [missions, setMissions] = useState<Mission[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -97,6 +105,77 @@ export function MissionsList({
|
|||||||
MISSIONS · {missions.length}
|
MISSIONS · {missions.length}
|
||||||
</span>
|
</span>
|
||||||
<div style={{ display: "flex", gap: 6 }}>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={load}
|
onClick={load}
|
||||||
@@ -162,13 +241,25 @@ export function MissionsList({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
missions.map((m) => {
|
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;
|
const badge = TEMPLATE_BADGE[m.template_kind] ?? TEMPLATE_BADGE.custom;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
key={m.id}
|
||||||
type="button"
|
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={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
@@ -177,13 +268,43 @@ export function MissionsList({
|
|||||||
gap: 4,
|
gap: 4,
|
||||||
padding: "10px 11px",
|
padding: "10px 11px",
|
||||||
borderRadius: 9,
|
borderRadius: 9,
|
||||||
border: `1px solid ${active ? "rgba(94,200,216,.5)" : "rgba(255,255,255,.07)"}`,
|
border: `1px solid ${
|
||||||
background: active ? "rgba(94,200,216,.08)" : "#101013",
|
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",
|
cursor: "pointer",
|
||||||
color: "#eaeaee",
|
color: "#eaeaee",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
<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
|
<span
|
||||||
style={{
|
style={{
|
||||||
width: 7,
|
width: 7,
|
||||||
|
|||||||
Reference in New Issue
Block a user