loops: N/M INTs progress pill on source-bound loop cards
Users couldn't see how a research-bound loop was progressing through
its integration plan — the consumed_int_ids state existed in the DB
but nothing surfaced it. Now each loop card in the sidebar shows a
cyan pill "3/8 INTs" + source topic title + a thin progress bar, when
the loop is bound to a research topic.
Backend:
- GET /api/loops/progress — bulk read for every loop with a source
topic. Returns {loop_id, source_topic_id, source_topic_title,
source_outcome_version, consumed_count, total_int_count,
current_int_index} per loop. Standalone loops are omitted.
- count_int_ids parses unique INT-<number> ids out of the source
outcome's markdown — same permissive matcher as the completion
hook, so what the pill counts matches what the loop can advance.
- Memoized by topic_id inside the endpoint so N loops sharing 1
source topic only fetch the outcome once.
Frontend:
- listLoopProgress helper + LoopProgress type in the loops API.
- LoopsList fetches loops + progress in parallel on mount.
- Each card looks up progress by loop_id and, when found, renders
under the schedule line: pill "3/8 INTs · Topic title" with a
3px cyan progress bar. Title hover shows artifact version.
Follow-ups:
- Refresh button on the card — re-snapshot artifact into
task_template (cosmetic; the enqueue path already reads latest).
- Reorder rationale extraction — parse "REORDER: <text>" out of run
output, index as a per-loop event log for a mini-timeline UI.
This commit is contained in:
@@ -450,6 +450,7 @@ pub fn router(state: AppState) -> Router {
|
|||||||
"/api/loops",
|
"/api/loops",
|
||||||
get(routes::loops::list_loops).post(routes::loops::create_loop),
|
get(routes::loops::list_loops).post(routes::loops::create_loop),
|
||||||
)
|
)
|
||||||
|
.route("/api/loops/progress", get(routes::loops::list_progress))
|
||||||
.route(
|
.route(
|
||||||
"/api/loops/{id}",
|
"/api/loops/{id}",
|
||||||
get(routes::loops::get_loop)
|
get(routes::loops::get_loop)
|
||||||
|
|||||||
@@ -351,6 +351,106 @@ pub async fn get_loop(
|
|||||||
Ok(Json(hydrate_staffing(&state.pool, inner).await?))
|
Ok(Json(hydrate_staffing(&state.pool, inner).await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct LoopProgress {
|
||||||
|
pub loop_id: Uuid,
|
||||||
|
pub source_topic_id: Uuid,
|
||||||
|
pub source_topic_title: String,
|
||||||
|
pub source_outcome_version: i32,
|
||||||
|
pub consumed_count: usize,
|
||||||
|
pub total_int_count: usize,
|
||||||
|
pub current_int_index: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/loops/progress` — bulk progress read for every loop in the
|
||||||
|
/// workspace that's bound to a research topic. Skips standalone loops
|
||||||
|
/// entirely (empty entry). Parses INT-XX ids from the source outcome's
|
||||||
|
/// markdown to compute the total; consumed count comes straight from
|
||||||
|
/// `consumed_int_ids`. Used by the loops sidebar to render an
|
||||||
|
/// "N/M INTs" pill on each source-bound card.
|
||||||
|
///
|
||||||
|
/// Cost: one query for the loops list + one outcome fetch per unique
|
||||||
|
/// source topic (memoized in the loop below). No N+1 on the topic
|
||||||
|
/// lookup when many loops share a source.
|
||||||
|
pub async fn list_progress(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
) -> Result<Json<Vec<LoopProgress>>, ApiError> {
|
||||||
|
let loops = cm_db::repo::loops::list(&state.pool, user.workspace_id.as_uuid()).await?;
|
||||||
|
let mut by_topic: std::collections::HashMap<Uuid, (String, i32, usize)> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for l in &loops {
|
||||||
|
let ctx = match cm_db::repo::loops::source_research_context(&state.pool, l.id).await {
|
||||||
|
Ok(Some(c)) => c,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let (topic_id, consumed, current_idx) = ctx;
|
||||||
|
let (title, version, total) = match by_topic.get(&topic_id) {
|
||||||
|
Some(cached) => cached.clone(),
|
||||||
|
None => {
|
||||||
|
// Ownership check via get + then count INTs in the latest
|
||||||
|
// outcome. Any failure downgrades to (title, 0, 0) so the
|
||||||
|
// pill still renders — showing 3/0 is better than 500ing
|
||||||
|
// the whole list.
|
||||||
|
let topic = match cm_db::repo::research_topics::get(
|
||||||
|
&state.pool,
|
||||||
|
topic_id,
|
||||||
|
user.workspace_id.as_uuid(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(t)) => t,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let outcome = cm_db::repo::research_outcomes::latest(&state.pool, topic_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(None);
|
||||||
|
let (version, total) = match &outcome {
|
||||||
|
Some(o) => (o.version, count_int_ids(&o.body_md)),
|
||||||
|
None => (0, 0),
|
||||||
|
};
|
||||||
|
let cached = (topic.title.clone(), version, total);
|
||||||
|
by_topic.insert(topic_id, cached.clone());
|
||||||
|
cached
|
||||||
|
}
|
||||||
|
};
|
||||||
|
out.push(LoopProgress {
|
||||||
|
loop_id: l.id,
|
||||||
|
source_topic_id: topic_id,
|
||||||
|
source_topic_title: title,
|
||||||
|
source_outcome_version: version,
|
||||||
|
consumed_count: consumed.len(),
|
||||||
|
total_int_count: total,
|
||||||
|
current_int_index: current_idx,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Json(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count unique INT-<number> ids in a markdown blob. Case-insensitive,
|
||||||
|
/// tolerates prefixes like `### INT-01` and inline references. Same
|
||||||
|
/// permissive matcher used by the completion-marker parser, so what the
|
||||||
|
/// pill counts matches what the completion path can advance against.
|
||||||
|
fn count_int_ids(text: &str) -> usize {
|
||||||
|
let upper = text.to_ascii_uppercase();
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while let Some(pos) = upper[i..].find("INT-") {
|
||||||
|
let start = i + pos + 4;
|
||||||
|
let end = start
|
||||||
|
+ upper[start..]
|
||||||
|
.chars()
|
||||||
|
.take_while(|c| c.is_ascii_digit())
|
||||||
|
.count();
|
||||||
|
if end > start {
|
||||||
|
seen.insert(upper[start..end].parse::<u32>().ok());
|
||||||
|
}
|
||||||
|
i = end.max(i + pos + 4);
|
||||||
|
}
|
||||||
|
seen.into_iter().flatten().count()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct UpdateLoopRequest {
|
pub struct UpdateLoopRequest {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ import {
|
|||||||
deleteLoop,
|
deleteLoop,
|
||||||
disableLoop,
|
disableLoop,
|
||||||
enableLoop,
|
enableLoop,
|
||||||
|
listLoopProgress,
|
||||||
listLoops,
|
listLoops,
|
||||||
type Loop,
|
type Loop,
|
||||||
|
type LoopProgress,
|
||||||
} from "@/lib/api/loops";
|
} from "@/lib/api/loops";
|
||||||
import type { Agent } from "@/lib/api/schemas";
|
import type { Agent } from "@/lib/api/schemas";
|
||||||
|
|
||||||
@@ -43,6 +45,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 [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);
|
||||||
@@ -56,8 +59,14 @@ export function LoopsList({
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const rows = await listLoops();
|
const [rows, prog] = await Promise.all([
|
||||||
if (alive) setLoops(rows);
|
listLoops(),
|
||||||
|
listLoopProgress().catch(() => [] as LoopProgress[]),
|
||||||
|
]);
|
||||||
|
if (alive) {
|
||||||
|
setLoops(rows);
|
||||||
|
setProgress(new Map(prog.map((p) => [p.loop_id, p])));
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (alive) setLoops([]);
|
if (alive) setLoops([]);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -256,6 +265,74 @@ export function LoopsList({
|
|||||||
: "no schedule"}
|
: "no schedule"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const p = progress.get(l.id);
|
||||||
|
if (!p) return null;
|
||||||
|
const done = p.consumed_count;
|
||||||
|
const total = p.total_int_count;
|
||||||
|
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
marginTop: 2,
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 9.5,
|
||||||
|
color: "#5ec8d8",
|
||||||
|
}}
|
||||||
|
title={`Based on research topic "${p.source_topic_title}" (artifact v${p.source_outcome_version})`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: "2px 6px",
|
||||||
|
borderRadius: 4,
|
||||||
|
border: "1px solid rgba(94,200,216,.35)",
|
||||||
|
background: "rgba(94,200,216,.08)",
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{done}/{total || "?"} INTs
|
||||||
|
</span>
|
||||||
|
<span style={{ opacity: 0.7, color: "#8a8a92" }}>·</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
opacity: 0.7,
|
||||||
|
color: "#8a8a92",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
maxWidth: 200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.source_topic_title}
|
||||||
|
</span>
|
||||||
|
{total > 0 ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: 3,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: "rgba(255,255,255,.06)",
|
||||||
|
overflow: "hidden",
|
||||||
|
minWidth: 20,
|
||||||
|
marginLeft: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
width: `${pct}%`,
|
||||||
|
height: "100%",
|
||||||
|
background: "#5ec8d8",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{confirming ? (
|
{confirming ? (
|
||||||
|
|||||||
@@ -66,6 +66,23 @@ 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 LoopProgress {
|
||||||
|
loop_id: string;
|
||||||
|
source_topic_id: string;
|
||||||
|
source_topic_title: string;
|
||||||
|
source_outcome_version: number;
|
||||||
|
consumed_count: number;
|
||||||
|
total_int_count: number;
|
||||||
|
current_int_index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bulk progress read — one entry per loop bound to a research topic.
|
||||||
|
* Standalone loops (no source) are omitted. Used by the sidebar to
|
||||||
|
* render an "N/M INTs" pill on source-bound loop cards. */
|
||||||
|
export const listLoopProgress = () =>
|
||||||
|
api<LoopProgress[]>("/api/loops/progress");
|
||||||
|
|
||||||
export const getLoop = (id: string) => api<Loop>(`/api/loops/${id}`);
|
export const getLoop = (id: string) => api<Loop>(`/api/loops/${id}`);
|
||||||
|
|
||||||
export const createLoop = (body: {
|
export const createLoop = (body: {
|
||||||
|
|||||||
Reference in New Issue
Block a user