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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user