"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, Trash2 } from "lucide-react"; import type { Agent } from "@/lib/api/schemas"; import { deleteTopic, listTopics, type TopicListItem, type TopicStatus, } from "@/lib/api/research"; import { ResearchWizard } from "./ResearchWizard"; const mono = "ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace"; const STATUS_COLOR: Record = { standby: "#8a8a92", processing: "#5ec8d8", reviewing: "#ffb44a", publishing: "#c98af0", published: "#5fd08a", }; export function ResearchList({ agents, selectedId, onSelect, refreshKey, onCreated, }: { agents: Agent[]; selectedId: string | null; onSelect: (id: string) => void; /** Bump to force a re-fetch after external mutations (start/publish/etc). */ refreshKey: number; onCreated: (id: string) => void; }) { const [topics, setTopics] = useState([]); const [loading, setLoading] = useState(true); const [wizardOpen, setWizardOpen] = useState(false); const [confirmDeleteId, setConfirmDeleteId] = useState(null); const [busyId, setBusyId] = useState(null); const [error, setError] = useState(null); const [localBump, setLocalBump] = useState(0); useEffect(() => { let alive = true; const load = async () => { setLoading(true); try { const rows = await listTopics(); if (alive) setTopics(rows); } catch { if (alive) setTopics([]); } finally { if (alive) setLoading(false); } }; load(); return () => { alive = false; }; }, [refreshKey, localBump]); // Light polling while any topic is actively running so the spinner + status // in the sidebar transition automatically without waiting on a parent bump. // Depending only on the boolean prevents the effect from thrashing every // time the polled response substitutes a fresh array. const anyActive = topics.some( (t) => t.status === "processing" || t.status === "publishing", ); useEffect(() => { if (!anyActive) return; let alive = true; const tick = async () => { try { const rows = await listTopics(); if (alive) setTopics(rows); } catch { /* transient — keep the last snapshot */ } }; const handle = setInterval(tick, 6000); return () => { alive = false; clearInterval(handle); }; }, [anyActive]); 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 ( <>
{topics.length} TOPIC{topics.length === 1 ? "" : "S"}
Research
{loading && topics.length === 0 ? (

Loading…

) : topics.length === 0 ? (

No topics yet. Hit the + button to launch the wizard.

) : ( topics.map((t) => { const on = t.id === selectedId; const dot = STATUS_COLOR[t.status]; const confirming = confirmDeleteId === t.id; return (
{confirming ? (
Delete topic + all outcomes?
) : (
)}
); }) )} {error ? (

{error}

) : null}
{wizardOpen && ( setWizardOpen(false)} onCreated={(id) => { setWizardOpen(false); onCreated(id); }} /> )} ); } function MiniSpinner({ color }: { color: string }) { return ( ); } 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", };