research: pending-approvals inbox in the sidebar (R3)
ci / gates (push) Successful in 6s
ci / rust (push) Failing after 10s
ci / frontend (push) Successful in 28s
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

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:
Omar Sobh
2026-07-09 15:57:45 -07:00
parent e3011ed025
commit 1896b78cb7
@@ -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