research canvas: wider rail + stage explainer + 409 fix
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:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user