From a4d0a4fc14b5e4868d572ce0960e3fec1c97e3da Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 20:43:33 -0700 Subject: [PATCH] feat: add ecc2 desktop notifications --- ecc2/src/config/mod.rs | 42 ++++++ ecc2/src/main.rs | 1 + ecc2/src/notifications.rs | 289 ++++++++++++++++++++++++++++++++++++ ecc2/src/session/manager.rs | 9 +- ecc2/src/session/store.rs | 29 ++++ ecc2/src/tui/dashboard.rs | 229 ++++++++++++++++++++++++++-- 6 files changed, 581 insertions(+), 18 deletions(-) create mode 100644 ecc2/src/notifications.rs diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index 165d8363..8259207e 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -3,6 +3,8 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; +use crate::notifications::DesktopNotificationConfig; + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PaneLayout { @@ -45,6 +47,7 @@ pub struct Config { pub auto_dispatch_limit_per_session: usize, pub auto_create_worktrees: bool, pub auto_merge_ready_worktrees: bool, + pub desktop_notifications: DesktopNotificationConfig, pub cost_budget_usd: f64, pub token_budget: u64, pub budget_alert_thresholds: BudgetAlertThresholds, @@ -107,6 +110,7 @@ impl Default for Config { auto_dispatch_limit_per_session: 5, auto_create_worktrees: true, auto_merge_ready_worktrees: false, + desktop_notifications: DesktopNotificationConfig::default(), cost_budget_usd: 10.0, token_budget: 500_000, budget_alert_thresholds: Self::BUDGET_ALERT_THRESHOLDS, @@ -431,6 +435,7 @@ theme = "Dark" config.auto_merge_ready_worktrees, defaults.auto_merge_ready_worktrees ); + assert_eq!(config.desktop_notifications, defaults.desktop_notifications); assert_eq!( config.auto_terminate_stale_sessions, defaults.auto_terminate_stale_sessions @@ -582,6 +587,35 @@ critical = 0.85 ); } + #[test] + fn desktop_notifications_deserialize_from_toml() { + let config: Config = toml::from_str( + r#" +[desktop_notifications] +enabled = true +session_completed = false +session_failed = true +budget_alerts = true +approval_requests = false + +[desktop_notifications.quiet_hours] +enabled = true +start_hour = 21 +end_hour = 7 +"#, + ) + .unwrap(); + + assert!(config.desktop_notifications.enabled); + assert!(!config.desktop_notifications.session_completed); + assert!(config.desktop_notifications.session_failed); + assert!(config.desktop_notifications.budget_alerts); + assert!(!config.desktop_notifications.approval_requests); + assert!(config.desktop_notifications.quiet_hours.enabled); + assert_eq!(config.desktop_notifications.quiet_hours.start_hour, 21); + assert_eq!(config.desktop_notifications.quiet_hours.end_hour, 7); + } + #[test] fn invalid_budget_alert_thresholds_fall_back_to_defaults() { let config: Config = toml::from_str( @@ -608,6 +642,10 @@ critical = 1.10 config.auto_dispatch_limit_per_session = 9; config.auto_create_worktrees = false; config.auto_merge_ready_worktrees = true; + config.desktop_notifications.session_completed = false; + config.desktop_notifications.quiet_hours.enabled = true; + config.desktop_notifications.quiet_hours.start_hour = 21; + config.desktop_notifications.quiet_hours.end_hour = 7; config.worktree_branch_prefix = "bots/ecc".to_string(); config.budget_alert_thresholds = BudgetAlertThresholds { advisory: 0.45, @@ -627,6 +665,10 @@ critical = 1.10 assert_eq!(loaded.auto_dispatch_limit_per_session, 9); assert!(!loaded.auto_create_worktrees); assert!(loaded.auto_merge_ready_worktrees); + assert!(!loaded.desktop_notifications.session_completed); + assert!(loaded.desktop_notifications.quiet_hours.enabled); + assert_eq!(loaded.desktop_notifications.quiet_hours.start_hour, 21); + assert_eq!(loaded.desktop_notifications.quiet_hours.end_hour, 7); assert_eq!(loaded.worktree_branch_prefix, "bots/ecc"); assert_eq!( loaded.budget_alert_thresholds, diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 0147942f..0f043382 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -1,5 +1,6 @@ mod comms; mod config; +mod notifications; mod observability; mod session; mod tui; diff --git a/ecc2/src/notifications.rs b/ecc2/src/notifications.rs new file mode 100644 index 00000000..c4c70711 --- /dev/null +++ b/ecc2/src/notifications.rs @@ -0,0 +1,289 @@ +use anyhow::Result; +use chrono::{DateTime, Local, Timelike}; +use serde::{Deserialize, Serialize}; + +#[cfg(not(test))] +use anyhow::Context; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotificationEvent { + SessionCompleted, + SessionFailed, + BudgetAlert, + ApprovalRequest, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct QuietHoursConfig { + pub enabled: bool, + pub start_hour: u8, + pub end_hour: u8, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DesktopNotificationConfig { + pub enabled: bool, + pub session_completed: bool, + pub session_failed: bool, + pub budget_alerts: bool, + pub approval_requests: bool, + pub quiet_hours: QuietHoursConfig, +} + +#[derive(Debug, Clone)] +pub struct DesktopNotifier { + config: DesktopNotificationConfig, +} + +impl Default for QuietHoursConfig { + fn default() -> Self { + Self { + enabled: false, + start_hour: 22, + end_hour: 8, + } + } +} + +impl QuietHoursConfig { + pub fn sanitized(self) -> Self { + let valid = self.start_hour <= 23 && self.end_hour <= 23; + if valid { + self + } else { + Self::default() + } + } + + pub fn is_active(&self, now: DateTime) -> bool { + if !self.enabled { + return false; + } + + let quiet = self.clone().sanitized(); + if quiet.start_hour == quiet.end_hour { + return false; + } + + let hour = now.hour() as u8; + if quiet.start_hour < quiet.end_hour { + hour >= quiet.start_hour && hour < quiet.end_hour + } else { + hour >= quiet.start_hour || hour < quiet.end_hour + } + } +} + +impl Default for DesktopNotificationConfig { + fn default() -> Self { + Self { + enabled: true, + session_completed: true, + session_failed: true, + budget_alerts: true, + approval_requests: true, + quiet_hours: QuietHoursConfig::default(), + } + } +} + +impl DesktopNotificationConfig { + pub fn sanitized(self) -> Self { + Self { + quiet_hours: self.quiet_hours.sanitized(), + ..self + } + } + + pub fn allows(&self, event: NotificationEvent, now: DateTime) -> bool { + let config = self.clone().sanitized(); + if !config.enabled || config.quiet_hours.is_active(now) { + return false; + } + + match event { + NotificationEvent::SessionCompleted => config.session_completed, + NotificationEvent::SessionFailed => config.session_failed, + NotificationEvent::BudgetAlert => config.budget_alerts, + NotificationEvent::ApprovalRequest => config.approval_requests, + } + } +} + +impl DesktopNotifier { + pub fn new(config: DesktopNotificationConfig) -> Self { + Self { + config: config.sanitized(), + } + } + + pub fn notify(&self, event: NotificationEvent, title: &str, body: &str) -> bool { + match self.try_notify(event, title, body, Local::now()) { + Ok(sent) => sent, + Err(error) => { + tracing::warn!("Failed to send desktop notification: {error}"); + false + } + } + } + + fn try_notify( + &self, + event: NotificationEvent, + title: &str, + body: &str, + now: DateTime, + ) -> Result { + if !self.config.allows(event, now) { + return Ok(false); + } + + let Some((program, args)) = notification_command(std::env::consts::OS, title, body) else { + return Ok(false); + }; + + run_notification_command(&program, &args)?; + Ok(true) + } +} + +fn notification_command(platform: &str, title: &str, body: &str) -> Option<(String, Vec)> { + match platform { + "macos" => Some(( + "osascript".to_string(), + vec![ + "-e".to_string(), + format!( + "display notification \"{}\" with title \"{}\"", + sanitize_osascript(body), + sanitize_osascript(title) + ), + ], + )), + "linux" => Some(( + "notify-send".to_string(), + vec![ + "--app-name".to_string(), + "ECC 2.0".to_string(), + title.trim().to_string(), + body.trim().to_string(), + ], + )), + _ => None, + } +} + +#[cfg(not(test))] +fn run_notification_command(program: &str, args: &[String]) -> Result<()> { + let status = std::process::Command::new(program) + .args(args) + .status() + .with_context(|| format!("launch {program}"))?; + + if status.success() { + Ok(()) + } else { + anyhow::bail!("{program} exited with {status}"); + } +} + +#[cfg(test)] +fn run_notification_command(_program: &str, _args: &[String]) -> Result<()> { + Ok(()) +} + +fn sanitize_osascript(value: &str) -> String { + value + .replace('\\', "") + .replace('"', "\u{201C}") + .replace('\n', " ") +} + +#[cfg(test)] +mod tests { + use super::{ + notification_command, DesktopNotificationConfig, DesktopNotifier, NotificationEvent, + QuietHoursConfig, + }; + use chrono::{Local, TimeZone}; + + #[test] + fn quiet_hours_support_cross_midnight_ranges() { + let quiet_hours = QuietHoursConfig { + enabled: true, + start_hour: 22, + end_hour: 8, + }; + + assert!(quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 23, 0, 0).unwrap())); + assert!(quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 7, 0, 0).unwrap())); + assert!(!quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 14, 0, 0).unwrap())); + } + + #[test] + fn quiet_hours_support_same_day_ranges() { + let quiet_hours = QuietHoursConfig { + enabled: true, + start_hour: 9, + end_hour: 17, + }; + + assert!(quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 10, 0, 0).unwrap())); + assert!(!quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 18, 0, 0).unwrap())); + } + + #[test] + fn notification_preferences_respect_event_flags() { + let mut config = DesktopNotificationConfig::default(); + config.session_completed = false; + let now = Local.with_ymd_and_hms(2026, 4, 9, 12, 0, 0).unwrap(); + + assert!(!config.allows(NotificationEvent::SessionCompleted, now)); + assert!(config.allows(NotificationEvent::BudgetAlert, now)); + } + + #[test] + fn notifier_skips_delivery_during_quiet_hours() { + let mut config = DesktopNotificationConfig::default(); + config.quiet_hours = QuietHoursConfig { + enabled: true, + start_hour: 22, + end_hour: 8, + }; + let notifier = DesktopNotifier::new(config); + + assert!(!notifier + .try_notify( + NotificationEvent::ApprovalRequest, + "ECC 2.0: Approval needed", + "worker-123 needs review", + Local.with_ymd_and_hms(2026, 4, 9, 23, 0, 0).unwrap(), + ) + .unwrap()); + } + + #[test] + fn macos_notifications_use_osascript() { + let (program, args) = + notification_command("macos", "ECC 2.0: Completed", "Task finished").unwrap(); + + assert_eq!(program, "osascript"); + assert_eq!(args[0], "-e"); + assert!(args[1].contains("display notification")); + assert!(args[1].contains("ECC 2.0: Completed")); + } + + #[test] + fn linux_notifications_use_notify_send() { + let (program, args) = + notification_command("linux", "ECC 2.0: Approval needed", "worker-123").unwrap(); + + assert_eq!(program, "notify-send"); + assert_eq!(args[0], "--app-name"); + assert_eq!(args[1], "ECC 2.0"); + assert_eq!(args[2], "ECC 2.0: Approval needed"); + assert_eq!(args[3], "worker-123"); + } +} diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 907f2f84..c80929d2 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -219,7 +219,7 @@ pub async fn drain_inbox( use_worktree, &repo_root, &runner_program, - SessionGrouping::default(), + SessionGrouping::default(), ) .await?; @@ -2237,6 +2237,7 @@ mod tests { auto_dispatch_limit_per_session: 5, auto_create_worktrees: true, auto_merge_ready_worktrees: false, + desktop_notifications: crate::notifications::DesktopNotificationConfig::default(), cost_budget_usd: 10.0, token_budget: 500_000, budget_alert_thresholds: Config::BUDGET_ALERT_THRESHOLDS, @@ -3691,7 +3692,7 @@ mod tests { true, &repo_root, &fake_runner, - SessionGrouping::default(), + SessionGrouping::default(), ) .await?; @@ -3763,7 +3764,7 @@ mod tests { true, &repo_root, &fake_runner, - SessionGrouping::default(), + SessionGrouping::default(), ) .await?; @@ -3819,7 +3820,7 @@ mod tests { true, &repo_root, &fake_runner, - SessionGrouping::default(), + SessionGrouping::default(), ) .await?; diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index ab477bd2..4f68ad2f 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -1299,6 +1299,35 @@ impl StateStore { messages.collect::, _>>().map_err(Into::into) } + pub fn latest_unread_approval_message(&self) -> Result> { + self.conn + .query_row( + "SELECT id, from_session, to_session, content, msg_type, read, timestamp + FROM messages + WHERE read = 0 AND msg_type IN ('query', 'conflict') + ORDER BY id DESC + LIMIT 1", + [], + |row| { + let timestamp: String = row.get(6)?; + + Ok(SessionMessage { + id: row.get(0)?, + from_session: row.get(1)?, + to_session: row.get(2)?, + content: row.get(3)?, + msg_type: row.get(4)?, + read: row.get::<_, i64>(5)? != 0, + timestamp: chrono::DateTime::parse_from_rfc3339(×tamp) + .unwrap_or_default() + .with_timezone(&chrono::Utc), + }) + }, + ) + .optional() + .map_err(Into::into) + } + pub fn unread_task_handoffs_for_session( &self, session_id: &str, diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index f59607b9..60d86ff6 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -14,6 +14,7 @@ use tokio::sync::broadcast; use super::widgets::{budget_state, format_currency, format_token_count, BudgetState, TokenMeter}; use crate::comms; use crate::config::{Config, PaneLayout, PaneNavigationAction, Theme}; +use crate::notifications::{DesktopNotifier, NotificationEvent}; use crate::observability::ToolLogEntry; use crate::session::manager; use crate::session::output::{ @@ -56,6 +57,7 @@ pub struct Dashboard { cfg: Config, output_store: SessionOutputStore, output_rx: broadcast::Receiver, + notifier: DesktopNotifier, sessions: Vec, session_output_cache: HashMap>, unread_message_counts: HashMap, @@ -114,6 +116,8 @@ pub struct Dashboard { last_cost_metrics_signature: Option<(u64, u128)>, last_tool_activity_signature: Option<(u64, u128)>, last_budget_alert_state: BudgetState, + last_session_states: HashMap, + last_seen_approval_message_id: Option, } #[derive(Debug, Default, PartialEq, Eq)] @@ -314,7 +318,17 @@ impl Dashboard { let _ = db.sync_tool_activity_metrics(&cfg.tool_activity_metrics_path()); } let sessions = db.list_sessions().unwrap_or_default(); + let initial_session_states = sessions + .iter() + .map(|session| (session.id.clone(), session.state.clone())) + .collect(); + let initial_approval_message_id = db + .latest_unread_approval_message() + .ok() + .flatten() + .map(|message| message.id); let output_rx = output_store.subscribe(); + let notifier = DesktopNotifier::new(cfg.desktop_notifications.clone()); let mut session_table_state = TableState::default(); if !sessions.is_empty() { session_table_state.select(Some(0)); @@ -325,6 +339,7 @@ impl Dashboard { cfg, output_store, output_rx, + notifier, sessions, session_output_cache: HashMap::new(), unread_message_counts: HashMap::new(), @@ -383,6 +398,8 @@ impl Dashboard { last_cost_metrics_signature: initial_cost_metrics_signature, last_tool_activity_signature: initial_tool_activity_signature, last_budget_alert_state: BudgetState::Normal, + last_session_states: initial_session_states, + last_seen_approval_message_id: initial_approval_message_id, }; sort_sessions_for_display(&mut dashboard.sessions); dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap_or_default(); @@ -746,9 +763,7 @@ impl Dashboard { } else { self.selected_git_status.min(total.saturating_sub(1)) + 1 }; - return format!( - " Git status staged:{staged} unstaged:{unstaged} {current}/{total} " - ); + return format!(" Git status staged:{staged} unstaged:{unstaged} {current}/{total} "); } let filter = format!( @@ -1930,7 +1945,10 @@ impl Dashboard { if let Err(error) = worktree::unstage_path(&worktree, &entry.path) { tracing::warn!("Failed to unstage {}: {error}", entry.path); - self.set_operator_note(format!("unstage failed for {}: {error}", entry.display_path)); + self.set_operator_note(format!( + "unstage failed for {}: {error}", + entry.display_path + )); return; } @@ -1963,7 +1981,9 @@ impl Dashboard { pub fn begin_commit_prompt(&mut self) { if self.output_mode != OutputMode::GitStatus { - self.set_operator_note("commit prompt is only available in git status view".to_string()); + self.set_operator_note( + "commit prompt is only available in git status view".to_string(), + ); return; } @@ -1977,7 +1997,11 @@ impl Dashboard { return; } - if !self.selected_git_status_entries.iter().any(|entry| entry.staged) { + if !self + .selected_git_status_entries + .iter() + .any(|entry| entry.staged) + { self.set_operator_note("no staged changes to commit".to_string()); return; } @@ -2960,10 +2984,15 @@ impl Dashboard { lines.push("## Session Metrics".to_string()); lines.push(format!( "- Tokens: {} total (in {} / out {})", - session.metrics.tokens_used, session.metrics.input_tokens, session.metrics.output_tokens + session.metrics.tokens_used, + session.metrics.input_tokens, + session.metrics.output_tokens )); lines.push(format!("- Tool calls: {}", session.metrics.tool_calls)); - lines.push(format!("- Files changed: {}", session.metrics.files_changed)); + lines.push(format!( + "- Files changed: {}", + session.metrics.files_changed + )); lines.push(format!( "- Duration: {}", format_duration(session.metrics.duration_secs) @@ -3372,6 +3401,8 @@ impl Dashboard { HashMap::new() } }; + self.sync_session_state_notifications(); + self.sync_approval_notifications(); self.sync_handoff_backlog_counts(); self.sync_worktree_health_by_session(); self.sync_global_handoff_backlog(); @@ -3440,6 +3471,91 @@ impl Dashboard { self.set_operator_note(format!( "{summary_suffix} | tokens {token_budget} | cost {cost_budget}" )); + self.notify_desktop( + NotificationEvent::BudgetAlert, + "ECC 2.0: Budget alert", + &format!("{summary_suffix} | tokens {token_budget} | cost {cost_budget}"), + ); + } + + fn sync_session_state_notifications(&mut self) { + let mut next_states = HashMap::new(); + + for session in &self.sessions { + let previous_state = self.last_session_states.get(&session.id); + if let Some(previous_state) = previous_state { + if previous_state != &session.state { + match session.state { + SessionState::Completed => { + self.notify_desktop( + NotificationEvent::SessionCompleted, + "ECC 2.0: Session completed", + &format!( + "{} | {}", + format_session_id(&session.id), + truncate_for_dashboard(&session.task, 96) + ), + ); + } + SessionState::Failed => { + self.notify_desktop( + NotificationEvent::SessionFailed, + "ECC 2.0: Session failed", + &format!( + "{} | {}", + format_session_id(&session.id), + truncate_for_dashboard(&session.task, 96) + ), + ); + } + _ => {} + } + } + } + + next_states.insert(session.id.clone(), session.state.clone()); + } + + self.last_session_states = next_states; + } + + fn sync_approval_notifications(&mut self) { + let latest_message = match self.db.latest_unread_approval_message() { + Ok(message) => message, + Err(error) => { + tracing::warn!("Failed to refresh latest approval request: {error}"); + return; + } + }; + + let Some(message) = latest_message else { + return; + }; + + if self + .last_seen_approval_message_id + .is_some_and(|last_seen| message.id <= last_seen) + { + return; + } + + self.last_seen_approval_message_id = Some(message.id); + let preview = + truncate_for_dashboard(&comms::preview(&message.msg_type, &message.content), 96); + self.notify_desktop( + NotificationEvent::ApprovalRequest, + "ECC 2.0: Approval needed", + &format!( + "{} from {} | {}", + format_session_id(&message.to_session), + format_session_id(&message.from_session), + preview + ), + ); + } + + fn notify_desktop(&self, event: NotificationEvent, title: &str, body: &str) { + let _ = self.notifier.notify(event, title, body); } fn sync_selection(&mut self) { @@ -3688,10 +3804,7 @@ impl Dashboard { .and_then(|worktree| worktree::git_status_entries(worktree).ok()) .unwrap_or_default(); if self.selected_git_status >= self.selected_git_status_entries.len() { - self.selected_git_status = self - .selected_git_status_entries - .len() - .saturating_sub(1); + self.selected_git_status = self.selected_git_status_entries.len().saturating_sub(1); } if self.output_mode == OutputMode::GitStatus && worktree.is_none() { self.output_mode = OutputMode::SessionOutput; @@ -4042,7 +4155,11 @@ impl Dashboard { .iter() .enumerate() .map(|(index, entry)| { - let marker = if index == self.selected_git_status { ">>" } else { "-" }; + let marker = if index == self.selected_git_status { + ">>" + } else { + "-" + }; let mut flags = Vec::new(); if entry.conflicted { flags.push("conflict"); @@ -6932,6 +7049,42 @@ mod tests { assert!(dashboard.approval_queue_preview.is_empty()); } + #[test] + fn refresh_tracks_latest_unread_approval_before_selected_messages_mark_read() { + let sessions = vec![sample_session( + "worker-123456", + "reviewer", + SessionState::Idle, + Some("ecc/worker"), + 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-123456", + "{\"question\":\"Need operator input\"}", + "query", + ) + .unwrap(); + let message_id = dashboard + .db + .latest_unread_approval_message() + .unwrap() + .expect("approval message should exist") + .id; + + dashboard.refresh(); + + assert_eq!(dashboard.last_seen_approval_message_id, Some(message_id)); + assert!(dashboard.approval_queue_preview.is_empty()); + } + #[test] fn focus_next_approval_target_selects_oldest_unread_target() { let sessions = vec![ @@ -7199,7 +7352,10 @@ mod tests { dashboard.operator_note.as_deref(), Some("showing selected worktree git status") ); - assert_eq!(dashboard.output_title(), " Git status staged:0 unstaged:1 1/1 "); + assert_eq!( + dashboard.output_title(), + " Git status staged:0 unstaged:1 1/1 " + ); let rendered = dashboard.rendered_output_text(180, 20); assert!(rendered.contains("Git status")); assert!(rendered.contains("README.md")); @@ -8959,6 +9115,42 @@ diff --git a/src/lib.rs b/src/lib.rs ); } + #[test] + fn refresh_updates_session_state_snapshot_after_completion() { + let db = StateStore::open(Path::new(":memory:")).unwrap(); + let now = Utc::now(); + let session = Session { + id: "done-1".to_string(), + task: "complete session".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + }; + db.insert_session(&session).unwrap(); + + let mut dashboard = Dashboard::new(db, Config::default()); + dashboard + .db + .update_state("done-1", &SessionState::Completed) + .unwrap(); + + dashboard.refresh(); + + assert_eq!(dashboard.sessions[0].state, SessionState::Completed); + assert_eq!( + dashboard.last_session_states.get("done-1"), + Some(&SessionState::Completed) + ); + } + #[test] fn refresh_syncs_tool_activity_metrics_from_hook_file() { let tempdir = std::env::temp_dir().join(format!("ecc2-activity-sync-{}", Uuid::new_v4())); @@ -11020,6 +11212,11 @@ diff --git a/src/lib.rs b/src/lib.rs fn test_dashboard(sessions: Vec, selected_session: usize) -> Dashboard { let selected_session = selected_session.min(sessions.len().saturating_sub(1)); let cfg = Config::default(); + let notifier = DesktopNotifier::new(cfg.desktop_notifications.clone()); + let last_session_states = sessions + .iter() + .map(|session| (session.id.clone(), session.state.clone())) + .collect(); let output_store = SessionOutputStore::default(); let output_rx = output_store.subscribe(); let mut session_table_state = TableState::default(); @@ -11033,6 +11230,7 @@ diff --git a/src/lib.rs b/src/lib.rs cfg, output_store, output_rx, + notifier, sessions, session_output_cache: HashMap::new(), unread_message_counts: HashMap::new(), @@ -11090,6 +11288,8 @@ diff --git a/src/lib.rs b/src/lib.rs last_cost_metrics_signature: None, last_tool_activity_signature: None, last_budget_alert_state: BudgetState::Normal, + last_session_states, + last_seen_approval_message_id: None, } } @@ -11109,6 +11309,7 @@ diff --git a/src/lib.rs b/src/lib.rs auto_dispatch_limit_per_session: 5, auto_create_worktrees: true, auto_merge_ready_worktrees: false, + desktop_notifications: crate::notifications::DesktopNotificationConfig::default(), cost_budget_usd: 10.0, token_budget: 500_000, budget_alert_thresholds: crate::config::Config::BUDGET_ALERT_THRESHOLDS,