aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-28 15:22:12 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-28 15:23:04 +0300
commit28222b20890a1f6cd36fd477de8e9bc6e5537ff5 (patch)
tree75d54cf3a1d43f22b85adcfc6712b08cf31482a6 /src
parenta90c90aa4aa92142e4684a81dcb7c2cab9aaac6b (diff)
fix: use live workspace labels in notifications
refs #332
Diffstat (limited to 'src')
-rw-r--r--src/app/actions.rs22
-rw-r--r--src/app/api.rs216
-rw-r--r--src/app/runtime.rs2
-rw-r--r--src/server/headless.rs10
-rw-r--r--src/server/notifications.rs90
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);
+ }
+}