Files
clawmates/frontend/src/components/dashboard/MissionArtifacts.tsx
T
Omar SobhandClaude Opus 5 e4bddeb1ba feat(ui): three-screen mission wizard that asks only what the type needs
Five fixed steps for every mission type, and getting a research document out of
it meant naming a team, choosing a runtime, and writing per-phase completion
conditions under a paragraph explaining what a model checker can and cannot
prove. Two of those steps asked for things the mission does not use, and one of
them blocked outright.

  1  What do you want to do?
  2  Title, a description with a Polish button, repo ONLY if the type needs one
  3  Review -> Launch, plus one collapsed Advanced section

Two hard defects fixed on the way:

- The microVM runtime could not be selected AT ALL. Step 4 gated Next on
  `targetNodeId`, which microVM deliberately never sets because placement picks
  the node per phase. Everything shipped today, the local-GPU backend included,
  was unreachable from the UI.
- Step 3 required a team while every workflow TOML already names one in
  `default_team_template` — which this file ignored. The answer was always
  available and the question was always asked. It is now resolved by key, with a
  category fallback, and shown under Advanced so an operator can see WHICH
  default rather than having to supply one.

A failed `/api/team-templates` request and a genuinely empty list rendered the
identical red banner, which sends the reader looking for missing template files
when the request had 401'd. They now say different things.

`phases[]` is no longer sent unless someone set a completion condition.
`recipeToPreset` strips each phase's `config`, so posting the stripped list
overrode the recipe's real settings — tools, commit policy, loop mode — with
nothing. Omitting it lets `phases_for_create` use the recipe, which is both
simpler and more correct.

Launch keeps its own gate, since Advanced can still produce an unlaunchable
combination — but it names what is missing instead of greying out in silence.

Artifacts get a Download link. Deliberately a plain link to the streaming route
rather than a Blob built from what "Read" already fetched: that content is
capped at 2 MiB and UTF-8-decoded, so reusing it would silently produce a
truncated or undownloadable file for exactly the artifacts worth downloading.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-09 18:50:13 -07:00

233 lines
7.8 KiB
TypeScript

