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
@@ -196,40 +196,67 @@ function RunOutputPanel({ runId }: { runId: string }) {
to reach their working directory or found nothing to act on. to reach their working directory or found nothing to act on.
</span> </span>
) : ( ) : (
data.outputs.map((o, i) => ( data.outputs.map((o, i) => {
<div key={i}> // First turn open by default so the operator sees something
<div // without a click; every subsequent turn collapses so the
style={{ // panel stays glanceable — click to expand each turn.
color: "#5ec8d8", const firstLine =
fontSize: 9, o.preview
letterSpacing: ".08em", .split("\n")
textTransform: "uppercase", .find((l) => l.trim().length > 0)
marginBottom: 3, ?.trim() ?? "";
}} const preview =
> firstLine.length > 140 ? `${firstLine.slice(0, 140)}…` : firstLine;
turn {i + 1} return (
{o.truncated <details key={i} open={i === 0}>
? ` · showing first ${o.preview.length.toLocaleString()} of ${o.full_len.toLocaleString()} chars` <summary
: ""} style={{
</div> cursor: "pointer",
<pre color: "#5ec8d8",
style={{ fontSize: 10,
margin: 0, letterSpacing: ".06em",
padding: 6, textTransform: "uppercase",
borderRadius: 4, padding: "3px 0",
background: "rgba(0,0,0,.5)", listStyle: "revert",
color: "#e0e0e5", }}
fontSize: 10.5, >
whiteSpace: "pre-wrap", turn {i + 1}
wordBreak: "break-word", {o.truncated
maxHeight: 300, ? ` · ${o.preview.length.toLocaleString()}/${o.full_len.toLocaleString()} chars`
overflow: "auto", : ""}
}} {preview && (
> <span
{o.preview} style={{
</pre> color: "#8a8a92",
</div> textTransform: "none",
)) letterSpacing: 0,
marginLeft: 8,
fontSize: 10,
}}
>
· {preview}
</span>
)}
</summary>
<pre
style={{
margin: "4px 0 0",
padding: 6,
borderRadius: 4,
background: "rgba(0,0,0,.5)",
color: "#e0e0e5",
fontSize: 10.5,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
maxHeight: 300,
overflow: "auto",
}}
>
{o.preview}
</pre>
</details>
);
})
)} )}
</div> </div>
); );
@@ -8,9 +8,11 @@
// 404 while `phase_summarizer` hasn't run yet — shows a "waiting" // 404 while `phase_summarizer` hasn't run yet — shows a "waiting"
// pill in that case instead of an error. // 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"; import { getPhaseSummary, type PhaseSummary } from "@/lib/api/missions";
const COLLAPSE_KEY_PREFIX = "cm.mission.summary.collapsed:";
const mono = const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace"; "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 [summary, setSummary] = useState<PhaseSummary | null>(null);
const [waiting, setWaiting] = useState(true); const [waiting, setWaiting] = useState(true);
const [err, setErr] = useState<string | null>(null); 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(() => { useEffect(() => {
let alive = true; let alive = true;
@@ -122,6 +138,16 @@ export function PhaseSummaryCard({
} }
const metrics = Object.entries(summary.metrics ?? {}); 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 ( return (
<div <div
style={{ style={{
@@ -138,31 +164,54 @@ export function PhaseSummaryCard({
gap: 10, gap: 10,
}} }}
> >
<div style={{ display: "flex", gap: 8, alignItems: "baseline" }}> <button
<span type="button"
style={{ onClick={toggleCollapsed}
fontSize: 9, aria-expanded={!collapsed}
letterSpacing: ".1em", style={{
textTransform: "uppercase", appearance: "none",
color: "#5ec8d8", background: "transparent",
}} border: "none",
> padding: 0,
phase summary · {summary.kind} margin: 0,
</span> textAlign: "left",
<span style={{ fontSize: 9, color: "#6a6a72" }}> cursor: "pointer",
{summary.model} · {new Date(summary.generated_at).toLocaleTimeString()} color: "inherit",
</span> fontFamily: "inherit",
</div> 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 }}> {!collapsed && (
{summary.narrative} <div style={{ whiteSpace: "pre-wrap", color: "#e0e0e5", lineHeight: 1.4 }}>
</div> {summary.narrative}
</div>
{metrics.length > 0 && (
<MetricsGrid entries={metrics} />
)} )}
{summary.sources && summary.sources.length > 0 && ( {!collapsed && metrics.length > 0 && <MetricsGrid entries={metrics} />}
{!collapsed && summary.sources && summary.sources.length > 0 && (
<Section <Section
label="sources consulted" label="sources consulted"
items={summary.sources.map((s) => ({ 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 <Section
label="tooling recommendations" label="tooling recommendations"
items={summary.tooling.map((t) => ({ 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 <Section
label="artifacts saved" label="artifacts saved"
items={summary.artifacts.map((a) => ({ 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 <Section
label="next actions" label="next actions"
items={summary.next_actions.map((n) => ({ items={summary.next_actions.map((n) => ({
@@ -277,7 +326,15 @@ function Section({
> >
{label} {label}
</div> </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) => ( {items.map((it, i) => (
<div <div
key={i} key={i}