research: inline Approve / Reject on the topic canvas
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user