diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-28 15:22:12 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-28 15:23:04 +0300 |
| commit | 28222b20890a1f6cd36fd477de8e9bc6e5537ff5 (patch) | |
| tree | 75d54cf3a1d43f22b85adcfc6712b08cf31482a6 /src | |
| parent | a90c90aa4aa92142e4684a81dcb7c2cab9aaac6b (diff) | |
fix: use live workspace labels in notifications
refs #332
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/actions.rs | 22 | ||||
| -rw-r--r-- | src/app/api.rs | 216 | ||||
| -rw-r--r-- | src/app/runtime.rs | 2 | ||||
| -rw-r--r-- | src/server/headless.rs | 10 | ||||
| -rw-r--r-- | src/server/notifications.rs | 90 |
5 files changed, 337 insertions, 3 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index c2329531..40c8e74c 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -73,10 +73,11 @@ fn toast_agent_label(agent_label: &str) -> &str { pub fn notification_context( ws: &crate::workspace::Workspace, + workspace_label: &str, ws_idx: usize, pane_id: PaneId, ) -> String { - let mut context = format!("{} · {}", ws.display_name(), ws_idx + 1); + let mut context = format!("{} · {}", workspace_label, ws_idx + 1); if ws.tabs.len() > 1 { if let Some(tab_idx) = ws.find_tab_index_for_pane(pane_id) { let tab = &ws.tabs[tab_idx]; @@ -2139,7 +2140,13 @@ impl AppState { ToastKind::Finished => "finished", ToastKind::UpdateInstalled => "updated", }; - let context = notification_context(&self.workspaces[ws_idx], ws_idx, pane_id); + let workspace_label = self.workspaces[ws_idx].display_name(); + let context = notification_context( + &self.workspaces[ws_idx], + &workspace_label, + ws_idx, + pane_id, + ); self.toast = Some(ToastNotification { kind, title: format!("{} {}", toast_agent_label(agent_label), event_text), @@ -2244,6 +2251,17 @@ mod tests { }); } + #[test] + fn notification_context_formats_resolved_workspace_label() { + let state = app_with_workspaces(&["stale"]); + let root = state.workspaces[0].tabs[0].root_pane; + + assert_eq!( + notification_context(&state.workspaces[0], "__herdr_projects__", 0, root), + "__herdr_projects__ · 1" + ); + } + fn selected_word(row: &str, col: u16) -> Option<String> { let (start, end) = word_bounds_at_column(row, col)?; Some(text_in_cell_range(row, start, end)) diff --git a/src/app/api.rs b/src/app/api.rs index 562bc449..e69ab0eb 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -103,6 +103,7 @@ impl App { let previous_toast = self.state.toast.clone(); let pane_updates = self.state.handle_app_event(ev); for update in &pane_updates { + self.refresh_new_herdr_toast_context_for_update(update, &previous_toast); self.emit_pane_state_update(update); } self.sync_agent_metadata_deadline(); @@ -180,10 +181,13 @@ impl App { ToastKind::Finished => "finished", ToastKind::UpdateInstalled => "updated", }; + let workspace_label = + ws.display_name_from(&self.state.terminals, &self.terminal_runtimes); let _ = notify( &format!("{} {}", agent_label, event_text), Some(&crate::app::actions::notification_context( ws, + &workspace_label, update.ws_idx, update.pane_id, )), @@ -196,6 +200,49 @@ impl App { self.shutdown_detached_terminal_runtimes(); } + pub(crate) fn refresh_new_herdr_toast_context_for_update( + &mut self, + update: &crate::app::actions::PaneStateUpdate, + previous_toast: &Option<crate::app::state::ToastNotification>, + ) { + if !matches!( + self.state.toast_config.delivery, + crate::config::ToastDelivery::Herdr + ) || self.state.toast == *previous_toast + { + return; + } + + let Some(target) = self + .state + .toast + .as_ref() + .and_then(|toast| toast.target.as_ref()) + else { + return; + }; + if target.pane_id != update.pane_id { + return; + } + let Some(ws) = self.state.workspaces.get(update.ws_idx) else { + return; + }; + if ws.id != target.workspace_id { + return; + } + + let workspace_label = ws.display_name_from(&self.state.terminals, &self.terminal_runtimes); + let context = crate::app::actions::notification_context( + ws, + &workspace_label, + update.ws_idx, + update.pane_id, + ); + if let Some(toast) = self.state.toast.as_mut() { + toast.context = context; + } + } + pub(crate) fn show_clipboard_feedback(&mut self) { self.state.copy_feedback = Some(crate::app::state::CopyFeedback { message: "copied to clipboard".to_string(), @@ -537,3 +584,172 @@ impl App { serde_json::to_string(&response).unwrap() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::detect::{Agent, AgentState}; + + fn init_repo(path: &std::path::Path) { + let status = std::process::Command::new("git") + .args(["init", "-q"]) + .current_dir(path) + .status() + .unwrap(); + assert!(status.success(), "git init failed for {}", path.display()); + } + + #[tokio::test] + async fn herdr_toast_context_uses_live_root_runtime_cwd_label() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + + let mut workspace = crate::workspace::Workspace::test_new("stale"); + workspace.custom_name = None; + let root = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(root).cloned().unwrap(); + let temp_root = std::env::temp_dir().join(format!( + "herdr-toast-context-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let stale_cwd = temp_root.join("__herdr_original__"); + let live_cwd = temp_root.join("__herdr_projects__"); + std::fs::create_dir_all(&stale_cwd).unwrap(); + std::fs::create_dir_all(&live_cwd).unwrap(); + init_repo(&stale_cwd); + init_repo(&live_cwd); + + workspace.identity_cwd = stale_cwd.clone(); + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + app.state.terminals.get_mut(&terminal_id).unwrap().cwd = stale_cwd; + app.state.active = None; + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + + let (events, _) = tokio::sync::mpsc::channel(4); + let runtime = crate::terminal::TerminalRuntime::spawn( + root, + 24, + 80, + live_cwd.clone(), + 0, + crate::terminal_theme::TerminalTheme::default(), + "/bin/sh", + events, + std::sync::Arc::new(tokio::sync::Notify::new()), + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while runtime.cwd() != Some(live_cwd.clone()) && std::time::Instant::now() < deadline { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + app.terminal_runtimes.insert(terminal_id, runtime); + + app.handle_internal_event(AppEvent::StateChanged { + pane_id: root, + agent: Some(Agent::Codex), + state: AgentState::Working, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), + }); + app.handle_internal_event(AppEvent::StateChanged { + pane_id: root, + agent: Some(Agent::Codex), + state: AgentState::Idle, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), + }); + + assert_eq!( + app.state.toast.as_ref().map(|toast| toast.context.as_str()), + Some("__herdr_projects__ · 1") + ); + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + let _ = std::fs::remove_dir_all(temp_root); + } + + #[test] + fn terminal_delivery_does_not_refresh_existing_targeted_toast() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.local_terminal_notifications = false; + + let mut workspace = crate::workspace::Workspace::test_new("stale"); + workspace.custom_name = None; + workspace.identity_cwd = "/__herdr_original__".into(); + let root = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(root).cloned().unwrap(); + let workspace_id = workspace.id.clone(); + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + app.state.terminals.get_mut(&terminal_id).unwrap().cwd = "/__herdr_projects__".into(); + app.state.active = None; + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.toast_config.delivery = crate::config::ToastDelivery::Terminal; + + app.handle_internal_event(AppEvent::StateChanged { + pane_id: root, + agent: Some(Agent::Codex), + state: AgentState::Working, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), + }); + app.state.toast = Some(crate::app::state::ToastNotification { + kind: ToastKind::Finished, + title: "codex finished".into(), + context: "__herdr_original__ · 1".into(), + target: Some(crate::app::state::ToastTarget { + workspace_id, + pane_id: root, + }), + }); + + app.handle_internal_event(AppEvent::StateChanged { + pane_id: root, + agent: Some(Agent::Codex), + state: AgentState::Idle, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), + }); + + assert_eq!( + app.state.toast.as_ref().map(|toast| toast.context.as_str()), + Some("__herdr_original__ · 1") + ); + } +} diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 64232397..62b803f3 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -210,7 +210,9 @@ impl App { .agent_metadata_deadline .filter(|deadline| now >= *deadline) { + let previous_toast = self.state.toast.clone(); for update in self.state.expire_agent_metadata_at(deadline, now) { + self.refresh_new_herdr_toast_context_for_update(&update, &previous_toast); self.emit_pane_state_update(&update); } self.sync_agent_metadata_deadline(); diff --git a/src/server/headless.rs b/src/server/headless.rs index 18717782..e6576df6 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -1218,6 +1218,7 @@ impl HeadlessServer { } else { toast_message_from_state_change( &self.app.state, + &self.app.terminal_runtimes, pane_id_val, suppress_active_tab_notifications, prev_state, @@ -1302,6 +1303,7 @@ impl HeadlessServer { } else { toast_message_from_state_change( &self.app.state, + &self.app.terminal_runtimes, pane_id_val, suppress_active_tab_notifications, prev_state, @@ -2115,12 +2117,17 @@ impl HeadlessServer { crate::app::state::ToastKind::Finished => "finished", crate::app::state::ToastKind::UpdateInstalled => "updated", }; + let workspace_label = self.app.state.workspaces[*ws_idx].display_name_from( + &self.app.state.terminals, + &self.app.terminal_runtimes, + ); let msg_text = format!( "{} {}: {}", agent_label, event_text, crate::app::actions::notification_context( &self.app.state.workspaces[*ws_idx], + &workspace_label, *ws_idx, *pane_id, ) @@ -2491,7 +2498,10 @@ impl HeadlessServer { .agent_metadata_deadline .filter(|deadline| now >= *deadline) { + let previous_toast = self.app.state.toast.clone(); for update in self.app.state.expire_agent_metadata_at(deadline, now) { + self.app + .refresh_new_herdr_toast_context_for_update(&update, &previous_toast); self.app.emit_pane_state_update(&update); } self.app.sync_agent_metadata_deadline(); diff --git a/src/server/notifications.rs b/src/server/notifications.rs index 1729c698..0ed681ac 100644 --- a/src/server/notifications.rs +++ b/src/server/notifications.rs @@ -4,6 +4,7 @@ use crate::config; use crate::detect::AgentState; use crate::layout::PaneId; use crate::protocol; +use crate::terminal::TerminalRuntimeRegistry; pub(crate) fn should_forward_toast_to_clients(delivery: config::ToastDelivery) -> bool { toast_notify_kind(delivery).is_some() @@ -19,6 +20,7 @@ pub(crate) fn toast_notify_kind(delivery: config::ToastDelivery) -> Option<proto pub(crate) fn toast_message_from_state_change( state: &AppState, + terminal_runtimes: &TerminalRuntimeRegistry, pane_id: PaneId, suppress_active_tab_notifications: bool, prev_state: AgentState, @@ -41,11 +43,12 @@ pub(crate) fn toast_message_from_state_change( .terminals .get(&pane.attached_terminal_id) .and_then(|terminal| terminal.effective_agent_label())?; + let workspace_label = ws.display_name_from(&state.terminals, terminal_runtimes); Some(format!( "{} {}: {}", agent_label, toast_event_text(kind), - app::actions::notification_context(ws, ws_idx, pane_id) + app::actions::notification_context(ws, &workspace_label, ws_idx, pane_id) )) }) }) @@ -58,3 +61,88 @@ fn toast_event_text(kind: app::state::ToastKind) -> &'static str { app::state::ToastKind::UpdateInstalled => "updated", } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::detect::Agent; + use crate::terminal::TerminalState; + + fn init_repo(path: &std::path::Path) { + let status = std::process::Command::new("git") + .args(["init", "-q"]) + .current_dir(path) + .status() + .unwrap(); + assert!(status.success(), "git init failed for {}", path.display()); + } + + #[tokio::test] + async fn toast_message_uses_live_root_runtime_cwd_label() { + let mut state = AppState::test_new(); + state + .workspaces + .push(crate::workspace::Workspace::test_new("stale")); + state.ensure_test_terminals(); + let root = state.workspaces[0].tabs[0].root_pane; + let terminal_id = state.workspaces[0].terminal_id(root).cloned().unwrap(); + let temp_root = std::env::temp_dir().join(format!( + "herdr-forwarded-toast-context-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let stale_cwd = temp_root.join("__herdr_original__"); + let live_cwd = temp_root.join("__herdr_projects__"); + std::fs::create_dir_all(&stale_cwd).unwrap(); + std::fs::create_dir_all(&live_cwd).unwrap(); + init_repo(&stale_cwd); + init_repo(&live_cwd); + state.workspaces[0].custom_name = None; + state.workspaces[0].identity_cwd = stale_cwd.clone(); + let mut terminal = TerminalState::new(terminal_id.clone(), stale_cwd); + terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); + state.terminals.insert(terminal_id.clone(), terminal); + let (events, _) = tokio::sync::mpsc::channel(4); + let runtime = crate::terminal::TerminalRuntime::spawn( + root, + 24, + 80, + live_cwd.clone(), + 0, + crate::terminal_theme::TerminalTheme::default(), + "/bin/sh", + events, + std::sync::Arc::new(tokio::sync::Notify::new()), + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while runtime.cwd() != Some(live_cwd.clone()) && std::time::Instant::now() < deadline { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + let mut terminal_runtimes = TerminalRuntimeRegistry::new(); + terminal_runtimes.insert(terminal_id, runtime); + + let message = toast_message_from_state_change( + &state, + &terminal_runtimes, + root, + false, + AgentState::Working, + AgentState::Idle, + ); + + assert_eq!( + message.as_deref(), + Some("codex finished: __herdr_projects__ · 1") + ); + + for (_, runtime) in terminal_runtimes.drain() { + runtime.shutdown(); + } + let _ = std::fs::remove_dir_all(temp_root); + } +} |
