loops: 'View full output' modal for a completed iteration
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:
@@ -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 "Run now" to fire one.</p>;
|
return <p style={hintStyle}>No iterations yet — hit "Run now" 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"
|
||||||
|
|||||||
@@ -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)}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user