"use client";
// MissionArtifacts — the mission's captured FILES, and a reader for them.
//
// Distinct from MissionOutputReader, which shows agent turn output: that is the
// agent's ACCOUNT of the work, this is the work. For a repo-less research
// mission these artifacts are the only durable result — see
// `cm-api/src/mission_outputs.rs`.
//
// Bodies are fetched on demand. The mission detail carries paths only, and a
// research phase can leave dozens of documents.
import { useCallback, useState } from "react";
import { Download, FileText, GitMerge } from "lucide-react";
import {
getArtifactContent,
mergeMissionBranch,
type ArtifactContent,
type MergeResult,
type MissionDetail,
} from "@/lib/api/missions";
import { MarkdownBlock } from "./MarkdownBlock";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
export function MissionArtifacts({
mission,
secondaryBtn,
}: {
mission: MissionDetail;
secondaryBtn: React.CSSProperties;
}) {
const missionId = mission.id;
const artifacts = mission.artifacts;
const [merging, setMerging] = useState(false);
const [merge, setMerge] = useState<MergeResult | null>(null);
const [openId, setOpenId] = useState<string | null>(null);
const [text, setText] = useState<ArtifactContent | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/**
* Open one artifact, fetching its text. Clicking the open one closes it.
*
* Errors surface in place rather than being swallowed: a file the server
* refuses to read (outside `_outputs`, or reaped with its mission) has to say
* so, or the panel sits empty and reads as a slow load that never finishes.
*/
const open = useCallback(
async (artifactId: string) => {
if (openId === artifactId) {
setOpenId(null);
return;
}
setOpenId(artifactId);
setText(null);
setError(null);
setLoading(true);
try {
setText(await getArtifactContent(missionId, artifactId));
} catch (e) {
setError(e instanceof Error ? e.message : "could not read this artifact");
} finally {
setLoading(false);
}
},
[missionId, openId],
);
// The branch delivery actually pushed, read from the artifact that recorded
// it — not rebuilt from the mission id, so a phase that never pushed shows no
// button rather than a button that cannot work.
const branch = artifacts
.map((a) => (a.metadata ?? {}) as Record<string, unknown>)
.filter((m) => m.pushed === true)
.map((m) => (typeof m.branch === "string" ? m.branch : null))
.filter(Boolean)
.pop() as string | null;
const doMerge = async () => {
if (merging) return;
setMerging(true);
setMerge(null);
try {
setMerge(await mergeMissionBranch(mission.id));
} catch (e) {
setMerge({
merged: false,
reason: e instanceof Error ? e.message : "merge request failed",
});
} finally {
setMerging(false);
}
};
if (artifacts.length === 0) {
return (
<div style={{ fontFamily: mono, fontSize: 11.5, color: "#8a8a92", padding: 12 }}>
no artifacts yet — phases produce them as they run
</div>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{branch && (
<div
style={{
padding: 11,
borderRadius: 10,
border: "1px solid rgba(124,214,224,.22)",
background: "rgba(124,214,224,.05)",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<GitMerge size={16} style={{ color: "#7cd6e0", flex: "none" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, color: "#f3f3f5", fontWeight: 500 }}>
Merge this mission&rsquo;s work
</div>
<div style={{ fontFamily: mono, fontSize: 10.5, color: "#8a8a92" }}>
{merge ? merge.reason : `${branch} → default branch`}
</div>
</div>
<button
type="button"
onClick={doMerge}
disabled={merging || merge?.merged === true}
style={{
...secondaryBtn,
padding: "5px 10px",
fontSize: 11,
opacity: merging || merge?.merged ? 0.6 : 1,
}}
>
{merging ? "Merging…" : merge?.merged ? "Merged" : "Merge to main"}
</button>
</div>
)}
{artifacts.map((a) => {
const isOpen = openId === a.id;
return (
<div key={a.id} style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div
style={{
padding: 11,
borderRadius: 10,
border: "1px solid rgba(255,255,255,.07)",
background: "#101014",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<FileText size={16} style={{ color: "#7cd6e0", flex: "none" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, color: "#f3f3f5", fontWeight: 500 }}>
{a.title ?? a.path.split("/").pop() ?? a.path}
</div>
<div style={{ fontFamily: mono, fontSize: 10.5, color: "#8a8a92" }}>
{a.kind} · {a.path}
</div>
</div>
<button
type="button"
onClick={() => open(a.id)}
style={{ ...secondaryBtn, padding: "5px 10px", fontSize: 11 }}
>
{isOpen ? "Hide" : "Read"}
</button>
{/* A plain link, not a fetch-and-Blob.
The read endpoint caps at 2 MiB and decodes as UTF-8, so
building the download from what "Read" already fetched would
inherit both limits and silently produce a truncated or
undownloadable file for exactly the artifacts worth
downloading. This hits the streaming route instead. */}
<a
href={`/api/missions/${missionId}/artifacts/${a.id}/download`}
download
style={{
...secondaryBtn,
padding: "5px 10px",
fontSize: 11,
textDecoration: "none",
display: "inline-flex",
alignItems: "center",
gap: 5,
}}
>
<Download aria-hidden size={12} />
Download
</a>
</div>
{isOpen && (
<div
style={{
border: "1px solid rgba(255,255,255,.06)",
borderRadius: 8,
background: "#0a0a0d",
padding: 16,
maxHeight: 640,
overflowY: "auto",
}}
>
{loading ? (
<div style={{ fontFamily: mono, fontSize: 11.5, color: "#8a8a92" }}>
loading…
</div>
) : error ? (
<div style={{ fontFamily: mono, fontSize: 11.5, color: "#ff8a7a" }}>
{error}
</div>
) : text?.truncated ? (
<div style={{ fontFamily: mono, fontSize: 11.5, color: "#8a8a92" }}>
too large to display inline ({text.bytes.toLocaleString()}{" "}
bytes) — use Download
</div>
) : text ? (
<MarkdownBlock source={text.content} />
) : null}
</div>
)}
</div>
);
})}
</div>
);
}