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
@@ -14,6 +14,7 @@ import { FileText, Play, RefreshCw, Sparkles } from "lucide-react";
import {
getMission,
refineMission,
setMissionDescription,
setMissionStatus,
triggerBenchmark,
triggerSecurityScan,
@@ -21,6 +22,7 @@ import {
type MissionStatus,
type PhaseKind,
type PhaseStatus,
type RefineResult,
type TaskStatus,
type TemplateKind,
} from "@/lib/api/missions";
@@ -82,6 +84,8 @@ export function MissionCanvas({
const [tab, setTab] = useState<Tab>("overview");
const [launching, setLaunching] = 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 [pdfPreviewId, setPdfPreviewId] = useState<string | null>(null);
@@ -112,15 +116,46 @@ export function MissionCanvas({
setRefining(true);
setError(null);
try {
await refineMission(mission.id);
onChanged();
await load();
const result = await refineMission(mission.id);
setRefineDiff(result);
} catch (e) {
setError(e instanceof Error ? e.message : "refine failed");
} finally {
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(
async (phaseId: string) => {
@@ -310,6 +345,16 @@ export function MissionCanvas({
<MarkdownBlock source={mission.description} />
</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 }}>
{(["overview", "phases", "tasks", "artifacts", "benchmarks"] as Tab[]).map((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 = {
width: 30,
height: 30,