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/app | |
| parent | a90c90aa4aa92142e4684a81dcb7c2cab9aaac6b (diff) | |
fix: use live workspace labels in notifications
refs #332
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 22 | ||||
| -rw-r--r-- | src/app/api.rs | 216 | ||||
| -rw-r--r-- | src/app/runtime.rs | 2 |
3 files changed, 238 insertions, 2 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(); |
