feat: jump ecc2 approval queue targets

This commit is contained in:
Affaan Mustafa
2026-04-09 05:27:43 -07:00
parent dc36a636af
commit f2cfaee6fe
2 changed files with 215 additions and 1 deletions

View File

@@ -73,6 +73,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
(_, KeyCode::Char('b')) => dashboard.rebalance_selected_team().await, (_, KeyCode::Char('b')) => dashboard.rebalance_selected_team().await,
(_, KeyCode::Char('B')) => dashboard.rebalance_all_teams().await, (_, KeyCode::Char('B')) => dashboard.rebalance_all_teams().await,
(_, KeyCode::Char('i')) => dashboard.drain_inbox_selected().await, (_, KeyCode::Char('i')) => dashboard.drain_inbox_selected().await,
(_, KeyCode::Char('I')) => dashboard.focus_next_approval_target(),
(_, KeyCode::Char('g')) => dashboard.auto_dispatch_backlog().await, (_, KeyCode::Char('g')) => dashboard.auto_dispatch_backlog().await,
(_, KeyCode::Char('G')) => dashboard.coordinate_backlog().await, (_, KeyCode::Char('G')) => dashboard.coordinate_backlog().await,
(_, KeyCode::Char('v')) => dashboard.toggle_output_mode(), (_, KeyCode::Char('v')) => dashboard.toggle_output_mode(),

View File

