research: pending-approvals inbox in the sidebar (R3)
Before: reviewers only saw a topic was awaiting them if they navigated directly to it — the approval CTA lived inside ResearchCanvas. If someone had 5 topics reviewing, they had to click through each to find which were staffed for their signoff. Adds a compact inbox strip at the top of the Research sidebar (right below the header, above the topic list). Only shown when there's at least one pending publish approval in the workspace: - Amber pill: "<count> PENDING REVIEW(s)" with a badge, click to expand. - Expanded state: one row per pending approval, showing the topic title (looked up from the already-fetched topics list) and a "review →" affordance. Row click selects the topic (fires onSelect with the topic_id) so the reviewer lands on the ResearchCanvas with the approve/reject controls surfaced. - Load piggybacks on the existing list-fetch effect via Promise.all([listTopics, listPendingApprovals]). Refreshes on the same bump + polling triggers, so approvals appear/disappear as people request/decide them. Fetch failure for approvals silently degrades to an empty list — the sidebar still renders the topic list normally. Follow-up: filter by reviewer role (currently shows all approvals in the workspace); notification badge on the top nav so the inbox is discoverable even when someone is elsewhere in the app.
This commit is contained in:
@@ -11,7 +11,9 @@ import { Download, Plus, Trash2 } from "lucide-react";
|
||||
import type { Agent } from "@/lib/api/schemas";
|
||||
import {
|
||||
deleteTopic,
|
||||
listPendingApprovals,
|
||||
listTopics,
|
||||
type PublishApproval,
|
||||
type TopicListItem,
|
||||
type TopicStatus,
|
||||
} from "@/lib/api/research";
|
||||
@@ -50,14 +52,22 @@ export function ResearchList({
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [localBump, setLocalBump] = useState(0);
|
||||
const [approvals, setApprovals] = useState<PublishApproval[]>([]);
|
||||
const [inboxOpen, setInboxOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const rows = await listTopics();
|
||||
if (alive) setTopics(rows);
|
||||
const [topicsRows, approvalRows] = await Promise.all([
|
||||
listTopics(),
|
||||
listPendingApprovals().catch(() => [] as PublishApproval[]),
|
||||
]);
|
||||
if (alive) {
|
||||
setTopics(topicsRows);
|
||||
setApprovals(approvalRows);
|
||||
}
|
||||
} catch {
|
||||
if (alive) setTopics([]);
|
||||
} finally {
|
||||
@@ -173,6 +183,89 @@ export function ResearchList({
|
||||
<Plus aria-hidden size={17} />
|
||||
</button>
|
||||
</div>
|
||||
{approvals.length > 0 ? (
|
||||
<div style={{ padding: "0 12px 8px" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInboxOpen((v) => !v)}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(255,180,74,.35)",
|
||||
background: "rgba(255,180,74,.08)",
|
||||
color: "#ffb44a",
|
||||
fontFamily: mono,
|
||||
fontSize: 11,
|
||||
letterSpacing: ".05em",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
}}
|
||||
aria-expanded={inboxOpen}
|
||||
title="Pending publish approvals in this workspace"
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
minWidth: 20,
|
||||
height: 18,
|
||||
padding: "0 6px",
|
||||
borderRadius: 999,
|
||||
background: "#ffb44a",
|
||||
color: "#1c1408",
|
||||
fontWeight: 700,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{approvals.length}
|
||||
</span>
|
||||
<span style={{ flex: 1 }}>
|
||||
PENDING REVIEW{approvals.length === 1 ? "" : "S"}
|
||||
</span>
|
||||
<span style={{ opacity: 0.7 }}>{inboxOpen ? "▾" : "▸"}</span>
|
||||
</button>
|
||||
{inboxOpen ? (
|
||||
<div style={{ marginTop: 6, display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
{approvals.map((a) => {
|
||||
const t = topics.find((tt) => tt.id === a.topic_id);
|
||||
return (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
onClick={() => { onSelect(a.topic_id); setInboxOpen(false); }}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "7px 10px",
|
||||
borderRadius: 7,
|
||||
border: "1px solid rgba(255,255,255,.06)",
|
||||
background: "#101014",
|
||||
color: "#eaeaee",
|
||||
fontSize: 12,
|
||||
textAlign: "left",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
title={`Requested ${new Date(a.created_at).toLocaleString()}`}
|
||||
>
|
||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "#ffb44a" }} />
|
||||
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{t?.title ?? a.topic_id.slice(0, 8)}
|
||||
</span>
|
||||
<span style={{ fontFamily: mono, fontSize: 9, color: "#8a8a92" }}>
|
||||
review →
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "8px" }}>
|
||||
{loading && topics.length === 0 ? (
|
||||
<p
|
||||
|
||||
Reference in New Issue
Block a user