research: reject-with-revision loop (R2)
Before: reviewer rejected a publish → audit log flipped, topic stayed in reviewing, no way to feed the critique back into the run pipeline. Reviewers with revision notes had to eat them or hand-message the coordinator. Now: reject accepts an optional `notes` field. When present: - Persisted on the research_publish_approvals row (migration 0039). - Topic flips `reviewing → standby` so the next `start_topic` is legal. - `start_topic` reads the most recent rejected-approval notes for the topic and prepends "PRIOR REVIEW NOTES (address these in this revision):\n<notes>\n---" to the coordinator task. Loop closes through the same run pipeline — no new spawn code path, which means the reviewer's guidance flows through the same topology_worker, run_events, outcome-writer chain and lands as a fresh research_outcomes row (versioned, prior drafts preserved). No notes on reject = legacy behavior (topic stays in reviewing, publish requests still allowed). Migration 0039 adds nullable `notes TEXT` to research_publish_approvals. `decide()` gains a `notes: Option<&str>` parameter (only one caller, updated inline). New `latest_rejection_notes(pool, topic_id)` helper for start_topic. Frontend: - rejectPublish(id, notes?) now sends a JSON body when notes are provided. - ResearchCanvas reject button opens an inline form with a textarea + Cancel/"Send back for revision" pair. Empty notes → plain reject. - Button label switches: "Send back for revision" when notes present, "Reject without notes" when empty. Follow-up: - Notes shown in the review UI on the resulting draft so the next reviewer sees what changed. - Multiple rejection rounds — currently only the LATEST rejection's notes surface. Accumulating history is a schema-only tweak.
This commit is contained in:
@@ -92,6 +92,8 @@ export function ResearchCanvas({
|
||||
const [topic, setTopic] = useState<TopicDetail | null>(null);
|
||||
const [pendingApproval, setPendingApproval] = useState<PublishApproval | null>(null);
|
||||
const [decidingApproval, setDecidingApproval] = useState<"approve" | "reject" | null>(null);
|
||||
const [rejectFormOpen, setRejectFormOpen] = useState(false);
|
||||
const [rejectNotes, setRejectNotes] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [acting, setActing] = useState(false);
|
||||
@@ -208,13 +210,15 @@ export function ResearchCanvas({
|
||||
? pendingApproval
|
||||
: null;
|
||||
|
||||
async function decide(kind: "approve" | "reject") {
|
||||
async function decide(kind: "approve" | "reject", notes?: string) {
|
||||
if (!boundApproval || decidingApproval) return;
|
||||
setDecidingApproval(kind);
|
||||
setError(null);
|
||||
try {
|
||||
if (kind === "approve") await approvePublish(boundApproval.id);
|
||||
else await rejectPublish(boundApproval.id);
|
||||
else await rejectPublish(boundApproval.id, notes);
|
||||
setRejectFormOpen(false);
|
||||
setRejectNotes("");
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "decision failed");
|
||||
@@ -580,8 +584,8 @@ export function ResearchCanvas({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => decide("reject")}
|
||||
disabled={!boundApproval || decidingApproval !== null}
|
||||
onClick={() => setRejectFormOpen(true)}
|
||||
disabled={!boundApproval || decidingApproval !== null || rejectFormOpen}
|
||||
style={{
|
||||
padding: "9px 16px",
|
||||
borderRadius: 8,
|
||||
@@ -591,18 +595,92 @@ export function ResearchCanvas({
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
cursor:
|
||||
boundApproval && decidingApproval === null
|
||||
boundApproval && decidingApproval === null && !rejectFormOpen
|
||||
? "pointer"
|
||||
: "default",
|
||||
opacity:
|
||||
!boundApproval || decidingApproval !== null
|
||||
!boundApproval || decidingApproval !== null || rejectFormOpen
|
||||
? 0.55
|
||||
: 1,
|
||||
}}
|
||||
>
|
||||
{decidingApproval === "reject" ? "Rejecting…" : "Reject"}
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
{rejectFormOpen ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(255,138,122,.3)",
|
||||
background: "rgba(255,138,122,.06)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<label style={{ fontSize: 11.5, color: "#cfcfd5", fontWeight: 600 }}>
|
||||
Revision guidance (optional — send back for another pass)
|
||||
</label>
|
||||
<textarea
|
||||
value={rejectNotes}
|
||||
onChange={(e) => setRejectNotes(e.target.value)}
|
||||
placeholder="What should the coordinator address in the next iteration?"
|
||||
rows={4}
|
||||
autoFocus
|
||||
style={{
|
||||
width: "100%",
|
||||
resize: "vertical",
|
||||
borderRadius: 6,
|
||||
border: "1px solid rgba(255,255,255,.12)",
|
||||
background: "#101014",
|
||||
color: "#eaeaee",
|
||||
fontSize: 12.5,
|
||||
padding: "8px 10px",
|
||||
outline: "none",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setRejectFormOpen(false); setRejectNotes(""); }}
|
||||
disabled={decidingApproval !== null}
|
||||
style={{
|
||||
padding: "7px 14px",
|
||||
borderRadius: 6,
|
||||
border: "1px solid rgba(255,255,255,.12)",
|
||||
background: "transparent",
|
||||
color: "#cfcfd5",
|
||||
fontSize: 12,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => decide("reject", rejectNotes)}
|
||||
disabled={decidingApproval !== null}
|
||||
style={{
|
||||
padding: "7px 14px",
|
||||
borderRadius: 6,
|
||||
border: 0,
|
||||
background: rejectNotes.trim() ? "#ff8a7a" : "rgba(255,138,122,.4)",
|
||||
color: "#1a0808",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
cursor: decidingApproval === null ? "pointer" : "default",
|
||||
}}
|
||||
>
|
||||
{decidingApproval === "reject"
|
||||
? (rejectNotes.trim() ? "Sending back…" : "Rejecting…")
|
||||
: (rejectNotes.trim() ? "Send back for revision" : "Reject without notes")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user