loops: 'View full output' modal for a completed iteration
ci / gates (push) Successful in 5s
ci / frontend (push) Successful in 26s
ci / rust (push) Successful in 4m6s
ci / e2e (push) Skipped
ci / publish (push) Successful in 35s

The expanded iteration panel now exposes the full topology_run.result
in a proper viewer instead of leaving it stranded in Postgres. Reads
/api/topology-runs/:id (existing endpoint, no backend change), prefers
result.final_output (the produced markdown/code), falls back to a
per-step transcript, last-resort a raw JSON dump.

Modal renders as a fixed overlay — Escape or backdrop-click to close.
Header carries kind/status/steps/tokens metadata, Copy button hits
navigator.clipboard, Download button emits a .md file named
run-<id-slice>.md. The button on the row is disabled until the
iteration terminates (completed | failed) — running iterations
already have LiveRunLogs surfacing the tail.
This commit is contained in:
Omar Sobh
2026-07-16 18:05:57 -07:00
parent 2ba40b03b7
commit b2a38da3f9
2 changed files with 247 additions and 1 deletions
@@ -11,6 +11,7 @@ import {
disableLoop, disableLoop,
enableLoop, enableLoop,
getLoop, getLoop,
getRunDetail,
listLoopRuns, listLoopRuns,
runLoopNow, runLoopNow,
type Loop, type Loop,
@@ -329,6 +330,8 @@ function IterationsTimeline({
}) { }) {
// One expanded iteration at a time; auto-open the newest running one. // One expanded iteration at a time; auto-open the newest running one.
const [expanded, setExpanded] = useState<string | null>(null); const [expanded, setExpanded] = useState<string | null>(null);
// Modal viewer for the full topology_run result.
const [outputRunId, setOutputRunId] = useState<string | null>(null);
const runningId = runs.find((r) => r.status === "running" || r.status === "queued")?.id ?? null; const runningId = runs.find((r) => r.status === "running" || r.status === "queued")?.id ?? null;
const [seenRunning, setSeenRunning] = useState<string | null>(runningId); const [seenRunning, setSeenRunning] = useState<string | null>(runningId);
if (seenRunning !== runningId) { if (seenRunning !== runningId) {
@@ -343,6 +346,10 @@ function IterationsTimeline({
return <p style={hintStyle}>No iterations yet hit &quot;Run now&quot; to fire one.</p>; return <p style={hintStyle}>No iterations yet hit &quot;Run now&quot; to fire one.</p>;
} }
return ( return (
<>
{outputRunId ? (
<RunOutputModal runId={outputRunId} onClose={() => setOutputRunId(null)} />
) : null}
<ul <ul
style={{ style={{
margin: 0, margin: 0,
@@ -420,7 +427,35 @@ function IterationsTimeline({
</span> </span>
</button> </button>
{isOpen ? ( {isOpen ? (
<div style={{ marginTop: 6 }}> <div style={{ marginTop: 6, display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<button
type="button"
onClick={() => setOutputRunId(r.id)}
disabled={r.status !== "completed" && r.status !== "failed"}
title={
r.status === "completed" || r.status === "failed"
? "Open the full topology_run result in a viewer"
: "Available once the iteration terminates"
}
style={{
padding: "4px 10px",
borderRadius: 999,
background: "rgba(94,200,216,.1)",
border: "1px solid rgba(94,200,216,.35)",
color: "#e5f6fb",
fontFamily: mono,
fontSize: 10,
cursor:
r.status === "completed" || r.status === "failed"
? "pointer"
: "not-allowed",
opacity: r.status === "completed" || r.status === "failed" ? 1 : 0.4,
}}
>
View full output
</button>
</div>
<LiveRunLogs directRunId={r.id} defaultOpen={true} /> <LiveRunLogs directRunId={r.id} defaultOpen={true} />
</div> </div>
) : null} ) : null}
@@ -428,9 +463,201 @@ function IterationsTimeline({
); );
})} })}
</ul> </ul>
</>
); );
} }
/** Modal viewer for a topology_run's full result. Fetches
* /api/topology-runs/:id which returns `comparison` (the run's
* serialized RunRecord). We look for `final_output` (the produced
* markdown / code) first; fall back to `steps` inline; last resort
* is a raw JSON dump.
*
* Rendered as a fixed overlay so it works from any tier. Escape /
* backdrop-click close. */
function RunOutputModal({
runId,
onClose,
}: {
runId: string;
onClose: () => void;
}) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [text, setText] = useState<string>("");
const [meta, setMeta] = useState<{
kind?: string;
status?: string;
tokens?: number;
steps?: number;
}>({});
useEffect(() => {
let live = true;
(async () => {
try {
const d = await getRunDetail(runId);
if (!live) return;
const c = (d.comparison ?? {}) as {
final_output?: string;
steps?: Array<{ role?: string; phase?: string; output?: string; tokens?: number }>;
totals?: { tokens?: number };
};
const finalOutput = c.final_output ?? "";
const steps = c.steps ?? [];
const tokens = c.totals?.tokens ?? 0;
setMeta({ kind: d.kind, status: d.status, tokens, steps: steps.length });
if (finalOutput) {
setText(finalOutput);
} else if (steps.length > 0) {
setText(
steps
.map(
(s, i) =>
`━━━ step ${i} · [${s.role ?? "?"}] ${s.phase ?? ""} · ${s.tokens ?? 0}t ━━━\n\n${s.output ?? ""}`,
)
.join("\n\n"),
);
} else {
setText(JSON.stringify(d.comparison, null, 2));
}
} catch (e) {
setError(e instanceof Error ? e.message : "fetch failed");
} finally {
setLoading(false);
}
})();
return () => {
live = false;
};
}, [runId]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
async function copy() {
try {
await navigator.clipboard.writeText(text);
} catch {
/* ignore */
}
}
function download() {
const blob = new Blob([text], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `run-${runId.slice(0, 8)}.md`;
a.click();
URL.revokeObjectURL(url);
}
return (
<div
role="dialog"
aria-modal="true"
onClick={onClose}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,.65)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
zIndex: 1000,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "min(1080px, 100%)",
maxHeight: "90vh",
display: "flex",
flexDirection: "column",
background: "#0b0b0e",
border: "1px solid rgba(255,255,255,.12)",
borderRadius: 12,
overflow: "hidden",
}}
>
<header
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "12px 16px",
borderBottom: "1px solid rgba(255,255,255,.08)",
fontFamily: mono,
fontSize: 11,
color: "#8a8a92",
}}
>
<span style={{ color: "#e5f6fb" }}>run · {runId.slice(0, 8)}</span>
{meta.kind && <span>· {meta.kind}</span>}
{meta.status && <span>· {meta.status}</span>}
{meta.steps ? <span>· {meta.steps} steps</span> : null}
{meta.tokens ? <span>· {meta.tokens.toLocaleString()} tokens</span> : null}
<span style={{ flex: 1 }} />
<button type="button" onClick={copy} style={modalBtn}>
Copy
</button>
<button type="button" onClick={download} style={modalBtn}>
Download .md
</button>
<button type="button" onClick={onClose} style={modalBtn}>
Close
</button>
</header>
<div
style={{
flex: 1,
minHeight: 0,
overflow: "auto",
padding: 20,
}}
>
{loading ? (
<p style={{ color: "#8a8a92", fontFamily: mono, fontSize: 12 }}>Loading</p>
) : error ? (
<p style={{ color: "#ff8a7a", fontFamily: mono, fontSize: 12 }}>{error}</p>
) : (
<pre
style={{
margin: 0,
fontFamily: mono,
fontSize: 12,
lineHeight: 1.6,
color: "#cfcfd5",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{text}
</pre>
)}
</div>
</div>
</div>
);
}
const modalBtn: React.CSSProperties = {
padding: "4px 10px",
borderRadius: 6,
background: "transparent",
border: "1px solid rgba(255,255,255,.15)",
color: "#cfcfd5",
fontFamily: mono,
fontSize: 10,
cursor: "pointer",
};
function StatusPill({ status }: { status: string }) { function StatusPill({ status }: { status: string }) {
const color = const color =
status === "completed" status === "completed"
+19
View File
@@ -165,3 +165,22 @@ export const listLoopRuns = (loopId: string, limit = 20) =>
api<RunSummary[]>( api<RunSummary[]>(
`/api/topology-runs?loop_id=${encodeURIComponent(loopId)}&limit=${limit}`, `/api/topology-runs?loop_id=${encodeURIComponent(loopId)}&limit=${limit}`,
); );
/** Full detail for one topology_run. `comparison` carries the run's
* serialized result — for a completed exec/research run this includes
* `final_output` (the produced markdown / code) and `steps` (per-node
* outputs). Used by the "View full output" modal on an iteration. */
export interface RunDetail {
id: string;
task: string;
kind: string;
status: string;
error: string | null;
created_at: string;
updated_at: string;
comparison: unknown;
checkpoint: unknown;
}
export const getRunDetail = (runId: string) =>
api<RunDetail>(`/api/topology-runs/${encodeURIComponent(runId)}`);