mission canvas: add Refine button + markdown-rendered description

Adds a Refine button to the left of Refresh + Launch on the mission
detail toolbar (draft-only). Clicking it POSTs to a new endpoint that
calls Gemini 2.5 Flash to rewrite the user's freeform description into
a coherent, sectioned Markdown brief (Objective / Context / Scope /
Constraints / Acceptance Criteria / Open Questions) ready for the
research + coding agents to ingest cleanly.

Backend:
  - crates/cm-api/src/mission_refiner.rs — Gemini call with a
    system prompt that preserves user-provided facts, avoids
    invention, and emits raw markdown (not JSON).
  - POST /api/missions/{id}/refine — draft-only, 400 on empty
    description or non-draft state.
  - cm-db::repo::missions::set_description helper.

Frontend:
  - MarkdownBlock — tiny zero-dep renderer for h1/h2/h3, bullet +
    numbered lists, **bold**, `code`, paragraphs. Deliberately
    small; the refiner emits a bounded subset.
  - MissionCanvas — Refine button (Sparkles icon, secondary style)
    to the left of Refresh; description now renders through
    MarkdownBlock instead of a single <p>. Disabled while
    description is empty or a refine is in flight.
  - lib/api/missions — refineMission client.
This commit is contained in:
Omar Sobh
2026-07-19 17:20:34 -07:00
parent 4663348a0e
commit 56201a6985
8 changed files with 497 additions and 4 deletions
@@ -9,10 +9,11 @@
// This replaces ResearchCanvas + LoopsCanvas after Slice 9's cutover.
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { FileText, Play, RefreshCw } from "lucide-react";
import { FileText, Play, RefreshCw, Sparkles } from "lucide-react";
import {
getMission,
refineMission,
setMissionStatus,
type MissionDetail,
type MissionStatus,
@@ -21,6 +22,7 @@ import {
type TaskStatus,
type TemplateKind,
} from "@/lib/api/missions";
import { MarkdownBlock } from "./MarkdownBlock";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
@@ -77,6 +79,7 @@ export function MissionCanvas({
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>("overview");
const [launching, setLaunching] = useState(false);
const [refining, setRefining] = useState(false);
const load = useCallback(async () => {
if (!selectedId) {
@@ -100,6 +103,21 @@ export function MissionCanvas({
void load();
}, [load, refreshKey]);
const refine = useCallback(async () => {
if (!mission) return;
setRefining(true);
setError(null);
try {
await refineMission(mission.id);
onChanged();
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "refine failed");
} finally {
setRefining(false);
}
}, [mission, onChanged, load]);
const launch = useCallback(async () => {
if (!mission) return;
setLaunching(true);
@@ -198,6 +216,26 @@ export function MissionCanvas({
{TEMPLATE_LABEL[mission.template_kind] ?? mission.template_kind}
</span>
<div style={{ marginLeft: "auto", display: "flex", gap: 6 }}>
{mission.status === "draft" && (
<button
type="button"
onClick={refine}
disabled={refining || !mission.description?.trim()}
title={
mission.description?.trim()
? "Refine the description into a sectioned brief"
: "Add a description first"
}
aria-label="Refine"
style={{
...secondaryBtn,
opacity: refining || !mission.description?.trim() ? 0.5 : 1,
}}
>
<Sparkles size={13} style={{ marginRight: 4 }} />
{refining ? "Refining…" : "Refine"}
</button>
)}
<button
type="button"
onClick={load}
@@ -225,9 +263,9 @@ export function MissionCanvas({
</div>
<h1 style={{ margin: 0, fontSize: 20, color: "#f3f3f5" }}>{mission.title}</h1>
{mission.description && (
<p style={{ margin: 0, fontSize: 13, color: "#cfcfd5", lineHeight: 1.5 }}>
{mission.description}
</p>
<div style={{ marginTop: 4 }}>
<MarkdownBlock source={mission.description} />
</div>
)}
<div style={{ display: "flex", gap: 4, marginTop: 4 }}>
{(["overview", "phases", "tasks", "artifacts", "benchmarks"] as Tab[]).map((t) => {