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:
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
// MarkdownBlock — tiny zero-dep Markdown renderer. Handles the subset
|
||||
// the refiner emits: h1/h2/h3 headings, - / * bullets, 1. numbered
|
||||
// lists, `**bold**`, `` `code` ``, blank-line-separated paragraphs.
|
||||
// Not a general-purpose renderer — deliberately small to avoid a
|
||||
// react-markdown dep for one canvas surface.
|
||||
|
||||
import React from "react";
|
||||
|
||||
const mono =
|
||||
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
||||
|
||||
type Block =
|
||||
| { kind: "h1" | "h2" | "h3"; text: string }
|
||||
| { kind: "p"; text: string }
|
||||
| { kind: "ul"; items: string[] }
|
||||
| { kind: "ol"; items: string[] };
|
||||
|
||||
function parse(md: string): Block[] {
|
||||
const lines = md.replace(/\r\n/g, "\n").split("\n");
|
||||
const blocks: Block[] = [];
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// Headings
|
||||
const h = /^(#{1,3})\s+(.*)$/.exec(trimmed);
|
||||
if (h) {
|
||||
const level = h[1].length as 1 | 2 | 3;
|
||||
blocks.push({
|
||||
kind: (`h${level}` as "h1" | "h2" | "h3"),
|
||||
text: h[2],
|
||||
});
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// Bullet list
|
||||
if (/^[-*]\s+/.test(trimmed)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
|
||||
items.push(lines[i].trim().replace(/^[-*]\s+/, ""));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ kind: "ul", items });
|
||||
continue;
|
||||
}
|
||||
// Numbered list
|
||||
if (/^\d+\.\s+/.test(trimmed)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
|
||||
items.push(lines[i].trim().replace(/^\d+\.\s+/, ""));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ kind: "ol", items });
|
||||
continue;
|
||||
}
|
||||
// Paragraph — greedily accumulate until blank line or block boundary
|
||||
const paraLines: string[] = [];
|
||||
while (
|
||||
i < lines.length &&
|
||||
lines[i].trim() &&
|
||||
!/^(#{1,3})\s+/.test(lines[i].trim()) &&
|
||||
!/^[-*]\s+/.test(lines[i].trim()) &&
|
||||
!/^\d+\.\s+/.test(lines[i].trim())
|
||||
) {
|
||||
paraLines.push(lines[i].trim());
|
||||
i++;
|
||||
}
|
||||
if (paraLines.length) blocks.push({ kind: "p", text: paraLines.join(" ") });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// Inline: **bold**, `code`. Simple sequential scan.
|
||||
function renderInline(text: string): React.ReactNode[] {
|
||||
const out: React.ReactNode[] = [];
|
||||
const re = /(\*\*[^*]+\*\*|`[^`]+`)/g;
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
let key = 0;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
if (m.index > last) out.push(text.slice(last, m.index));
|
||||
const tok = m[0];
|
||||
if (tok.startsWith("**")) {
|
||||
out.push(
|
||||
<strong key={key++} style={{ color: "#f3f3f5" }}>
|
||||
{tok.slice(2, -2)}
|
||||
</strong>,
|
||||
);
|
||||
} else {
|
||||
out.push(
|
||||
<code
|
||||
key={key++}
|
||||
style={{
|
||||
fontFamily: mono,
|
||||
fontSize: 11.5,
|
||||
padding: "1px 5px",
|
||||
borderRadius: 4,
|
||||
background: "rgba(255,255,255,.06)",
|
||||
color: "#ffb44a",
|
||||
}}
|
||||
>
|
||||
{tok.slice(1, -1)}
|
||||
</code>,
|
||||
);
|
||||
}
|
||||
last = m.index + tok.length;
|
||||
}
|
||||
if (last < text.length) out.push(text.slice(last));
|
||||
return out;
|
||||
}
|
||||
|
||||
export function MarkdownBlock({ source }: { source: string }) {
|
||||
const blocks = React.useMemo(() => parse(source), [source]);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
color: "#cfcfd5",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
{blocks.map((b, idx) => {
|
||||
if (b.kind === "h1")
|
||||
return (
|
||||
<h1
|
||||
key={idx}
|
||||
style={{
|
||||
margin: "8px 0 2px",
|
||||
fontSize: 17,
|
||||
color: "#f3f3f5",
|
||||
fontWeight: 600,
|
||||
letterSpacing: ".01em",
|
||||
}}
|
||||
>
|
||||
{renderInline(b.text)}
|
||||
</h1>
|
||||
);
|
||||
if (b.kind === "h2")
|
||||
return (
|
||||
<h2
|
||||
key={idx}
|
||||
style={{
|
||||
margin: "10px 0 -2px",
|
||||
fontSize: 12,
|
||||
color: "#7cd6e0",
|
||||
fontFamily: mono,
|
||||
letterSpacing: ".14em",
|
||||
textTransform: "uppercase",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{renderInline(b.text)}
|
||||
</h2>
|
||||
);
|
||||
if (b.kind === "h3")
|
||||
return (
|
||||
<h3
|
||||
key={idx}
|
||||
style={{
|
||||
margin: "6px 0 -4px",
|
||||
fontSize: 11.5,
|
||||
color: "#a0a0a8",
|
||||
fontFamily: mono,
|
||||
letterSpacing: ".10em",
|
||||
textTransform: "uppercase",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{renderInline(b.text)}
|
||||
</h3>
|
||||
);
|
||||
if (b.kind === "p")
|
||||
return (
|
||||
<p key={idx} style={{ margin: 0 }}>
|
||||
{renderInline(b.text)}
|
||||
</p>
|
||||
);
|
||||
if (b.kind === "ul")
|
||||
return (
|
||||
<ul
|
||||
key={idx}
|
||||
style={{
|
||||
margin: 0,
|
||||
paddingLeft: 18,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{b.items.map((it, i) => (
|
||||
<li key={i}>{renderInline(it)}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
if (b.kind === "ol")
|
||||
return (
|
||||
<ol
|
||||
key={idx}
|
||||
style={{
|
||||
margin: 0,
|
||||
paddingLeft: 20,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{b.items.map((it, i) => (
|
||||
<li key={i}>{renderInline(it)}</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user