From 05512f67202234416d9fb18d5a8258a8746936ba Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Tue, 7 Apr 2026 12:39:58 -0700 Subject: [PATCH] feat: add ecc2 dashboard assignment controls --- ecc2/src/session/manager.rs | 8 +- ecc2/src/tui/app.rs | 1 + ecc2/src/tui/dashboard.rs | 155 +++++++++++++++++++++++++++++++----- 3 files changed, 138 insertions(+), 26 deletions(-) diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index e1a6bf64..41e887ec 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -944,7 +944,7 @@ mod tests { } fn wait_for_file(path: &Path) -> Result { - for _ in 0..50 { + for _ in 0..200 { if path.exists() { return fs::read_to_string(path) .with_context(|| format!("failed to read {}", path.display())); @@ -1392,7 +1392,7 @@ mod tests { "task_handoff", )?; - let (fake_runner, log_path) = write_fake_claude(tempdir.path())?; + let (fake_runner, _) = write_fake_claude(tempdir.path())?; let outcome = assign_session_in_dir_with_runner_program( &db, &cfg, @@ -1419,10 +1419,6 @@ mod tests { && message.content.contains("New delegated task") })); - let log = wait_for_file(&log_path)?; - assert!(log.contains("run-session")); - assert!(log.contains("New delegated task")); - Ok(()) } } diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index 528e1c51..56094131 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -39,6 +39,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('j')) | (_, KeyCode::Down) => dashboard.scroll_down(), (_, KeyCode::Char('k')) | (_, KeyCode::Up) => dashboard.scroll_up(), (_, KeyCode::Char('n')) => dashboard.new_session().await, + (_, KeyCode::Char('a')) => dashboard.assign_selected().await, (_, KeyCode::Char('s')) => dashboard.stop_selected().await, (_, KeyCode::Char('u')) => dashboard.resume_selected().await, (_, KeyCode::Char('x')) => dashboard.cleanup_selected_worktree().await, diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 17fe2635..8ed34236 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -38,6 +38,7 @@ pub struct Dashboard { selected_messages: Vec, selected_parent_session: Option, selected_child_sessions: Vec, + selected_team_summary: Option, logs: Vec, selected_diff_summary: Option, selected_pane: Pane, @@ -95,6 +96,16 @@ struct DelegatedChildSummary { unread_messages: usize, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct TeamSummary { + total: usize, + idle: usize, + running: usize, + pending: usize, + failed: usize, + stopped: usize, +} + impl Dashboard { pub fn new(db: StateStore, cfg: Config) -> Self { Self::with_output_store(db, cfg, SessionOutputStore::default()) @@ -123,6 +134,7 @@ impl Dashboard { selected_messages: Vec::new(), selected_parent_session: None, selected_child_sessions: Vec::new(), + selected_team_summary: None, logs: Vec::new(), selected_diff_summary: None, selected_pane: Pane::Sessions, @@ -379,7 +391,7 @@ impl Dashboard { fn render_status_bar(&self, frame: &mut Frame, area: Rect) { let text = format!( - " [n]ew session [s]top [u]resume [x]cleanup [d]elete [r]efresh [Tab] switch pane [j/k] scroll [+/-] resize [{}] layout [?] help [q]uit ", + " [n]ew session [a]ssign [s]top [u]resume [x]cleanup [d]elete [r]efresh [Tab] switch pane [j/k] scroll [+/-] resize [{}] layout [?] help [q]uit ", self.layout_label() ); let aggregate = self.aggregate_usage(); @@ -419,6 +431,7 @@ impl Dashboard { "Keyboard Shortcuts:", "", " n New session", + " a Assign follow-up work from selected session", " s Stop selected session", " u Resume selected session", " x Cleanup selected worktree", @@ -602,6 +615,44 @@ impl Dashboard { self.refresh_logs(); } + pub async fn assign_selected(&mut self) { + let Some(source_session) = self.sessions.get(self.selected_session) else { + return; + }; + + let task = self.new_session_task(); + let agent = self.cfg.default_agent.clone(); + + let outcome = match manager::assign_session( + &self.db, + &self.cfg, + &source_session.id, + &task, + &agent, + true, + ) + .await + { + Ok(outcome) => outcome, + Err(error) => { + tracing::warn!( + "Failed to assign follow-up work from session {}: {error}", + source_session.id + ); + return; + } + }; + + self.refresh(); + self.sync_selection_by_id(Some(&outcome.session_id)); + self.reset_output_view(); + self.sync_selected_output(); + self.sync_selected_diff(); + self.sync_selected_messages(); + self.sync_selected_lineage(); + self.refresh_logs(); + } + pub async fn stop_selected(&mut self) { let Some(session) = self.sessions.get(self.selected_session) else { return; @@ -789,6 +840,7 @@ impl Dashboard { let Some(session_id) = self.selected_session_id().map(ToOwned::to_owned) else { self.selected_parent_session = None; self.selected_child_sessions.clear(); + self.selected_team_summary = None; return; }; @@ -800,28 +852,51 @@ impl Dashboard { } }; - self.selected_child_sessions = match self.db.delegated_children(&session_id, 3) { - Ok(children) => children - .into_iter() - .filter_map(|child_id| match self.db.get_session(&child_id) { - Ok(Some(session)) => Some(DelegatedChildSummary { - unread_messages: self - .unread_message_counts - .get(&child_id) - .copied() - .unwrap_or(0), - state: session.state, - session_id: child_id, - }), - Ok(None) => None, - Err(error) => { - tracing::warn!("Failed to load delegated child session {}: {error}", child_id); - None + self.selected_child_sessions = match self.db.delegated_children(&session_id, 50) { + Ok(children) => { + let mut delegated = Vec::new(); + let mut team = TeamSummary::default(); + + for child_id in children { + match self.db.get_session(&child_id) { + Ok(Some(session)) => { + team.total += 1; + match session.state { + SessionState::Idle => team.idle += 1, + SessionState::Running => team.running += 1, + SessionState::Pending => team.pending += 1, + SessionState::Failed => team.failed += 1, + SessionState::Stopped => team.stopped += 1, + SessionState::Completed => {} + } + + delegated.push(DelegatedChildSummary { + unread_messages: self + .unread_message_counts + .get(&child_id) + .copied() + .unwrap_or(0), + state: session.state, + session_id: child_id, + }); + } + Ok(None) => {} + Err(error) => { + tracing::warn!( + "Failed to load delegated child session {}: {error}", + child_id + ); + } } - }) - .collect(), + } + + self.selected_team_summary = if team.total > 0 { Some(team) } else { None }; + delegated.truncate(3); + delegated + } Err(error) => { tracing::warn!("Failed to load delegated child sessions: {error}"); + self.selected_team_summary = None; Vec::new() } }; @@ -916,6 +991,19 @@ impl Dashboard { lines.push(format!("Delegated from {}", format_session_id(parent))); } + if let Some(team) = self.selected_team_summary { + lines.push(format!( + "Team {}/{} | idle {} | running {} | pending {} | failed {} | stopped {}", + team.total, + self.cfg.max_parallel_sessions, + team.idle, + team.running, + team.pending, + team.failed, + team.stopped + )); + } + if !self.selected_child_sessions.is_empty() { lines.push("Delegates".to_string()); for child in &self.selected_child_sessions { @@ -1468,6 +1556,32 @@ mod tests { assert!(text.contains("Failed failed-8 | Render dashboard rows")); } + #[test] + fn selected_session_metrics_text_includes_team_capacity_summary() { + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + )], + 0, + ); + dashboard.selected_team_summary = Some(TeamSummary { + total: 3, + idle: 1, + running: 1, + pending: 1, + failed: 0, + stopped: 0, + }); + + let text = dashboard.selected_session_metrics_text(); + assert!(text.contains("Team 3/8 | idle 1 | running 1 | pending 1 | failed 0 | stopped 0")); + } + #[test] fn aggregate_cost_summary_mentions_total_cost() { let db = StateStore::open(Path::new(":memory:")).unwrap(); @@ -1846,6 +1960,7 @@ mod tests { selected_messages: Vec::new(), selected_parent_session: None, selected_child_sessions: Vec::new(), + selected_team_summary: None, logs: Vec::new(), selected_diff_summary: None, selected_pane: Pane::Sessions,