"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(null); const [openId, setOpenId] = useState(null); const [text, setText] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(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) .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 (
no artifacts yet — phases produce them as they run
); } return (
{branch && (
Merge this mission’s work
{merge ? merge.reason : `${branch} → default branch`}
)} {artifacts.map((a) => { const isOpen = openId === a.id; return (
{a.title ?? a.path.split("/").pop() ?? a.path}
{a.kind} · {a.path}
{/* 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. */} Download
{isOpen && (
{loading ? (
loading…
) : error ? (
{error}
) : text?.truncated ? (
too large to display inline ({text.bytes.toLocaleString()}{" "} bytes) — use Download
) : text ? ( ) : null}
)}
); })}
); }