@@ -721,7 +721,7 @@ impl Dashboard {
fn render_status_bar(&self, frame: &mut Frame, area: Rect) { fn render_status_bar(&self, frame: &mut Frame, area: Rect) {
let base_text = format!( let base_text = format!(
" [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox [g]lobal dispatch coordinate [G]lobal [v]iew diff conflict proto[c]ol cont[e]nt filter time [f]ilter search scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] auto-merge [w] toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [Tab] switch pane [j/k] scroll delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", " [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox approval jump [I] [g]lobal dispatch coordinate [G]lobal [v]iew diff conflict proto[c]ol cont[e]nt filter time [f]ilter search scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] auto-merge [w] toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [Tab] switch pane [j/k] scroll delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ",
self.layout_label(), self.layout_label(),
self.theme_label() self.theme_label()
); );
@@ -802,6 +802,7 @@ impl Dashboard {
" b Rebalance backed-up delegate handoff backlog for selected lead", " b Rebalance backed-up delegate handoff backlog for selected lead",
" B Rebalance backed-up delegate handoff backlog across lead teams", " B Rebalance backed-up delegate handoff backlog across lead teams",
" i Drain unread task handoffs from selected lead", " i Drain unread task handoffs from selected lead",
" I Jump to the next unread approval/conflict target session",
" g Auto-dispatch unread handoffs across lead sessions", " g Auto-dispatch unread handoffs across lead sessions",
" G Dispatch then rebalance backlog across lead teams", " G Dispatch then rebalance backlog across lead teams",
" v Toggle selected worktree diff in output pane", " v Toggle selected worktree diff in output pane",
@@ -1178,6 +1179,28 @@ impl Dashboard {
)); ));
} }
pub fn focus_next_approval_target(&mut self) {
self.sync_approval_queue();
let Some(target_session_id) = self.next_approval_target_session_id() else {
self.set_operator_note("approval queue clear".to_string());
return;
};
self.sync_selection_by_id(Some(&target_session_id));
self.reset_output_view();
self.reset_metrics_view();
self.sync_selected_output();
self.sync_selected_diff();
self.unread_message_counts = self.db.unread_message_counts().unwrap_or_default();
self.sync_selected_messages();
self.sync_selected_lineage();
self.refresh_logs();
self.set_operator_note(format!(
"focused approval target {}",
format_session_id(&target_session_id)
));
}
pub async fn new_session(&mut self) { pub async fn new_session(&mut self) {
if self.active_session_count() >= self.cfg.max_parallel_sessions { if self.active_session_count() >= self.cfg.max_parallel_sessions {
tracing::warn!( tracing::warn!(
@@ -2876,6 +2899,44 @@ impl Dashboard {
.collect() .collect()
} }
fn next_approval_target_session_id(&self) -> Option<String> {
let pending_items: usize = self.approval_queue_counts.values().sum();
if pending_items == 0 {
return None;
}
let active_session_ids: HashSet<_> =
self.sessions.iter().map(|session| &session.id).collect();
let queue = self.db.unread_approval_queue(pending_items).ok()?;
let mut seen = HashSet::new();
let ordered_targets = queue
.into_iter()
.filter_map(|message| {
if active_session_ids.contains(&message.to_session)
&& seen.insert(message.to_session.clone())
{
Some(message.to_session)
} else {
None
}
})
.collect::<Vec<_>>();
if ordered_targets.is_empty() {
return None;
}
let current_session_id = self.selected_session_id();
current_session_id
.and_then(|session_id| {
ordered_targets
.iter()
.position(|target_session_id| target_session_id == session_id)
.map(|index| ordered_targets[(index + 1) % ordered_targets.len()].clone())
})
.or_else(|| ordered_targets.first().cloned())
}
fn sync_output_scroll(&mut self, viewport_height: usize) { fn sync_output_scroll(&mut self, viewport_height: usize) {
self.last_output_height = viewport_height.max(1); self.last_output_height = viewport_height.max(1);
let max_scroll = self.max_output_scroll(); let max_scroll = self.max_output_scroll();
@@ -4633,6 +4694,158 @@ mod tests {
assert!(dashboard.approval_queue_preview.is_empty()); assert!(dashboard.approval_queue_preview.is_empty());
} }
#[test]
fn focus_next_approval_target_selects_oldest_unread_target() {
let sessions = vec![
sample_session(
"lead-12345678",
"planner",
SessionState::Running,
Some("ecc/lead"),
512,
42,
),
sample_session(
"worker-a",
"reviewer",
SessionState::Idle,
Some("ecc/worker-a"),
64,
5,
),
sample_session(
"worker-b",
"reviewer",
SessionState::Idle,
Some("ecc/worker-b"),
64,
5,
),
];
let mut dashboard = test_dashboard(sessions, 0);
for session in &dashboard.sessions {
dashboard.db.insert_session(session).unwrap();
}
dashboard
.db
.send_message(
"lead-12345678",
"worker-b",
"{\"question\":\"Need approval on B\"}",
"query",
)
.unwrap();
dashboard
.db
.send_message(
"lead-12345678",
"worker-a",
"{\"question\":\"Need approval on A\"}",
"query",
)
.unwrap();
dashboard.sync_approval_queue();
dashboard.focus_next_approval_target();
assert_eq!(dashboard.selected_session_id(), Some("worker-b"));
assert_eq!(
dashboard.operator_note.as_deref(),
Some("focused approval target worker-b")
);
}
#[test]
fn focus_next_approval_target_cycles_distinct_targets() {
let sessions = vec![
sample_session(
"lead-12345678",
"planner",
SessionState::Running,
Some("ecc/lead"),
512,
42,
),
sample_session(
"worker-a",
"reviewer",
SessionState::Idle,
Some("ecc/worker-a"),
64,
5,
),
sample_session(
"worker-b",
"reviewer",
SessionState::Idle,
Some("ecc/worker-b"),
64,
5,
),
];
let mut dashboard = test_dashboard(sessions, 1);
for session in &dashboard.sessions {
dashboard.db.insert_session(session).unwrap();
}
dashboard
.db
.send_message(
"lead-12345678",
"worker-a",
"{\"question\":\"Need approval on A\"}",
"query",
)
.unwrap();
dashboard
.db
.send_message(
"lead-12345678",
"worker-a",
"{\"question\":\"Need another approval on A\"}",
"conflict",
)
.unwrap();
dashboard
.db
.send_message(
"lead-12345678",
"worker-b",
"{\"question\":\"Need approval on B\"}",
"query",
)
.unwrap();
dashboard.sync_approval_queue();
dashboard.focus_next_approval_target();
assert_eq!(dashboard.selected_session_id(), Some("worker-b"));
assert_eq!(dashboard.approval_queue_counts.get("worker-a"), Some(&2));
assert_eq!(dashboard.approval_queue_counts.get("worker-b"), None);
}
#[test]
fn focus_next_approval_target_reports_clear_queue() {
let mut dashboard = test_dashboard(
vec![sample_session(
"lead-12345678",
"planner",
SessionState::Running,
Some("ecc/lead"),
512,
42,
)],
0,
);
dashboard.focus_next_approval_target();
assert_eq!(dashboard.selected_session_id(), Some("lead-12345678"));
assert_eq!(
dashboard.operator_note.as_deref(),
Some("approval queue clear")
);
}
#[test] #[test]
fn selected_session_metrics_text_includes_worktree_output_and_attention_queue() { fn selected_session_metrics_text_includes_worktree_output_and_attention_queue() {
let mut dashboard = test_dashboard( let mut dashboard = test_dashboard(