research canvas: wider rail + stage explainer + 409 fix
ci / gates (push) Successful in 7s
ci / frontend (push) Successful in 38s
ci / rust (push) Successful in 3m13s
ci / e2e (push) Has been skipped
ci / publish (push) Successful in 3m58s

Three connected fixes from a single session's feedback:

- Structure rail widened from 60→76 px, tabs 42→58 px wide with a
  bit of left padding so labels ("Research", "Visualizations") no
  longer bump against the active-tab indicator strip.
- Research canvas: each stage now shows a small "STAGE · <status>"
  card explaining what state the topic is in and a "Next → …" hint
  describing what the primary button will do. No more guessing
  which of standby/processing/reviewing/publishing means what.
- Request-publish 409 fix:
    - Backend TopicDetail now includes has_pending_publish_request
      (SELECTs pending_for_topic when the topic loads). Frontend
      TopicDetail interface + ResearchCanvas honor the flag: when
      an approval is already pending the "Request publish" button
      is replaced with an amber "Awaiting reviewer approval" pill,
      so double-clicks can't 409 in the first place.
    - runAction() also catches 409 as a signal-of-success (the
      user's intent — "queue for approval" — is satisfied by the
      first attempt), refetches the topic, and lets the new
      awaiting-approval card render instead of surfacing a scary
      error to the user.
This commit is contained in:
Omar Sobh
2026-07-08 15:59:04 -07:00
parent 92e923e585
commit 81a436d221
4 changed files with 122 additions and 29 deletions
@@ -27,7 +27,27 @@ const STATUS_COLOR: Record<TopicStatus, string> = {
published: "#5fd08a",
};
function nextAction(status: TopicStatus): {
// Human-readable stage description + what the next button does. Shown
// under the primary action so the user knows what each step means
// without having to remember the pipeline.
const STAGE_COPY: Record<TopicStatus, { stage: string; nextHint: string }> = {
standby: {
stage: "Ready to run. Assigned agents will investigate and draft the outcome.",
nextHint: "Kick off the research — assigned agents start working the prompt.",
},
processing: {
stage: "Research in progress. Waiting for the assigned agents to finish drafting.",
nextHint: "Move the topic to review once the draft looks complete.",
},
reviewing: {
stage: "Under review — read the draft above and decide whether to publish.",
nextHint: "Ask a reviewer to approve publishing. Stays in review until they decide.",
},
publishing: { stage: "Approved. Publishing pipeline is running.", nextHint: "" },
published: { stage: "Published. This topic is done.", nextHint: "" },
};
function nextAction(status: TopicStatus, hasPendingPublish: boolean): {
label: string;
run: (id: string) => Promise<unknown>;
} | null {
@@ -37,6 +57,9 @@ function nextAction(status: TopicStatus): {
case "processing":
return { label: "Submit for review", run: submitReview };
case "reviewing":
// Backend already has a pending approval — don't offer the button
// (a second click 409s). Frontend surfaces an "Awaiting" note below.
if (hasPendingPublish) return null;
return { label: "Request publish", run: requestPublish };
default:
return null;
@@ -99,17 +122,27 @@ export function ResearchCanvas({
}
const dot = STATUS_COLOR[topic.status];
const action = nextAction(topic.status);
const action = nextAction(topic.status, topic.has_pending_publish_request);
const stageCopy = STAGE_COPY[topic.status];
const agentById = new Map(agents.map((a) => [a.id, a]));
async function runAction() {
if (!action) return;
setActing(true);
setError(null);
try {
await action.run(topic!.id);
onChanged();
} catch (e) {
setError(e instanceof Error ? e.message : "action failed");
const msg = e instanceof Error ? e.message : "action failed";
// A 409 on request-publish means an approval is already pending.
// Treat it as success (the user's intent is satisfied) so the UI
// flips into "awaiting approval" instead of surfacing a scary code.
if (/\b409\b/.test(msg)) {
onChanged();
} else {
setError(msg);
}
} finally {
setActing(false);
}
@@ -226,32 +259,75 @@ export function ResearchCanvas({
</pre>
</div>
{/* Action */}
{action && (
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<button
type="button"
onClick={runAction}
disabled={acting}
{/* Stage explainer + primary action */}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div
style={{
padding: "10px 14px",
borderRadius: 10,
border: "1px solid rgba(255,255,255,.06)",
background: "#101014",
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".12em", color: dot }}>
STAGE · {topic.status.toUpperCase()}
</div>
<div style={{ fontSize: 12.5, color: "#cfcfd5", lineHeight: 1.55 }}>
{stageCopy.stage}
</div>
{action && stageCopy.nextHint ? (
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92", marginTop: 2 }}>
Next {stageCopy.nextHint}
</div>
) : null}
</div>
{action ? (
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<button
type="button"
onClick={runAction}
disabled={acting}
style={{
padding: "12px 22px",
borderRadius: 10,
border: 0,
background: "linear-gradient(135deg,#ff8a7a,#ff5f57)",
color: "#2a0d0a",
fontSize: 14,
fontWeight: 700,
cursor: acting ? "default" : "pointer",
opacity: acting ? 0.6 : 1,
}}
>
{acting ? "Working…" : action.label}
</button>
{error && (
<span style={{ fontFamily: mono, fontSize: 12, color: "#ff8a7a" }}>{error}</span>
)}
</div>
) : topic.status === "reviewing" && topic.has_pending_publish_request ? (
<div
style={{
padding: "12px 22px",
padding: "10px 14px",
borderRadius: 10,
border: 0,
background: "linear-gradient(135deg,#ff8a7a,#ff5f57)",
color: "#2a0d0a",
fontSize: 14,
fontWeight: 700,
cursor: acting ? "default" : "pointer",
opacity: acting ? 0.6 : 1,
border: "1px solid rgba(255,180,74,.35)",
background: "rgba(255,180,74,.06)",
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 12.5,
color: "#ffb44a",
}}
>
{acting ? "Working…" : action.label}
</button>
{error && (
<span style={{ fontFamily: mono, fontSize: 12, color: "#ff8a7a" }}>{error}</span>
)}
</div>
)}
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#ffb44a" }} />
Awaiting reviewer approval. A workspace approver needs to sign off before publish.
</div>
) : null}
</div>
</div>
</div>
);