research sidebar: delete-with-confirm per topic
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:
@@ -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",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user