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:
Omar Sobh
2026-07-19 18:46:14 -07:00
parent 278cbf90b7
commit ad1cee0b08
5 changed files with 322 additions and 15 deletions
+4
View File
@@ -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),
+11 -5
View File
@@ -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(
+45 -5
View File
@@ -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,
+12 -1
View File
@@ -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`, {