missions: collapse each run turn + collapse phase summary card
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 27s
ci / rust (push) Successful in 4m30s
ci / e2e (push) Skipped
ci / publish (push) Successful in 36s

- RunOutputPanel: each turn now renders as <details> with a 1-line
  peek in the summary. First turn open by default (so operators see
  something without a click), subsequent turns collapsed. Same shape
  applies to research + coding runs (shared component).
- PhaseSummaryCard: click the header to collapse the whole card;
  narrative peek shows in the collapsed state. State persisted per
  phase_id in localStorage so it stays remembered across visits.
- PhaseSummaryCard Section: cap max height at 280px with internal
  scroll so long tooling / sources / next-action lists dont blow
  out the card height.
This commit is contained in:
Omar Sobh
2026-07-23 19:26:28 -07:00
parent c4ecb9baa4
commit 9a23c851e0
2 changed files with 145 additions and 61 deletions
@@ -8,9 +8,11 @@
// 404 while `phase_summarizer` hasn't run yet — shows a "waiting"
// pill in that case instead of an error.
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { getPhaseSummary, type PhaseSummary } from "@/lib/api/missions";
const COLLAPSE_KEY_PREFIX = "cm.mission.summary.collapsed:";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
@@ -24,6 +26,20 @@ export function PhaseSummaryCard({
const [summary, setSummary] = useState<PhaseSummary | null>(null);
const [waiting, setWaiting] = useState(true);
const [err, setErr] = useState<string | null>(null);
const collapseKey = `${COLLAPSE_KEY_PREFIX}${phaseId}`;
const [collapsed, setCollapsed] = useState<boolean>(() => {
if (typeof window === "undefined") return false;
return window.localStorage.getItem(collapseKey) === "1";
});
const toggleCollapsed = useCallback(() => {
setCollapsed((v) => {
const next = !v;
if (typeof window !== "undefined") {
window.localStorage.setItem(collapseKey, next ? "1" : "0");
}
return next;
});
}, [collapseKey]);
useEffect(() => {
let alive = true;
@@ -122,6 +138,16 @@ export function PhaseSummaryCard({
}
const metrics = Object.entries(summary.metrics ?? {});
// First line of narrative doubles as a peek when collapsed.
const narrativeFirstLine =
summary.narrative
.split("\n")
.find((l) => l.trim().length > 0)
?.trim() ?? "";
const narrativePeek =
narrativeFirstLine.length > 180
? `${narrativeFirstLine.slice(0, 180)}`
: narrativeFirstLine;
return (
<div
style={{
@@ -138,31 +164,54 @@ export function PhaseSummaryCard({
gap: 10,
}}
>
<div style={{ display: "flex", gap: 8, alignItems: "baseline" }}>
<span
style={{
fontSize: 9,
letterSpacing: ".1em",
textTransform: "uppercase",
color: "#5ec8d8",
}}
>
phase summary · {summary.kind}
</span>
<span style={{ fontSize: 9, color: "#6a6a72" }}>
{summary.model} · {new Date(summary.generated_at).toLocaleTimeString()}
</span>
</div>
<button
type="button"
onClick={toggleCollapsed}
aria-expanded={!collapsed}
style={{
appearance: "none",
background: "transparent",
border: "none",
padding: 0,
margin: 0,
textAlign: "left",
cursor: "pointer",
color: "inherit",
fontFamily: "inherit",
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
<div style={{ display: "flex", gap: 8, alignItems: "baseline" }}>
<span
style={{
fontSize: 9,
letterSpacing: ".1em",
textTransform: "uppercase",
color: "#5ec8d8",
}}
>
{collapsed ? "▸" : "▾"} phase summary · {summary.kind}
</span>
<span style={{ fontSize: 9, color: "#6a6a72" }}>
{summary.model} · {new Date(summary.generated_at).toLocaleTimeString()}
</span>
</div>
{collapsed && narrativePeek && (
<div style={{ color: "#8a8a92", fontSize: 10 }}>{narrativePeek}</div>
)}
</button>
<div style={{ whiteSpace: "pre-wrap", color: "#e0e0e5", lineHeight: 1.4 }}>
{summary.narrative}
</div>
{metrics.length > 0 && (
<MetricsGrid entries={metrics} />
{!collapsed && (
<div style={{ whiteSpace: "pre-wrap", color: "#e0e0e5", lineHeight: 1.4 }}>
{summary.narrative}
</div>
)}
{summary.sources && summary.sources.length > 0 && (
{!collapsed && metrics.length > 0 && <MetricsGrid entries={metrics} />}
{!collapsed && summary.sources && summary.sources.length > 0 && (
<Section
label="sources consulted"
items={summary.sources.map((s) => ({
@@ -174,7 +223,7 @@ export function PhaseSummaryCard({
/>
)}
{summary.tooling && summary.tooling.length > 0 && (
{!collapsed && summary.tooling && summary.tooling.length > 0 && (
<Section
label="tooling recommendations"
items={summary.tooling.map((t) => ({
@@ -185,7 +234,7 @@ export function PhaseSummaryCard({
/>
)}
{summary.artifacts && summary.artifacts.length > 0 && (
{!collapsed && summary.artifacts && summary.artifacts.length > 0 && (
<Section
label="artifacts saved"
items={summary.artifacts.map((a) => ({
@@ -196,7 +245,7 @@ export function PhaseSummaryCard({
/>
)}
{summary.next_actions && summary.next_actions.length > 0 && (
{!collapsed && summary.next_actions && summary.next_actions.length > 0 && (
<Section
label="next actions"
items={summary.next_actions.map((n) => ({
@@ -277,7 +326,15 @@ function Section({
>
{label}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 4,
maxHeight: 280,
overflow: "auto",
}}
>
{items.map((it, i) => (
<div
key={i}