refine polish: before/after diff view + accept/cancel/restore
Refine no longer clobbers the mission description on click. Flow:
1. Click Refine → server generates the rewrite, returns
{ original, refined } WITHOUT persisting
2. RefineDiffModal shows a side-by-side pane (raw before,
Markdown-rendered after)
3. User picks:
- Accept → PATCH /api/missions/{id}/description commits refined
- Cancel → discards the proposal, description unchanged
- Restore original → forces a write of `original` (undo path
for accidentally-accepted refines, since Accept+Cancel is
still a two-step confirmation)
Backend:
- mission_refiner::refine returns a RefineResult { original, refined }
struct instead of persisting + returning the text
- routes::missions::refine now returns { original, refined }
- routes::missions::set_description added on PATCH
/api/missions/{id}/description (draft-only)
Frontend:
- lib/api/missions — refineMission return type is now RefineResult;
added setMissionDescription
- MissionCanvas — RefineDiffModal + DiffPane subcomponents;
accept / cancel / restore handlers wired to state
Closes task #20.
This commit is contained in:
@@ -443,6 +443,10 @@ pub fn router(state: AppState) -> Router {
|
|||||||
"/api/missions/{id}/refine",
|
"/api/missions/{id}/refine",
|
||||||
post(routes::missions::refine),
|
post(routes::missions::refine),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/missions/{id}/description",
|
||||||
|
patch(routes::missions::set_description),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/missions/{id}/benchmark",
|
"/api/missions/{id}/benchmark",
|
||||||
post(routes::missions::trigger_benchmark),
|
post(routes::missions::trigger_benchmark),
|
||||||
|
|||||||
@@ -15,11 +15,20 @@ fn model_name() -> String {
|
|||||||
std::env::var("CLAWMATES_REFINER_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string())
|
std::env::var("CLAWMATES_REFINER_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct RefineResult {
|
||||||
|
pub original: String,
|
||||||
|
pub refined: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a refined description without touching the database. The
|
||||||
|
/// caller (frontend) reviews the diff and calls `set_description` to
|
||||||
|
/// commit — that separation makes Accept/Cancel + undo trivial without
|
||||||
|
/// an audit table.
|
||||||
pub async fn refine(
|
pub async fn refine(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
workspace_id: cm_domain::WorkspaceId,
|
workspace_id: cm_domain::WorkspaceId,
|
||||||
mission_id: Uuid,
|
mission_id: Uuid,
|
||||||
) -> Result<String, String> {
|
) -> Result<RefineResult, String> {
|
||||||
let mission = cm_db::repo::missions::get(pool, mission_id, workspace_id.as_uuid())
|
let mission = cm_db::repo::missions::get(pool, mission_id, workspace_id.as_uuid())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("load mission: {e}"))?
|
.map_err(|e| format!("load mission: {e}"))?
|
||||||
@@ -41,10 +50,7 @@ pub async fn refine(
|
|||||||
|
|
||||||
let refined = call_gemini(&mission.title, &mission.template_kind, &phase_kinds, &raw).await?;
|
let refined = call_gemini(&mission.title, &mission.template_kind, &phase_kinds, &raw).await?;
|
||||||
|
|
||||||
cm_db::repo::missions::set_description(pool, mission_id, workspace_id.as_uuid(), &refined)
|
Ok(RefineResult { original: raw, refined })
|
||||||
.await
|
|
||||||
.map_err(|e| format!("save description: {e}"))?;
|
|
||||||
Ok(refined)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn call_gemini(
|
async fn call_gemini(
|
||||||
|
|||||||
@@ -221,15 +221,22 @@ pub async fn trigger_security_scan(
|
|||||||
Ok(Json(SecurityScanResponse { findings, tasks }))
|
Ok(Json(SecurityScanResponse { findings, tasks }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/missions/{id}/refine — rewrite the description into a
|
#[derive(Debug, Serialize)]
|
||||||
/// coherent, sectioned Markdown brief ready for downstream agent
|
pub struct RefineResponse {
|
||||||
/// ingestion. Draft-only.
|
pub original: String,
|
||||||
|
pub refined: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/missions/{id}/refine — generate a coherent, sectioned
|
||||||
|
/// Markdown rewrite of the current description WITHOUT persisting.
|
||||||
|
/// Frontend renders a before/after diff; user hits Accept (PATCH
|
||||||
|
/// /description) or Cancel. Draft-only.
|
||||||
pub async fn refine(
|
pub async fn refine(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Authed(user): Authed,
|
Authed(user): Authed,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<Mission>, ApiError> {
|
) -> Result<Json<RefineResponse>, ApiError> {
|
||||||
crate::mission_refiner::refine(&state.pool, user.workspace_id, id)
|
let result = crate::mission_refiner::refine(&state.pool, user.workspace_id, id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
eprintln!("mission {id}: refine failed: {e}");
|
eprintln!("mission {id}: refine failed: {e}");
|
||||||
@@ -241,6 +248,39 @@ pub async fn refine(
|
|||||||
ApiError::Internal
|
ApiError::Internal
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
Ok(Json(RefineResponse {
|
||||||
|
original: result.original,
|
||||||
|
refined: result.refined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SetDescriptionRequest {
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PATCH /api/missions/{id}/description — commit a new description.
|
||||||
|
/// Draft-only. Used by the Refine Accept flow (and any future
|
||||||
|
/// direct-edit surface).
|
||||||
|
pub async fn set_description(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(body): Json<SetDescriptionRequest>,
|
||||||
|
) -> Result<Json<Mission>, ApiError> {
|
||||||
|
let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::NotFound)?;
|
||||||
|
if mission.status != "draft" {
|
||||||
|
return Err(ApiError::BadRequest);
|
||||||
|
}
|
||||||
|
cm_db::repo::missions::set_description(
|
||||||
|
&state.pool,
|
||||||
|
id,
|
||||||
|
user.workspace_id.as_uuid(),
|
||||||
|
&body.description,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
|
let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
|
||||||
.await?
|
.await?
|
||||||
.ok_or(ApiError::NotFound)?;
|
.ok_or(ApiError::NotFound)?;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { FileText, Play, RefreshCw, Sparkles } from "lucide-react";
|
|||||||
import {
|
import {
|
||||||
getMission,
|
getMission,
|
||||||
refineMission,
|
refineMission,
|
||||||
|
setMissionDescription,
|
||||||
setMissionStatus,
|
setMissionStatus,
|
||||||
triggerBenchmark,
|
triggerBenchmark,
|
||||||
triggerSecurityScan,
|
triggerSecurityScan,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
type MissionStatus,
|
type MissionStatus,
|
||||||
type PhaseKind,
|
type PhaseKind,
|
||||||
type PhaseStatus,
|
type PhaseStatus,
|
||||||
|
type RefineResult,
|
||||||
type TaskStatus,
|
type TaskStatus,
|
||||||
type TemplateKind,
|
type TemplateKind,
|
||||||
} from "@/lib/api/missions";
|
} from "@/lib/api/missions";
|
||||||
@@ -82,6 +84,8 @@ export function MissionCanvas({
|
|||||||
const [tab, setTab] = useState<Tab>("overview");
|
const [tab, setTab] = useState<Tab>("overview");
|
||||||
const [launching, setLaunching] = useState(false);
|
const [launching, setLaunching] = useState(false);
|
||||||
const [refining, setRefining] = useState(false);
|
const [refining, setRefining] = useState(false);
|
||||||
|
const [refineDiff, setRefineDiff] = useState<RefineResult | null>(null);
|
||||||
|
const [accepting, setAccepting] = useState(false);
|
||||||
const [phaseBusy, setPhaseBusy] = useState<string | null>(null);
|
const [phaseBusy, setPhaseBusy] = useState<string | null>(null);
|
||||||
const [pdfPreviewId, setPdfPreviewId] = useState<string | null>(null);
|
const [pdfPreviewId, setPdfPreviewId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -112,15 +116,46 @@ export function MissionCanvas({
|
|||||||
setRefining(true);
|
setRefining(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await refineMission(mission.id);
|
const result = await refineMission(mission.id);
|
||||||
onChanged();
|
setRefineDiff(result);
|
||||||
await load();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "refine failed");
|
setError(e instanceof Error ? e.message : "refine failed");
|
||||||
} finally {
|
} finally {
|
||||||
setRefining(false);
|
setRefining(false);
|
||||||
}
|
}
|
||||||
}, [mission, onChanged, load]);
|
}, [mission]);
|
||||||
|
|
||||||
|
const acceptRefine = useCallback(async () => {
|
||||||
|
if (!mission || !refineDiff) return;
|
||||||
|
setAccepting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await setMissionDescription(mission.id, refineDiff.refined);
|
||||||
|
setRefineDiff(null);
|
||||||
|
onChanged();
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "accept failed");
|
||||||
|
} finally {
|
||||||
|
setAccepting(false);
|
||||||
|
}
|
||||||
|
}, [mission, refineDiff, onChanged, load]);
|
||||||
|
|
||||||
|
const undoRefine = useCallback(async () => {
|
||||||
|
if (!mission || !refineDiff) return;
|
||||||
|
setAccepting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await setMissionDescription(mission.id, refineDiff.original);
|
||||||
|
setRefineDiff(null);
|
||||||
|
onChanged();
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "undo failed");
|
||||||
|
} finally {
|
||||||
|
setAccepting(false);
|
||||||
|
}
|
||||||
|
}, [mission, refineDiff, onChanged, load]);
|
||||||
|
|
||||||
const runSecurityScan = useCallback(
|
const runSecurityScan = useCallback(
|
||||||
async (phaseId: string) => {
|
async (phaseId: string) => {
|
||||||
@@ -310,6 +345,16 @@ export function MissionCanvas({
|
|||||||
<MarkdownBlock source={mission.description} />
|
<MarkdownBlock source={mission.description} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{refineDiff && (
|
||||||
|
<RefineDiffModal
|
||||||
|
original={refineDiff.original}
|
||||||
|
refined={refineDiff.refined}
|
||||||
|
busy={accepting}
|
||||||
|
onAccept={acceptRefine}
|
||||||
|
onCancel={() => setRefineDiff(null)}
|
||||||
|
onUndoAfterAccept={undoRefine}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div style={{ display: "flex", gap: 4, marginTop: 4 }}>
|
<div style={{ display: "flex", gap: 4, marginTop: 4 }}>
|
||||||
{(["overview", "phases", "tasks", "artifacts", "benchmarks"] as Tab[]).map((t) => {
|
{(["overview", "phases", "tasks", "artifacts", "benchmarks"] as Tab[]).map((t) => {
|
||||||
const active = tab === t;
|
const active = tab === t;
|
||||||
@@ -843,6 +888,207 @@ function Empty({ label }: { label: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RefineDiffModal({
|
||||||
|
original,
|
||||||
|
refined,
|
||||||
|
busy,
|
||||||
|
onAccept,
|
||||||
|
onCancel,
|
||||||
|
onUndoAfterAccept,
|
||||||
|
}: {
|
||||||
|
original: string;
|
||||||
|
refined: string;
|
||||||
|
busy: boolean;
|
||||||
|
onAccept: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onUndoAfterAccept: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal
|
||||||
|
onClick={onCancel}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,.6)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 900,
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: "min(1100px, 100%)",
|
||||||
|
height: "min(720px, calc(100vh - 48px))",
|
||||||
|
background: "#141419",
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px solid rgba(255,255,255,.08)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "12px 18px",
|
||||||
|
borderBottom: "1px solid rgba(255,255,255,.06)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 10.5,
|
||||||
|
letterSpacing: ".14em",
|
||||||
|
color: "#ffb44a",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refine — review before/after
|
||||||
|
</span>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onUndoAfterAccept}
|
||||||
|
disabled={busy}
|
||||||
|
title="Restore the original description (drops the refinement)"
|
||||||
|
style={{
|
||||||
|
padding: "5px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid rgba(255,255,255,.1)",
|
||||||
|
background: "transparent",
|
||||||
|
color: "#a0a0a8",
|
||||||
|
fontSize: 12,
|
||||||
|
cursor: "pointer",
|
||||||
|
opacity: busy ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Restore original
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={busy}
|
||||||
|
style={{
|
||||||
|
padding: "5px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid rgba(255,255,255,.1)",
|
||||||
|
background: "transparent",
|
||||||
|
color: "#a0a0a8",
|
||||||
|
fontSize: 12,
|
||||||
|
cursor: "pointer",
|
||||||
|
opacity: busy ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAccept}
|
||||||
|
disabled={busy}
|
||||||
|
style={{
|
||||||
|
padding: "5px 14px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid rgba(127,208,160,.5)",
|
||||||
|
background: "rgba(127,208,160,.12)",
|
||||||
|
color: "#7fd0a0",
|
||||||
|
fontSize: 12,
|
||||||
|
cursor: "pointer",
|
||||||
|
opacity: busy ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? "Applying…" : "Accept"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr",
|
||||||
|
gap: 1,
|
||||||
|
background: "rgba(255,255,255,.06)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DiffPane
|
||||||
|
title="Before"
|
||||||
|
color="#8a8a92"
|
||||||
|
body={original}
|
||||||
|
renderAsMarkdown={false}
|
||||||
|
/>
|
||||||
|
<DiffPane
|
||||||
|
title="After"
|
||||||
|
color="#7fd0a0"
|
||||||
|
body={refined}
|
||||||
|
renderAsMarkdown
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiffPane({
|
||||||
|
title,
|
||||||
|
color,
|
||||||
|
body,
|
||||||
|
renderAsMarkdown,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
color: string;
|
||||||
|
body: string;
|
||||||
|
renderAsMarkdown: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#0e0e12",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
minHeight: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "8px 14px",
|
||||||
|
borderBottom: "1px solid rgba(255,255,255,.05)",
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 10,
|
||||||
|
letterSpacing: ".14em",
|
||||||
|
color,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 14 }}>
|
||||||
|
{renderAsMarkdown ? (
|
||||||
|
<MarkdownBlock source={body} />
|
||||||
|
) : (
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#cfcfd5",
|
||||||
|
lineHeight: 1.55,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const iconBtn: React.CSSProperties = {
|
const iconBtn: React.CSSProperties = {
|
||||||
width: 30,
|
width: 30,
|
||||||
height: 30,
|
height: 30,
|
||||||
|
|||||||
@@ -195,8 +195,19 @@ export const createMission = (body: CreateMissionRequest) =>
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export interface RefineResult {
|
||||||
|
original: string;
|
||||||
|
refined: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const refineMission = (id: string) =>
|
export const refineMission = (id: string) =>
|
||||||
api<Mission>(`/api/missions/${id}/refine`, { method: "POST" });
|
api<RefineResult>(`/api/missions/${id}/refine`, { method: "POST" });
|
||||||
|
|
||||||
|
export const setMissionDescription = (id: string, description: string) =>
|
||||||
|
api<Mission>(`/api/missions/${id}/description`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ description }),
|
||||||
|
});
|
||||||
|
|
||||||
export const setMissionStatus = (id: string, status: MissionStatus) =>
|
export const setMissionStatus = (id: string, status: MissionStatus) =>
|
||||||
api<Mission>(`/api/missions/${id}/status`, {
|
api<Mission>(`/api/missions/${id}/status`, {
|
||||||
|
|||||||
Reference in New Issue
Block a user