research sidebar: delete-with-confirm per topic
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 30s
ci / rust (push) Successful in 2m38s
ci / e2e (push) Has been skipped
ci / publish (push) Successful in 2m40s

Mirror the row-level delete affordance the loops sidebar already
has. Loops was wired earlier; research had a bare title-only card
with no way to remove a stale topic.

- cm-db: research_topics::delete cascades via existing FK rules
  (research_topic_agents, research_publish_approvals, and the new
  research_outcomes all CASCADE on topic_id; topology_runs's
  research_topic_id back-ref is SET NULL so historical runs stay).
- cm-api: DELETE /api/research/{id} → 204. Idempotent.
- Frontend: deleteTopic helper. ResearchList row is now a card
  with the existing title/status/outcome header plus a trash icon
  that flips the card into an inline "Delete topic + all outcomes?"
  confirm strip. Confirm → red Delete / gray Cancel. If the
  deleted topic was selected, selection clears; local counter
  bumps the list refetch without waiting on a parent.
This commit is contained in:
Omar Sobh
2026-07-08 17:21:51 -07:00
parent a2d3d85ebe
commit 316cdbf929
6 changed files with 224 additions and 38 deletions
@@ -1,12 +1,16 @@
"use client";
// Sidebar for the Research tier — real topic list, +New opens the wizard.
// Each row also carries a delete affordance with inline confirm; delete is
// hard on the backend (research_topics DELETE cascades to agents/approvals/
// outcomes; topology_runs.research_topic_id is SET NULL).
import { useEffect, useState } from "react";
import { Plus } from "lucide-react";
import { Plus, Trash2 } from "lucide-react";
import type { Agent } from "@/lib/api/schemas";
import {
deleteTopic,
listTopics,
type TopicListItem,
type TopicStatus,
@@ -42,6 +46,10 @@ export function ResearchList({
const [topics, setTopics] = useState<TopicListItem[]>([]);
const [loading, setLoading] = useState(true);
const [wizardOpen, setWizardOpen] = useState(false);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [localBump, setLocalBump] = useState(0);
useEffect(() => {
let alive = true;
@@ -60,7 +68,23 @@ export function ResearchList({
return () => {
alive = false;
};
}, [refreshKey]);
}, [refreshKey, localBump]);
async function doDelete(id: string) {
if (busyId) return;
setBusyId(id);
setError(null);
try {
await deleteTopic(id);
setConfirmDeleteId(null);
if (selectedId === id) onSelect("");
setLocalBump((n) => n + 1);
} catch (e) {
setError(e instanceof Error ? e.message : "delete failed");
} finally {
setBusyId(null);
}
}
return (
<>
@@ -156,63 +180,155 @@ export function ResearchList({
topics.map((t) => {
const on = t.id === selectedId;
const dot = STATUS_COLOR[t.status];
const confirming = confirmDeleteId === t.id;
return (
<button
<div
key={t.id}
type="button"
onClick={() => onSelect(t.id)}
style={{
width: "100%",
textAlign: "left",
padding: "10px 12px",
marginBottom: 4,
borderRadius: 9,
border: `1px solid ${on ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.06)"}`,
background: on ? "rgba(255,111,97,.08)" : "#101014",
color: "#eaeaee",
cursor: "pointer",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<div
style={{
fontSize: 13.5,
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{t.title}
</div>
<div
<button
type="button"
onClick={() => onSelect(t.id)}
style={{
all: "unset",
cursor: "pointer",
color: "#eaeaee",
display: "flex",
alignItems: "center",
gap: 6,
fontFamily: mono,
fontSize: 10,
color: "#8a8a92",
flexDirection: "column",
gap: 4,
}}
>
<span
<div
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: dot,
fontSize: 13.5,
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
/>
<span style={{ color: dot }}>{t.status}</span>
<span style={{ opacity: 0.5 }}>·</span>
<span>{t.outcome_kind.replace("_", " ")}</span>
</div>
</button>
>
{t.title}
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
fontFamily: mono,
fontSize: 10,
color: "#8a8a92",
}}
>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: dot,
}}
/>
<span style={{ color: dot }}>{t.status}</span>
<span style={{ opacity: 0.5 }}>·</span>
<span>{t.outcome_kind.replace("_", " ")}</span>
</div>
</button>
{confirming ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
marginTop: 4,
}}
>
<span
style={{
fontFamily: mono,
fontSize: 10.5,
color: "#ff8a7a",
flex: 1,
}}
>
Delete topic + all outcomes?
</span>
<button
type="button"
onClick={() => doDelete(t.id)}
disabled={busyId === t.id}
style={{
...dangerBtn,
opacity: busyId === t.id ? 0.6 : 1,
}}
>
{busyId === t.id ? "…" : "Delete"}
</button>
<button
type="button"
onClick={() => setConfirmDeleteId(null)}
style={ghostBtn}
>
Cancel
</button>
</div>
) : (
<div
style={{
display: "flex",
alignItems: "center",
gap: 4,
marginTop: 2,
}}
>
<button
type="button"
title="Delete topic"
aria-label="Delete topic"
onClick={() => setConfirmDeleteId(t.id)}
disabled={busyId === t.id}
style={{
width: 24,
height: 24,
borderRadius: 6,
border: "1px solid rgba(255,255,255,.08)",
background: "transparent",
color: "#ff8a7a",
cursor: busyId === t.id ? "not-allowed" : "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
opacity: busyId === t.id ? 0.5 : 1,
}}
>
<Trash2 aria-hidden size={12} />
</button>
</div>
)}
</div>
);
})
)}
{error ? (
<p
style={{
fontFamily: mono,
fontSize: 11,
color: "#ff8a7a",
padding: "8px 12px",
}}
>
{error}
</p>
) : null}
</div>
{wizardOpen && (
<ResearchWizard
@@ -227,3 +343,24 @@ export function ResearchList({
</>
);
}
const dangerBtn: React.CSSProperties = {
padding: "4px 10px",
borderRadius: 6,
border: 0,
background: "linear-gradient(135deg,#ff8a7a,#ff5f57)",
color: "#2a0d0a",
fontSize: 11,
fontWeight: 700,
cursor: "pointer",
};
const ghostBtn: React.CSSProperties = {
padding: "4px 10px",
borderRadius: 6,
border: "1px solid rgba(255,255,255,.14)",
background: "transparent",
color: "#cfcfd5",
fontSize: 11,
fontWeight: 600,
cursor: "pointer",
};