loops: reorder history collapsed under the progress pill
Completes the reorder rationale loop — the previous commit captured
REORDER: markers server-side but nothing surfaced them. Now the loops
sidebar renders a small "N reorders ▸" button under the progress pill
whenever the loop has any reorder events. Click expands to a
newest-first list of "iter <n> · <rationale>" lines so a reviewer can
see, at a glance, when the plan was adjusted and why.
Backend:
- LoopProgress DTO gains recent_reorders: Vec<Value> — newest-first,
capped at 5 so the card stays compact. Full history remains on the
loop row's reorder_events column.
- cm_db::repo::loops::recent_reorders — reads the jsonb array, returns
the last N in newest-first order.
- list_progress populates it per loop.
Frontend:
- LoopReorderEvent + recent_reorders on LoopProgress type.
- LoopsList tracks openHistoryId per-loop (one open at a time).
- Card renders history button + expanded panel styled to match the
progress pill above.
Notes:
- The event object schema is {run_id, iteration, text, ts}. Fields are
optional in the TS type so future schema tweaks don't break the
render.
- 5-item cap chosen so the sidebar card doesn't grow unbounded. If a
loop accumulates a lot of reorders, follow-up UI can render the full
history on the loop detail page.
This commit is contained in:
@@ -363,6 +363,10 @@ pub struct LoopProgress {
|
|||||||
pub consumed_count: usize,
|
pub consumed_count: usize,
|
||||||
pub total_int_count: usize,
|
pub total_int_count: usize,
|
||||||
pub current_int_index: i32,
|
pub current_int_index: i32,
|
||||||
|
/// Recent coordinator-issued reorders on this loop — newest first,
|
||||||
|
/// capped at 5 so the sidebar card stays compact. Full history is
|
||||||
|
/// on the loop row's reorder_events column.
|
||||||
|
pub recent_reorders: Vec<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/loops/progress` — bulk progress read for every loop in the
|
/// `GET /api/loops/progress` — bulk progress read for every loop in the
|
||||||
@@ -418,6 +422,9 @@ pub async fn list_progress(
|
|||||||
cached
|
cached
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let recent = cm_db::repo::loops::recent_reorders(&state.pool, l.id, 5)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
out.push(LoopProgress {
|
out.push(LoopProgress {
|
||||||
loop_id: l.id,
|
loop_id: l.id,
|
||||||
source_topic_id: topic_id,
|
source_topic_id: topic_id,
|
||||||
@@ -426,6 +433,7 @@ pub async fn list_progress(
|
|||||||
consumed_count: consumed.len(),
|
consumed_count: consumed.len(),
|
||||||
total_int_count: total,
|
total_int_count: total,
|
||||||
current_int_index: current_idx,
|
current_int_index: current_idx,
|
||||||
|
recent_reorders: recent,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(Json(out))
|
Ok(Json(out))
|
||||||
|
|||||||
@@ -254,6 +254,31 @@ pub async fn set_zeroclaw_container(
|
|||||||
/// downstream mini-timeline can show WHEN the plan was adjusted and
|
/// downstream mini-timeline can show WHEN the plan was adjusted and
|
||||||
/// WHY. Idempotent: appending a duplicate text/run_id combo is allowed
|
/// WHY. Idempotent: appending a duplicate text/run_id combo is allowed
|
||||||
/// (rare — indicates the parser matched twice on the same line).
|
/// (rare — indicates the parser matched twice on the same line).
|
||||||
|
/// Read the reorder_events array for a loop, newest-first, capped at
|
||||||
|
/// `limit`. Used by the progress endpoint to surface a compact recent
|
||||||
|
/// history on the sidebar card. Empty array for standalone loops or
|
||||||
|
/// loops whose coordinator hasn't emitted any REORDER markers yet.
|
||||||
|
pub async fn recent_reorders(
|
||||||
|
pool: &PgPool,
|
||||||
|
loop_id: Uuid,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<Value>, DbError> {
|
||||||
|
use sqlx::Row;
|
||||||
|
let row: Option<sqlx::postgres::PgRow> =
|
||||||
|
sqlx::query("SELECT reorder_events FROM loops WHERE id = $1")
|
||||||
|
.bind(loop_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
let arr: Vec<Value> = row
|
||||||
|
.and_then(|r| r.try_get::<Value, _>("reorder_events").ok())
|
||||||
|
.and_then(|v| v.as_array().map(|a| a.clone()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
// Appended in chronological order (oldest → newest); reversing then
|
||||||
|
// taking `limit` yields the newest N in newest-first order.
|
||||||
|
let recent: Vec<Value> = arr.into_iter().rev().take(limit as usize).collect();
|
||||||
|
Ok(recent)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn append_reorder_event(
|
pub async fn append_reorder_event(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
loop_id: Uuid,
|
loop_id: Uuid,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export function LoopsList({
|
|||||||
const noAgents = agents.length === 0;
|
const noAgents = agents.length === 0;
|
||||||
const [loops, setLoops] = useState<Loop[]>([]);
|
const [loops, setLoops] = useState<Loop[]>([]);
|
||||||
const [progress, setProgress] = useState<Map<string, LoopProgress>>(new Map());
|
const [progress, setProgress] = useState<Map<string, LoopProgress>>(new Map());
|
||||||
|
const [openHistoryId, setOpenHistoryId] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Loop | null>(null);
|
const [editing, setEditing] = useState<Loop | null>(null);
|
||||||
@@ -334,6 +335,69 @@ export function LoopsList({
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</button>
|
</button>
|
||||||
|
{(() => {
|
||||||
|
const p = progress.get(l.id);
|
||||||
|
if (!p || p.recent_reorders.length === 0) return null;
|
||||||
|
const open = openHistoryId === l.id;
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 2 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setOpenHistoryId(open ? null : l.id)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
all: "unset",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 9.5,
|
||||||
|
color: "#8a8a92",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<span style={{ opacity: 0.7 }}>{open ? "▾" : "▸"}</span>
|
||||||
|
<span>
|
||||||
|
{p.recent_reorders.length} reorder
|
||||||
|
{p.recent_reorders.length === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 4,
|
||||||
|
padding: "6px 8px",
|
||||||
|
borderRadius: 6,
|
||||||
|
background: "rgba(255,255,255,.02)",
|
||||||
|
border: "1px solid rgba(255,255,255,.05)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.recent_reorders.map((e, i) => (
|
||||||
|
<div
|
||||||
|
key={`${e.run_id ?? "unk"}-${i}`}
|
||||||
|
style={{
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 10,
|
||||||
|
color: "#c3c3c8",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "#8a8a92" }}>
|
||||||
|
iter {e.iteration ?? "?"} ·{" "}
|
||||||
|
</span>
|
||||||
|
{e.text ?? ""}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{confirming ? (
|
{confirming ? (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
|
|
||||||
export const listLoops = () => api<Loop[]>("/api/loops");
|
export const listLoops = () => api<Loop[]>("/api/loops");
|
||||||
|
|
||||||
|
export interface LoopReorderEvent {
|
||||||
|
run_id?: string;
|
||||||
|
iteration?: number;
|
||||||
|
text?: string;
|
||||||
|
ts?: string;
|
||||||
|
}
|
||||||
export interface LoopProgress {
|
export interface LoopProgress {
|
||||||
loop_id: string;
|
loop_id: string;
|
||||||
source_topic_id: string;
|
source_topic_id: string;
|
||||||
@@ -75,6 +81,9 @@ export interface LoopProgress {
|
|||||||
consumed_count: number;
|
consumed_count: number;
|
||||||
total_int_count: number;
|
total_int_count: number;
|
||||||
current_int_index: number;
|
current_int_index: number;
|
||||||
|
/** Newest-first, capped at 5. Empty when the coordinator hasn't
|
||||||
|
* emitted any REORDER: markers yet. */
|
||||||
|
recent_reorders: LoopReorderEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bulk progress read — one entry per loop bound to a research topic.
|
/** Bulk progress read — one entry per loop bound to a research topic.
|
||||||
|
|||||||
Reference in New Issue
Block a user