research: inline Approve / Reject on the topic canvas
ci / gates (push) Successful in 15s
ci / frontend (push) Successful in 43s
ci / rust (push) Successful in 2m44s
ci / e2e (push) Has been skipped
ci / publish (push) Successful in 47s

The prior "Publish approval pending" pill was a dead-end: it told
users an approver had to sign off but gave them nowhere to do it.
No approvals inbox surface existed, so the topic sat in reviewing
indefinitely.

Backend already allows any workspace member to decide
(decide_publish has no role gate). Wire the inline UX:

- ResearchCanvas fetches /api/research/publish-approvals when the
  topic's has_pending_publish_request flag is true, filters to the
  match for the current topic, and stashes it as pendingApproval.
- The amber pill grows two buttons — green "Approve & publish"
  (POST /publish-approvals/{id}/approve → topic goes to
  publishing, published_at gets stamped) and a bordered "Reject"
  (POST /publish-approvals/{id}/reject → topic stays in reviewing,
  requester can request again). Both disable + relabel while the
  request is in flight.
- A "Requested at <timestamp>" line under the pill so approvers
  can see how long it's been pending.
- boundApproval derives from pendingApproval only when the loaded
  approval's topic_id matches the current topic — protects
  against a stale approval leaking in during a topic switch.
- setPendingApproval uses a functional updater that returns the
  previous reference when the fetch produced an identical row,
  satisfying react-hooks/set-state-in-effect.
This commit is contained in:
Omar Sobh
2026-07-08 21:24:05 -07:00
parent 036d64ee5d
commit 1a4c5eb159
@@ -8,10 +8,14 @@ import { useEffect, useState } from "react";
import type { Agent } from "@/lib/api/schemas";
import {
approvePublish,
getTopic,
listPendingApprovals,
rejectPublish,
requestPublish,
startTopic,
submitReview,
type PublishApproval,
type TopicDetail,
type TopicStatus,
} from "@/lib/api/research";
@@ -86,6 +90,8 @@ export function ResearchCanvas({
onChanged: () => void;
}) {
const [topic, setTopic] = useState<TopicDetail | null>(null);
const [pendingApproval, setPendingApproval] = useState<PublishApproval | null>(null);
const [decidingApproval, setDecidingApproval] = useState<"approve" | "reject" | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [acting, setActing] = useState(false);
@@ -140,6 +146,35 @@ export function ResearchCanvas({
};
}, [selectedId, topic?.status, topic?.runs_in_flight]);
// Lookup the pending approval that belongs to this topic (if any) so we
// can offer inline Approve/Reject buttons — no separate inbox page needed.
// Only fetches when the backend flag says an approval exists to avoid
// hammering the endpoint on every topic view.
const wantsApprovalLookup =
!!topic && topic.has_pending_publish_request === true;
useEffect(() => {
if (!wantsApprovalLookup) return;
let alive = true;
(async () => {
try {
const rows = await listPendingApprovals();
if (!alive) return;
const match = rows.find((r) => r.topic_id === selectedId) ?? null;
// Skip identical writes so React doesn't schedule a wasted render
// (also silences react-hooks/set-state-in-effect).
setPendingApproval((prev) => {
if (prev?.id === match?.id) return prev;
return match;
});
} catch {
/* keep previous value on transient failure */
}
})();
return () => {
alive = false;
};
}, [selectedId, wantsApprovalLookup]);
if (!selectedId) {
return <Placeholder />;
}
@@ -166,6 +201,27 @@ export function ResearchCanvas({
(topic.status === "processing" || topic.status === "publishing") &&
topic.runs_in_flight > 0;
const agentById = new Map(agents.map((a) => [a.id, a]));
// Guard against a stale approval from a previously-selected topic: only
// consider one that actually belongs to the current topic.
const boundApproval =
pendingApproval && pendingApproval.topic_id === topic.id
? pendingApproval
: null;
async function decide(kind: "approve" | "reject") {
if (!boundApproval || decidingApproval) return;
setDecidingApproval(kind);
setError(null);
try {
if (kind === "approve") await approvePublish(boundApproval.id);
else await rejectPublish(boundApproval.id);
onChanged();
} catch (e) {
setError(e instanceof Error ? e.message : "decision failed");
} finally {
setDecidingApproval(null);
}
}
async function runAction() {
if (!action) return;
@@ -462,19 +518,91 @@ export function ResearchCanvas({
) : topic.status === "reviewing" && topic.has_pending_publish_request ? (
<div
style={{
padding: "10px 14px",
padding: "12px 14px",
borderRadius: 10,
border: "1px solid rgba(255,180,74,.35)",
background: "rgba(255,180,74,.06)",
display: "flex",
alignItems: "center",
gap: 8,
flexDirection: "column",
gap: 10,
fontSize: 12.5,
color: "#ffb44a",
}}
>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#ffb44a" }} />
Awaiting reviewer approval. A workspace approver needs to sign off before publish.
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: "#ffb44a",
}}
/>
<span style={{ fontWeight: 700 }}>
Publish approval pending
</span>
</div>
<div style={{ fontFamily: mono, fontSize: 11, color: "#cfcfd5", lineHeight: 1.55 }}>
Any workspace member can decide.{" "}
{boundApproval ? (
<>
Requested{" "}
{new Date(boundApproval.created_at).toLocaleString()}.
</>
) : (
"Loading approval…"
)}
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<button
type="button"
onClick={() => decide("approve")}
disabled={!boundApproval || decidingApproval !== null}
style={{
padding: "9px 16px",
borderRadius: 8,
border: 0,
background: "#5fd08a",
color: "#06140c",
fontSize: 13,
fontWeight: 700,
cursor:
boundApproval && decidingApproval === null
? "pointer"
: "default",
opacity:
!boundApproval || decidingApproval !== null
? 0.55
: 1,
}}
>
{decidingApproval === "approve" ? "Approving…" : "Approve & publish"}
</button>
<button
type="button"
onClick={() => decide("reject")}
disabled={!boundApproval || decidingApproval !== null}
style={{
padding: "9px 16px",
borderRadius: 8,
border: "1px solid rgba(255,138,122,.5)",
background: "transparent",
color: "#ff8a7a",
fontSize: 13,
fontWeight: 700,
cursor:
boundApproval && decidingApproval === null
? "pointer"
: "default",
opacity:
!boundApproval || decidingApproval !== null
? 0.55
: 1,
}}
>
{decidingApproval === "reject" ? "Rejecting…" : "Reject"}
</button>
</div>
</div>
) : null}
</div>