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
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM research_topics WHERE id = $1 AND workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": []
},
"hash": "60c65d67613db839401ebab66eb28710e292d7bec1debef316634ef735bc48ff"
}
+3 -1
View File
@@ -385,7 +385,9 @@ pub fn router(state: AppState) -> Router {
) )
.route( .route(
"/api/research/{id}", "/api/research/{id}",
get(routes::research::get_topic).patch(routes::research::patch_topic), get(routes::research::get_topic)
.patch(routes::research::patch_topic)
.delete(routes::research::delete_topic),
) )
.route( .route(
"/api/research/{id}/agents", "/api/research/{id}/agents",
+14
View File
@@ -324,6 +324,20 @@ pub async fn attach_agent(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
/// `DELETE /api/research/:id` — hard-delete a topic and cascade every
/// dependent row. FK cascades on research_topic_agents,
/// research_publish_approvals, and research_outcomes; topology_runs's
/// research_topic_id back-ref is SET NULL so historical runs survive.
/// Returns 204 whether the topic existed or not (idempotent).
pub async fn delete_topic(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
cm_db::repo::research_topics::delete(&state.pool, id, user.workspace_id.as_uuid()).await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn detach_agent( pub async fn detach_agent(
State(state): State<AppState>, State(state): State<AppState>,
Authed(user): Authed, Authed(user): Authed,
+15
View File
@@ -98,6 +98,21 @@ pub async fn get(
Ok(row) Ok(row)
} }
/// Hard-delete a topic and cascade every dependent row. FK cascades on
/// research_topic_agents, research_publish_approvals, and research_outcomes
/// clean themselves up; topology_runs.research_topic_id is SET NULL so
/// historical runs survive with the back-ref cleared.
pub async fn delete(pool: &PgPool, id: Uuid, workspace_id: Uuid) -> Result<(), DbError> {
sqlx::query!(
"DELETE FROM research_topics WHERE id = $1 AND workspace_id = $2",
id,
workspace_id,
)
.execute(pool)
.await?;
Ok(())
}
/// Non-status fields; the state machine transitions are their own endpoints. /// Non-status fields; the state machine transitions are their own endpoints.
pub async fn update_fields( pub async fn update_fields(
pool: &PgPool, pool: &PgPool,
@@ -1,12 +1,16 @@
"use client"; "use client";
// Sidebar for the Research tier — real topic list, +New opens the wizard. // 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 { useEffect, useState } from "react";
import { Plus } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
import { import {
deleteTopic,
listTopics, listTopics,
type TopicListItem, type TopicListItem,
type TopicStatus, type TopicStatus,
@@ -42,6 +46,10 @@ export function ResearchList({
const [topics, setTopics] = useState<TopicListItem[]>([]); const [topics, setTopics] = useState<TopicListItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [wizardOpen, setWizardOpen] = useState(false); 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(() => { useEffect(() => {
let alive = true; let alive = true;
@@ -60,7 +68,23 @@ export function ResearchList({
return () => { return () => {
alive = false; 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 ( return (
<> <>
@@ -156,25 +180,32 @@ export function ResearchList({
topics.map((t) => { topics.map((t) => {
const on = t.id === selectedId; const on = t.id === selectedId;
const dot = STATUS_COLOR[t.status]; const dot = STATUS_COLOR[t.status];
const confirming = confirmDeleteId === t.id;
return ( return (
<button <div
key={t.id} key={t.id}
type="button"
onClick={() => onSelect(t.id)}
style={{ style={{
width: "100%",
textAlign: "left",
padding: "10px 12px", padding: "10px 12px",
marginBottom: 4, marginBottom: 4,
borderRadius: 9, borderRadius: 9,
border: `1px solid ${on ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.06)"}`, border: `1px solid ${on ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.06)"}`,
background: on ? "rgba(255,111,97,.08)" : "#101014", background: on ? "rgba(255,111,97,.08)" : "#101014",
color: "#eaeaee",
cursor: "pointer",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: 6, gap: 6,
}} }}
>
<button
type="button"
onClick={() => onSelect(t.id)}
style={{
all: "unset",
cursor: "pointer",
color: "#eaeaee",
display: "flex",
flexDirection: "column",
gap: 4,
}}
> >
<div <div
style={{ style={{
@@ -210,9 +241,94 @@ export function ResearchList({
<span>{t.outcome_kind.replace("_", " ")}</span> <span>{t.outcome_kind.replace("_", " ")}</span>
</div> </div>
</button> </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> </div>
{wizardOpen && ( {wizardOpen && (
<ResearchWizard <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",
};
+3
View File
@@ -114,6 +114,9 @@ export const attachAgent = (
export const detachAgent = (topicId: string, agentId: string) => export const detachAgent = (topicId: string, agentId: string) =>
api<void>(`/api/research/${topicId}/agents/${agentId}`, { method: "DELETE" }); api<void>(`/api/research/${topicId}/agents/${agentId}`, { method: "DELETE" });
export const deleteTopic = (id: string) =>
api<void>(`/api/research/${id}`, { method: "DELETE" });
export const startTopic = (id: string) => export const startTopic = (id: string) =>
api<void>(`/api/research/${id}/start`, { method: "POST" }); api<void>(`/api/research/${id}/start`, { method: "POST" });