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
+14 -1
View File
@@ -138,6 +138,11 @@ pub struct TopicDetail {
#[serde(flatten)] #[serde(flatten)]
pub topic: cm_db::repo::research_topics::ResearchTopic, pub topic: cm_db::repo::research_topics::ResearchTopic,
pub agents: Vec<cm_db::repo::research_topics::AgentSlot>, pub agents: Vec<cm_db::repo::research_topics::AgentSlot>,
/// True when a publish approval is already pending for this topic.
/// Lets the frontend hide the "Request publish" button and show
/// "Awaiting reviewer approval" instead — avoids the 409 the user
/// gets from double-clicking.
pub has_pending_publish_request: bool,
} }
pub async fn get_topic( pub async fn get_topic(
@@ -149,7 +154,15 @@ pub async fn get_topic(
.await? .await?
.ok_or(ApiError::NotFound)?; .ok_or(ApiError::NotFound)?;
let agents = cm_db::repo::research_topics::agents(&state.pool, id).await?; let agents = cm_db::repo::research_topics::agents(&state.pool, id).await?;
Ok(Json(TopicDetail { topic, agents })) let has_pending_publish_request =
cm_db::repo::research_publish_approvals::pending_for_topic(&state.pool, id)
.await?
.is_some();
Ok(Json(TopicDetail {
topic,
agents,
has_pending_publish_request,
}))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -586,15 +586,15 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
{!clawFull ? ( {!clawFull ? (
<> <>
{/* STRUCTURE RAIL — tiers, then a divider, then the "+" create affordance */} {/* STRUCTURE RAIL — tiers, then a divider, then the "+" create affordance */}
<div style={{ width: 60, flex: "none", borderRight: "1px solid rgba(255,255,255,.06)", background: "#0a0a0c", display: "flex", flexDirection: "column", alignItems: "center", padding: "14px 0" }}> <div style={{ width: 76, flex: "none", borderRight: "1px solid rgba(255,255,255,.06)", background: "#0a0a0c", display: "flex", flexDirection: "column", alignItems: "center", padding: "14px 0" }}>
<div style={{ display: "flex", flexDirection: "column", gap: 6, alignItems: "center" }}> <div style={{ display: "flex", flexDirection: "column", gap: 6, alignItems: "center" }}>
{TIER_TABS.map((t) => { {TIER_TABS.map((t) => {
const on = tier === t.key; const on = tier === t.key;
return ( return (
<div key={t.key} onClick={() => setTier(t.key)} style={{ position: "relative", width: 42, height: 48, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 4, borderRadius: 10, cursor: "pointer", color: on ? "#ff6f61" : "#5a5a62", background: on ? "rgba(255,111,97,.1)" : "transparent" }}> <div key={t.key} onClick={() => setTier(t.key)} style={{ position: "relative", width: 58, height: 50, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 4, borderRadius: 10, cursor: "pointer", color: on ? "#ff6f61" : "#5a5a62", background: on ? "rgba(255,111,97,.1)" : "transparent", paddingLeft: 5 }}>
{on ? <span style={{ position: "absolute", left: 0, top: 8, bottom: 8, width: 3, borderRadius: "0 3px 3px 0", background: "#ff6f61" }} /> : null} {on ? <span style={{ position: "absolute", left: 0, top: 8, bottom: 8, width: 3, borderRadius: "0 3px 3px 0", background: "#ff6f61" }} /> : null}
{railIcon[t.key]} {railIcon[t.key]}
<span style={{ fontFamily: mono, fontSize: 8 }}>{t.label}</span> <span style={{ fontFamily: mono, fontSize: 8.5, letterSpacing: ".02em" }}>{t.label}</span>
</div> </div>
); );
})} })}
@@ -27,7 +27,27 @@ const STATUS_COLOR: Record<TopicStatus, string> = {
published: "#5fd08a", 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; label: string;
run: (id: string) => Promise<unknown>; run: (id: string) => Promise<unknown>;
} | null { } | null {
@@ -37,6 +57,9 @@ function nextAction(status: TopicStatus): {
case "processing": case "processing":
return { label: "Submit for review", run: submitReview }; return { label: "Submit for review", run: submitReview };
case "reviewing": 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 }; return { label: "Request publish", run: requestPublish };
default: default:
return null; return null;
@@ -99,17 +122,27 @@ export function ResearchCanvas({
} }
const dot = STATUS_COLOR[topic.status]; 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])); const agentById = new Map(agents.map((a) => [a.id, a]));
async function runAction() { async function runAction() {
if (!action) return; if (!action) return;
setActing(true); setActing(true);
setError(null);
try { try {
await action.run(topic!.id); await action.run(topic!.id);
onChanged(); onChanged();
} catch (e) { } 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 { } finally {
setActing(false); setActing(false);
} }
@@ -226,8 +259,33 @@ export function ResearchCanvas({
</pre> </pre>
</div> </div>
{/* Action */} {/* Stage explainer + primary action */}
{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 }}> <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<button <button
type="button" type="button"
@@ -251,7 +309,25 @@ export function ResearchCanvas({
<span style={{ fontFamily: mono, fontSize: 12, color: "#ff8a7a" }}>{error}</span> <span style={{ fontFamily: mono, fontSize: 12, color: "#ff8a7a" }}>{error}</span>
)} )}
</div> </div>
)} ) : topic.status === "reviewing" && topic.has_pending_publish_request ? (
<div
style={{
padding: "10px 14px",
borderRadius: 10,
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",
}}
>
<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>
</div> </div>
); );
+4
View File
@@ -34,6 +34,10 @@ export interface TopicDetail {
updated_at: string; updated_at: string;
published_at: string | null; published_at: string | null;
agents: AgentSlot[]; agents: AgentSlot[];
/** True while a publish approval is pending. Frontend hides the
* "Request publish" button and shows "Awaiting reviewer approval"
* instead so double-clicks don't 409. */
has_pending_publish_request: boolean;
} }
export interface PublishApproval { export interface PublishApproval {