From f2cfaee6fe5d986677ab3e9c9bfb414e2f73d5c6 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 05:27:43 -0700 Subject: [PATCH] feat: jump ecc2 approval queue targets --- ecc2/src/tui/app.rs | 1 + ecc2/src/tui/dashboard.rs | 215 +++++++++++++++++++++++++++++++++++++- 2 files changed, 215 insertions(+), 1 deletion(-) diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index 716c8215..ce6198ac 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -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_all_teams().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.coordinate_backlog().await, (_, KeyCode::Char('v')) => dashboard.toggle_output_mode(), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index a303939a..c5b78d84 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -721,7 +721,7 @@ impl Dashboard { fn render_status_bar(&self, frame: &mut Frame, area: Rect) { 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.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 across lead teams", " 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 Dispatch then rebalance backlog across lead teams", " 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) { if self.active_session_count() >= self.cfg.max_parallel_sessions { tracing::warn!( @@ -2876,6 +2899,44 @@ impl Dashboard { .collect() } + fn next_approval_target_session_id(&self) -> Option { + 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::>(); + + 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) { self.last_output_height = viewport_height.max(1); let max_scroll = self.max_output_scroll(); @@ -4633,6 +4694,158 @@ mod tests { 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] fn selected_session_metrics_text_includes_worktree_output_and_attention_queue() { let mut dashboard = test_dashboard(