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:
Generated
+21
@@ -966,12 +966,14 @@ dependencies = [
|
|||||||
"rsa",
|
"rsa",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_yaml",
|
||||||
"sha2",
|
"sha2",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite 0.26.2",
|
"tokio-tungstenite 0.26.2",
|
||||||
|
"toml",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -4466,6 +4468,19 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_yaml"
|
||||||
|
version = "0.9.34+deprecated"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap 2.14.0",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
"unsafe-libyaml",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serial"
|
name = "serial"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@@ -5625,6 +5640,12 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unsafe-libyaml"
|
||||||
|
version = "0.2.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub mod level_up;
|
|||||||
mod mcp_door;
|
mod mcp_door;
|
||||||
mod mcp_skills;
|
mod mcp_skills;
|
||||||
pub mod mission_orchestrator;
|
pub mod mission_orchestrator;
|
||||||
|
pub mod mission_refiner;
|
||||||
pub mod node_rules;
|
pub mod node_rules;
|
||||||
pub mod pdf_renderer;
|
pub mod pdf_renderer;
|
||||||
pub mod quota;
|
pub mod quota;
|
||||||
@@ -439,6 +440,10 @@ pub fn router(state: AppState) -> Router {
|
|||||||
"/api/missions/{id}/status",
|
"/api/missions/{id}/status",
|
||||||
axum::routing::patch(routes::missions::set_status),
|
axum::routing::patch(routes::missions::set_status),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/missions/{id}/refine",
|
||||||
|
post(routes::missions::refine),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/missions/{id}/benchmark",
|
"/api/missions/{id}/benchmark",
|
||||||
post(routes::missions::trigger_benchmark),
|
post(routes::missions::trigger_benchmark),
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
//! Mission refiner — take the user's freeform description on a draft
|
||||||
|
//! mission and rewrite it into a coherent, sectioned Markdown brief
|
||||||
|
//! that downstream research + coding agents can ingest cleanly.
|
||||||
|
//!
|
||||||
|
//! Uses Gemini 2.5 Flash (same call shape as level_up.rs) but with a
|
||||||
|
//! text-mode response — we want Markdown out, not JSON.
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const DEFAULT_MODEL: &str = "gemini-2.5-flash";
|
||||||
|
|
||||||
|
fn model_name() -> String {
|
||||||
|
std::env::var("CLAWMATES_REFINER_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn refine(
|
||||||
|
pool: &PgPool,
|
||||||
|
workspace_id: cm_domain::WorkspaceId,
|
||||||
|
mission_id: Uuid,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let mission = cm_db::repo::missions::get(pool, mission_id, workspace_id.as_uuid())
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("load mission: {e}"))?
|
||||||
|
.ok_or_else(|| "mission not found".to_string())?;
|
||||||
|
if mission.status != "draft" {
|
||||||
|
return Err(format!("mission is {}, refine only allowed on draft", mission.status));
|
||||||
|
}
|
||||||
|
let raw = mission.description.unwrap_or_default();
|
||||||
|
if raw.trim().is_empty() {
|
||||||
|
return Err("description is empty — nothing to refine".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let phase_kinds: Vec<String> = cm_db::repo::missions::phases_for(pool, mission_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("load phases: {e}"))?
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| p.kind)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
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)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("save description: {e}"))?;
|
||||||
|
Ok(refined)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call_gemini(
|
||||||
|
title: &str,
|
||||||
|
template_kind: &str,
|
||||||
|
phase_kinds: &[String],
|
||||||
|
raw: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let api_key =
|
||||||
|
std::env::var("GEMINI_API_KEY").map_err(|_| "GEMINI_API_KEY unset".to_string())?;
|
||||||
|
let model = model_name();
|
||||||
|
let url = format!(
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let system = "You are a technical brief editor for an autonomous software \
|
||||||
|
engineering platform. Rewrite the user's raw mission description into a \
|
||||||
|
clean, sectioned Markdown brief that research + coding agents can ingest \
|
||||||
|
directly. Preserve every concrete fact, requirement, constraint, and \
|
||||||
|
acceptance criterion the user provided — do not invent new scope. \
|
||||||
|
Structure the output with these sections when the source material \
|
||||||
|
supports them (omit sections with nothing to say):\n\
|
||||||
|
\n\
|
||||||
|
# <One-line restated goal>\n\
|
||||||
|
\n\
|
||||||
|
## Objective\n\
|
||||||
|
A 1–3 sentence framing of what success looks like.\n\
|
||||||
|
\n\
|
||||||
|
## Context & Background\n\
|
||||||
|
Any relevant prior art, files, systems, or motivation the user gave.\n\
|
||||||
|
\n\
|
||||||
|
## Scope\n\
|
||||||
|
Bullet list of concrete deliverables (in scope). If the user \
|
||||||
|
called out non-goals, add an `### Out of scope` subsection.\n\
|
||||||
|
\n\
|
||||||
|
## Constraints\n\
|
||||||
|
Technical, stylistic, or process constraints (languages, versions, \
|
||||||
|
style guides, migration paths, existing conventions to respect).\n\
|
||||||
|
\n\
|
||||||
|
## Acceptance Criteria\n\
|
||||||
|
Numbered list of concrete, verifiable pass/fail conditions the \
|
||||||
|
coding agents should treat as done-definitions.\n\
|
||||||
|
\n\
|
||||||
|
## Open Questions\n\
|
||||||
|
Only include if the source material has genuine ambiguity worth \
|
||||||
|
flagging to the research phase before coding starts.\n\
|
||||||
|
\n\
|
||||||
|
Rules:\n\
|
||||||
|
- Output raw Markdown only — no code fence around the whole doc, \
|
||||||
|
no preamble like \"Here is the refined brief\".\n\
|
||||||
|
- Never make up file paths, APIs, repo names, or version numbers.\n\
|
||||||
|
- If the user's text is very short, produce a short brief — do not \
|
||||||
|
pad with generic filler.\n\
|
||||||
|
- Use `**bold**` sparingly for load-bearing terms; do not bold entire \
|
||||||
|
sentences.\n\
|
||||||
|
- Prefer bullet lists over paragraphs for scope, constraints, and criteria.";
|
||||||
|
|
||||||
|
let user = format!(
|
||||||
|
"Mission title: {title}\n\
|
||||||
|
Template kind: {template_kind}\n\
|
||||||
|
Planned phases: {phases}\n\
|
||||||
|
\n\
|
||||||
|
Raw description:\n\
|
||||||
|
---\n\
|
||||||
|
{raw}\n\
|
||||||
|
---",
|
||||||
|
phases = if phase_kinds.is_empty() {
|
||||||
|
"(none configured yet)".to_string()
|
||||||
|
} else {
|
||||||
|
phase_kinds.join(", ")
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let body = json!({
|
||||||
|
"system_instruction": { "parts": [{ "text": system }] },
|
||||||
|
"contents": [{ "role": "user", "parts": [{ "text": user }] }],
|
||||||
|
"generationConfig": {
|
||||||
|
"temperature": 0.3,
|
||||||
|
"maxOutputTokens": 4096,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(60))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("http client: {e}"))?;
|
||||||
|
let resp = client
|
||||||
|
.post(&url)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("gemini call: {e}"))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let code = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("gemini {code}: {}", &body[..body.len().min(500)]));
|
||||||
|
}
|
||||||
|
let json: serde_json::Value = resp.json().await.map_err(|e| format!("gemini json: {e}"))?;
|
||||||
|
let text = json
|
||||||
|
.pointer("/candidates/0/content/parts/0/text")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| "gemini response missing text".to_string())?
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
if text.is_empty() {
|
||||||
|
return Err("gemini returned empty text".into());
|
||||||
|
}
|
||||||
|
Ok(text)
|
||||||
|
}
|
||||||
@@ -221,6 +221,32 @@ 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
|
||||||
|
/// coherent, sectioned Markdown brief ready for downstream agent
|
||||||
|
/// ingestion. Draft-only.
|
||||||
|
pub async fn refine(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Mission>, ApiError> {
|
||||||
|
crate::mission_refiner::refine(&state.pool, user.workspace_id, id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
eprintln!("mission {id}: refine failed: {e}");
|
||||||
|
if e.contains("not found") {
|
||||||
|
ApiError::NotFound
|
||||||
|
} else if e.contains("empty") || e.contains("only allowed on draft") {
|
||||||
|
ApiError::BadRequest
|
||||||
|
} else {
|
||||||
|
ApiError::Internal
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::NotFound)?;
|
||||||
|
Ok(Json(mission))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn set_status(
|
pub async fn set_status(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Authed(user): Authed,
|
Authed(user): Authed,
|
||||||
|
|||||||
@@ -233,6 +233,25 @@ pub async fn list_by_workspace(
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn set_description(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
description: &str,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE missions
|
||||||
|
SET description = $3, updated_at = now()
|
||||||
|
WHERE id = $1 AND workspace_id = $2",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(workspace_id)
|
||||||
|
.bind(description)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn set_status(
|
pub async fn set_status(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,10 +9,11 @@
|
|||||||
// This replaces ResearchCanvas + LoopsCanvas after Slice 9's cutover.
|
// This replaces ResearchCanvas + LoopsCanvas after Slice 9's cutover.
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { FileText, Play, RefreshCw } from "lucide-react";
|
import { FileText, Play, RefreshCw, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getMission,
|
getMission,
|
||||||
|
refineMission,
|
||||||
setMissionStatus,
|
setMissionStatus,
|
||||||
type MissionDetail,
|
type MissionDetail,
|
||||||
type MissionStatus,
|
type MissionStatus,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
type TaskStatus,
|
type TaskStatus,
|
||||||
type TemplateKind,
|
type TemplateKind,
|
||||||
} from "@/lib/api/missions";
|
} from "@/lib/api/missions";
|
||||||
|
import { MarkdownBlock } from "./MarkdownBlock";
|
||||||
|
|
||||||
const mono =
|
const mono =
|
||||||
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
"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 [error, setError] = useState<string | null>(null);
|
||||||
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 load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!selectedId) {
|
if (!selectedId) {
|
||||||
@@ -100,6 +103,21 @@ export function MissionCanvas({
|
|||||||
void load();
|
void load();
|
||||||
}, [load, refreshKey]);
|
}, [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 () => {
|
const launch = useCallback(async () => {
|
||||||
if (!mission) return;
|
if (!mission) return;
|
||||||
setLaunching(true);
|
setLaunching(true);
|
||||||
@@ -198,6 +216,26 @@ export function MissionCanvas({
|
|||||||
{TEMPLATE_LABEL[mission.template_kind] ?? mission.template_kind}
|
{TEMPLATE_LABEL[mission.template_kind] ?? mission.template_kind}
|
||||||
</span>
|
</span>
|
||||||
<div style={{ marginLeft: "auto", display: "flex", gap: 6 }}>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={load}
|
onClick={load}
|
||||||
@@ -225,9 +263,9 @@ export function MissionCanvas({
|
|||||||
</div>
|
</div>
|
||||||
<h1 style={{ margin: 0, fontSize: 20, color: "#f3f3f5" }}>{mission.title}</h1>
|
<h1 style={{ margin: 0, fontSize: 20, color: "#f3f3f5" }}>{mission.title}</h1>
|
||||||
{mission.description && (
|
{mission.description && (
|
||||||
<p style={{ margin: 0, fontSize: 13, color: "#cfcfd5", lineHeight: 1.5 }}>
|
<div style={{ marginTop: 4 }}>
|
||||||
{mission.description}
|
<MarkdownBlock source={mission.description} />
|
||||||
</p>
|
</div>
|
||||||
)}
|
)}
|
||||||
<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) => {
|
||||||
|
|||||||
@@ -195,6 +195,9 @@ export const createMission = (body: CreateMissionRequest) =>
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const refineMission = (id: string) =>
|
||||||
|
api<Mission>(`/api/missions/${id}/refine`, { method: "POST" });
|
||||||
|
|
||||||
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`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
|
|||||||
Reference in New Issue
Block a user