mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-11 02:20:29 +08:00
feat: add ecc2 desktop notifications
This commit is contained in:
parent
491ee81889
commit
a4d0a4fc14
@ -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,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
mod comms;
|
||||
mod config;
|
||||
mod notifications;
|
||||
mod observability;
|
||||
mod session;
|
||||
mod tui;
|
||||
|
||||
289
ecc2/src/notifications.rs
Normal file
289
ecc2/src/notifications.rs
Normal file
@ -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<Local>) -> 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<Local>) -> 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<Local>,
|
||||
) -> Result<bool> {
|
||||
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<String>)> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@ -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?;
|
||||
|
||||
|
||||
@ -1299,6 +1299,35 @@ impl StateStore {
|
||||
messages.collect::<Result<Vec<_>, _>>().map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn latest_unread_approval_message(&self) -> Result<Option<SessionMessage>> {
|
||||
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,
|
||||
|
||||
@ -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<OutputEvent>,
|
||||
notifier: DesktopNotifier,
|
||||
sessions: Vec<Session>,
|
||||
session_output_cache: HashMap<String, Vec<OutputLine>>,
|
||||
unread_message_counts: HashMap<String, usize>,
|
||||
@ -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<String, SessionState>,
|
||||
last_seen_approval_message_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[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<Session>, 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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user