aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-17 16:08:43 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-17 16:08:43 +0300
commit54490254f70469cd154ee630bc4bec84da178f0c (patch)
tree450bf0397398b28932f636d33e37f73c011dd19d
parentd998753efe506a04c80306795efc72bff60bb0ec (diff)
feat: sort agent panel by priority
refs #318
-rw-r--r--docs/next/website/src/content/docs/configuration.mdx4
-rw-r--r--src/app/actions.rs95
-rw-r--r--src/app/config_io.rs16
-rw-r--r--src/app/input/mod.rs7
-rw-r--r--src/app/input/mouse.rs10
-rw-r--r--src/app/input/sidebar.rs21
-rw-r--r--src/app/mod.rs61
-rw-r--r--src/app/runtime.rs16
-rw-r--r--src/app/session.rs1
-rw-r--r--src/app/state.rs14
-rw-r--r--src/config.rs2
-rw-r--r--src/config/model.rs44
-rw-r--r--src/main.rs5
-rw-r--r--src/persist/io.rs2
-rw-r--r--src/persist/restore.rs5
-rw-r--r--src/persist/snapshot.rs24
-rw-r--r--src/server/headless.rs1
-rw-r--r--src/terminal/state.rs3
-rw-r--r--src/ui/mobile.rs1
-rw-r--r--src/ui/sidebar.rs147
-rw-r--r--src/workspace/aggregate.rs2
21 files changed, 247 insertions, 234 deletions
diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx
index 188d1237..28a26223 100644
--- a/docs/next/website/src/content/docs/configuration.mdx
+++ b/docs/next/website/src/content/docs/configuration.mdx
@@ -287,7 +287,7 @@ mouse_scroll_lines = 3
confirm_close = true
prompt_new_tab_name = true
show_agent_labels_on_pane_borders = false
-agent_panel_scope = "all"
+agent_panel_sort = "spaces"
accent = "cyan"
```
@@ -295,7 +295,7 @@ accent = "cyan"
`mobile_width_threshold` controls the terminal width at or below which Herdr uses the mobile single-column layout. The default is 64 columns; increase it for foldables, tablets, or wide phone terminals.
-`agent_panel_scope` can be `all` or `current`. Use `current` if you only want the agent panel to show agents in the active workspace.
+The agent panel shows all agents across all spaces. `agent_panel_sort` can be `spaces` or `priority`; `workspaces` is accepted as an alias for `spaces`. `spaces` keeps agents grouped by space order. `priority` sorts by attention priority: blocked, done, working, idle, then unknown. Within the same status, agents that most recently changed state appear first.
`confirm_close` controls whether closing a workspace asks for confirmation. `prompt_new_tab_name` controls whether new tabs ask for a label first.
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 9df07af5..1e0f0f5e 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -951,12 +951,6 @@ impl AppState {
let workspace_id = self.workspaces[idx].id.clone();
crate::logging::workspace_focused(&workspace_id);
self.mark_session_dirty();
- if matches!(
- self.agent_panel_scope,
- crate::app::state::AgentPanelScope::CurrentWorkspace
- ) {
- self.agent_panel_scroll = 0;
- }
self.ensure_workspace_visible(idx);
if let Some(ws) = self.workspaces.get_mut(idx) {
let active_tab = ws.active_tab;
@@ -994,14 +988,6 @@ impl AppState {
crate::logging::workspace_focused(&workspace_id);
}
self.mark_session_dirty();
- if workspace_changed
- && matches!(
- self.agent_panel_scope,
- crate::app::state::AgentPanelScope::CurrentWorkspace
- )
- {
- self.agent_panel_scroll = 0;
- }
self.ensure_workspace_visible(ws_idx);
if let Some(ws) = self.workspaces.get_mut(ws_idx) {
ws.switch_tab(tab_idx);
@@ -2587,6 +2573,12 @@ impl AppState {
self.mark_session_dirty();
}
let change = mutation.effective_state_change?;
+ if change.previous_state != change.state {
+ self.next_agent_state_change_seq += 1;
+ if let Some(terminal) = self.terminals.get_mut(&terminal_id) {
+ terminal.last_agent_state_change_seq = Some(self.next_agent_state_change_seq);
+ }
+ }
let seen = self.apply_pane_state_change(ws_idx, pane_id, &change)?;
let update = PaneStateUpdate {
pane_id,
@@ -3542,6 +3534,16 @@ mod tests {
}
fn mark_agent(state: &mut AppState, ws_idx: usize, tab_idx: usize, pane_id: PaneId) {
+ set_agent_state(state, ws_idx, tab_idx, pane_id, AgentState::Idle);
+ }
+
+ fn set_agent_state(
+ state: &mut AppState,
+ ws_idx: usize,
+ tab_idx: usize,
+ pane_id: PaneId,
+ agent_state: AgentState,
+ ) {
state.ensure_test_terminals();
let terminal_id = state.workspaces[ws_idx].tabs[tab_idx]
.panes
@@ -3550,12 +3552,28 @@ mod tests {
.attached_terminal_id
.clone();
if let Some(terminal) = state.terminals.get_mut(&terminal_id) {
- terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ terminal.set_detected_state(Some(Agent::Pi), agent_state);
}
}
+ fn transition_agent_state(state: &mut AppState, pane_id: PaneId, agent_state: AgentState) {
+ state
+ .update_terminal_state(pane_id, |terminal| {
+ Some(terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ agent_state,
+ matches!(agent_state, AgentState::Blocked),
+ false,
+ false,
+ false,
+ std::time::Instant::now(),
+ ))
+ })
+ .expect("agent state transition should update pane state");
+ }
+
#[test]
- fn next_agent_cycles_agent_panel_entries_in_all_scope() {
+ fn next_agent_cycles_agent_panel_entries() {
let mut first = Workspace::test_new("one");
let first_root = first.tabs[0].root_pane;
let first_second = first.test_split(Direction::Horizontal);
@@ -3569,7 +3587,6 @@ mod tests {
state.active = Some(0);
state.selected = 0;
state.mode = Mode::Terminal;
- state.agent_panel_scope = crate::app::state::AgentPanelScope::AllWorkspaces;
mark_agent(&mut state, 0, 0, first_root);
mark_agent(&mut state, 0, 0, first_second);
mark_agent(&mut state, 1, 0, second_root);
@@ -3602,7 +3619,6 @@ mod tests {
state.active = Some(0);
state.selected = 0;
state.mode = Mode::Terminal;
- state.agent_panel_scope = crate::app::state::AgentPanelScope::AllWorkspaces;
mark_agent(&mut state, 0, 0, first_root);
mark_agent(&mut state, 0, 0, first_second);
mark_agent(&mut state, 1, 0, second_root);
@@ -3618,7 +3634,6 @@ mod tests {
fn focus_agent_entry_succeeds_for_already_focused_agent() {
let mut state = app_with_workspaces(&["one"]);
let root = state.workspaces[0].tabs[0].root_pane;
- state.agent_panel_scope = crate::app::state::AgentPanelScope::AllWorkspaces;
mark_agent(&mut state, 0, 0, root);
assert!(state.focus_agent_entry(0));
@@ -3628,11 +3643,11 @@ mod tests {
}
#[test]
- fn next_agent_cycles_only_current_scope_entries() {
+ fn next_agent_cycles_priority_sorted_agent_panel_entries() {
let mut first = Workspace::test_new("one");
let first_root = first.tabs[0].root_pane;
let first_second = first.test_split(Direction::Horizontal);
- first.tabs[0].layout.focus_pane(first_second);
+ first.tabs[0].layout.focus_pane(first_root);
let second = Workspace::test_new("two");
let second_root = second.tabs[0].root_pane;
@@ -3642,15 +3657,40 @@ mod tests {
state.active = Some(0);
state.selected = 0;
state.mode = Mode::Terminal;
- state.agent_panel_scope = crate::app::state::AgentPanelScope::CurrentWorkspace;
- mark_agent(&mut state, 0, 0, first_root);
- mark_agent(&mut state, 0, 0, first_second);
- mark_agent(&mut state, 1, 0, second_root);
+ state.agent_panel_sort = crate::app::state::AgentPanelSort::Priority;
+ set_agent_state(&mut state, 0, 0, first_root, AgentState::Idle);
+ set_agent_state(&mut state, 0, 0, first_second, AgentState::Working);
+ set_agent_state(&mut state, 1, 0, second_root, AgentState::Blocked);
state.next_agent();
- assert_eq!(state.active, Some(0));
- assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root));
+ assert_eq!(state.active, Some(1));
+ assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root));
+ state.assert_invariants_for_test();
+ }
+
+ #[test]
+ fn priority_sort_keeps_recently_changed_idle_agent_above_older_idle_agent() {
+ let mut workspace = Workspace::test_new("one");
+ let first = workspace.tabs[0].root_pane;
+ let second = workspace.test_split(Direction::Horizontal);
+ workspace.tabs[0].layout.focus_pane(first);
+
+ let mut state = AppState::test_new();
+ state.workspaces = vec![workspace];
+ state.ensure_test_terminals();
+ state.active = Some(0);
+ state.selected = 0;
+ state.mode = Mode::Terminal;
+ state.agent_panel_sort = crate::app::state::AgentPanelSort::Priority;
+
+ transition_agent_state(&mut state, first, AgentState::Idle);
+ transition_agent_state(&mut state, second, AgentState::Working);
+ assert_eq!(crate::ui::agent_panel_entries(&state)[0].pane_id, second);
+
+ transition_agent_state(&mut state, second, AgentState::Idle);
+
+ assert_eq!(crate::ui::agent_panel_entries(&state)[0].pane_id, second);
state.assert_invariants_for_test();
}
@@ -3668,7 +3708,6 @@ mod tests {
state.active = Some(0);
state.selected = 0;
state.mode = Mode::Terminal;
- state.agent_panel_scope = crate::app::state::AgentPanelScope::CurrentWorkspace;
for tab_idx in 0..state.workspaces[0].tabs.len() {
let pane_id = state.workspaces[0].tabs[tab_idx].root_pane;
mark_agent(&mut state, 0, tab_idx, pane_id);
diff --git a/src/app/config_io.rs b/src/app/config_io.rs
index 804a26f6..7a196008 100644
--- a/src/app/config_io.rs
+++ b/src/app/config_io.rs
@@ -107,20 +107,20 @@ impl App {
}
}
- pub(super) fn save_agent_panel_scope(&mut self, scope: crate::app::state::AgentPanelScope) {
- let value = match scope {
- crate::app::state::AgentPanelScope::CurrentWorkspace => {
- crate::config::AgentPanelScopeConfig::Current.as_str()
+ pub(super) fn save_agent_panel_sort(&mut self, sort: crate::app::state::AgentPanelSort) {
+ let value = match sort {
+ crate::app::state::AgentPanelSort::Spaces => {
+ crate::config::AgentPanelSortConfig::Spaces.as_str()
}
- crate::app::state::AgentPanelScope::AllWorkspaces => {
- crate::config::AgentPanelScopeConfig::All.as_str()
+ crate::app::state::AgentPanelSort::Priority => {
+ crate::config::AgentPanelSortConfig::Priority.as_str()
}
};
- if self.update_config_file("agent panel scope", |content| {
+ if self.update_config_file("agent panel sort", |content| {
crate::config::upsert_section_value(
content,
"ui",
- "agent_panel_scope",
+ "agent_panel_sort",
&format!("\"{value}\""),
)
}) {
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs
index cb4b0d46..5760c436 100644
--- a/src/app/input/mod.rs
+++ b/src/app/input/mod.rs
@@ -256,7 +256,7 @@ impl App {
let handled_pane_double_click = self.handle_pane_double_click(mouse);
- let previous_agent_panel_scope = self.state.agent_panel_scope;
+ let previous_agent_panel_sort = self.state.agent_panel_sort;
let previous_settings_section = self.state.settings.section;
if !handled_pane_double_click {
if let Some(action) = self.state.handle_mouse(&mut self.terminal_runtimes, mouse) {
@@ -286,8 +286,8 @@ impl App {
{
self.refresh_integration_recommendations();
}
- if self.state.agent_panel_scope != previous_agent_panel_scope {
- self.save_agent_panel_scope(self.state.agent_panel_scope);
+ if self.state.agent_panel_sort != previous_agent_panel_sort {
+ self.save_agent_panel_sort(self.state.agent_panel_sort);
}
if let Some(content) = self.state.request_clipboard_write.take() {
@@ -590,7 +590,6 @@ fn capture_snapshot(state: &AppState) -> crate::persist::SessionSnapshot {
&terminal_runtimes,
state.active,
state.selected,
- state.agent_panel_scope,
state.sidebar_width,
state.sidebar_section_split,
state.collapsed_space_keys.clone(),
diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs
index 2e2a94aa..41bb5267 100644
--- a/src/app/input/mouse.rs
+++ b/src/app/input/mouse.rs
@@ -5,7 +5,7 @@ use tracing::warn;
use crate::{
app::state::{
- AgentPanelScope, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget,
+ AgentPanelSort, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget,
MenuListState, Mode, RightClickPassthroughGesture, TabPressState, ViewLayout,
WorkspacePressState,
},
@@ -540,10 +540,10 @@ impl AppState {
return None;
}
- if self.on_agent_panel_scope_toggle(mouse.column, mouse.row) {
- self.agent_panel_scope = match self.agent_panel_scope {
- AgentPanelScope::CurrentWorkspace => AgentPanelScope::AllWorkspaces,
- AgentPanelScope::AllWorkspaces => AgentPanelScope::CurrentWorkspace,
+ if self.on_agent_panel_sort_toggle(mouse.column, mouse.row) {
+ self.agent_panel_sort = match self.agent_panel_sort {
+ AgentPanelSort::Spaces => AgentPanelSort::Priority,
+ AgentPanelSort::Priority => AgentPanelSort::Spaces,
};
self.agent_panel_scroll = 0;
self.mark_session_dirty();
diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs
index 3ab5f8f5..fd631cd4 100644
--- a/src/app/input/sidebar.rs
+++ b/src/app/input/sidebar.rs
@@ -432,7 +432,7 @@ impl AppState {
best.map(|(insert_idx, _)| insert_idx)
}
- pub(super) fn on_agent_panel_scope_toggle(&self, col: u16, row: u16) -> bool {
+ pub(super) fn on_agent_panel_sort_toggle(&self, col: u16, row: u16) -> bool {
if self.sidebar_collapsed {
return false;
}
@@ -441,7 +441,7 @@ impl AppState {
self.view.sidebar_rect,
self.sidebar_section_split,
);
- let rect = crate::ui::agent_panel_toggle_rect(detail_area, self.agent_panel_scope);
+ let rect = crate::ui::agent_panel_toggle_rect(detail_area, self.agent_panel_sort);
rect.width > 0
&& col >= rect.x
&& col < rect.x + rect.width
@@ -496,7 +496,7 @@ mod tests {
use super::super::{app_for_mouse_test, capture_snapshot, mouse, unique_temp_path};
use crate::{
- app::state::{AgentPanelScope, DragTarget, Mode},
+ app::state::{AgentPanelSort, DragTarget, Mode},
detect::Agent,
workspace::Workspace,
};
@@ -711,7 +711,7 @@ mod tests {
}
#[test]
- fn clicking_agent_panel_toggle_switches_scope() {
+ fn clicking_agent_panel_toggle_switches_sort() {
let mut app = app_for_mouse_test();
app.state.workspaces = vec![Workspace::test_new("test")];
app.state.active = Some(0);
@@ -723,23 +723,15 @@ mod tests {
app.state.view.sidebar_rect,
app.state.sidebar_section_split,
);
- let toggle = crate::ui::agent_panel_toggle_rect(detail_area, app.state.agent_panel_scope);
+ let toggle = crate::ui::agent_panel_toggle_rect(detail_area, app.state.agent_panel_sort);
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
toggle.x,
toggle.y,
));
- assert_eq!(
- app.state.agent_panel_scope,
- AgentPanelScope::CurrentWorkspace
- );
+ assert_eq!(app.state.agent_panel_sort, AgentPanelSort::Priority);
assert_eq!(app.state.agent_panel_scroll, 0);
- let snapshot = capture_snapshot(&app.state);
- assert_eq!(
- snapshot.agent_panel_scope,
- AgentPanelScope::CurrentWorkspace
- );
}
#[test]
@@ -772,7 +764,6 @@ mod tests {
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
- app.state.agent_panel_scope = AgentPanelScope::AllWorkspaces;
let (_, detail_area) = crate::ui::expanded_sidebar_sections(
app.state.view.sidebar_rect,
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 1b90f157..ff1581cf 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -204,12 +204,12 @@ fn load_plugin_registry(no_session: bool) -> crate::app::state::InstalledPluginR
.collect()
}
-fn agent_panel_scope_from_config(
- scope: crate::config::AgentPanelScopeConfig,
-) -> state::AgentPanelScope {
- match scope {
- crate::config::AgentPanelScopeConfig::Current => state::AgentPanelScope::CurrentWorkspace,
- crate::config::AgentPanelScopeConfig::All => state::AgentPanelScope::AllWorkspaces,
+fn agent_panel_sort_from_config(
+ sort: crate::config::AgentPanelSortConfig,
+) -> state::AgentPanelSort {
+ match sort {
+ crate::config::AgentPanelSortConfig::Spaces => state::AgentPanelSort::Spaces,
+ crate::config::AgentPanelSortConfig::Priority => state::AgentPanelSort::Priority,
}
}
@@ -289,7 +289,6 @@ impl App {
workspaces,
active,
selected,
- _restored_agent_panel_scope,
sidebar_width,
sidebar_width_source,
sidebar_section_split,
@@ -299,7 +298,6 @@ impl App {
Vec::new(),
None,
0,
- state::AgentPanelScope::CurrentWorkspace,
config.ui.sidebar_width,
state::SidebarWidthSource::ConfigDefault,
0.5_f32,
@@ -332,7 +330,6 @@ impl App {
Vec::new(),
None,
0,
- snap.agent_panel_scope,
snap.sidebar_width.unwrap_or(config.ui.sidebar_width),
if snap.sidebar_width.is_some() {
state::SidebarWidthSource::Persisted
@@ -350,7 +347,6 @@ impl App {
ws,
active,
selected,
- snap.agent_panel_scope,
snap.sidebar_width.unwrap_or(config.ui.sidebar_width),
if snap.sidebar_width.is_some() {
state::SidebarWidthSource::Persisted
@@ -366,7 +362,6 @@ impl App {
Vec::new(),
None,
0,
- state::AgentPanelScope::CurrentWorkspace,
config.ui.sidebar_width,
state::SidebarWidthSource::ConfigDefault,
0.5_f32,
@@ -374,7 +369,7 @@ impl App {
)
};
- let agent_panel_scope = agent_panel_scope_from_config(config.ui.agent_panel_scope);
+ let agent_panel_sort = agent_panel_sort_from_config(config.ui.agent_panel_sort);
// Validate sidebar bounds before they reach any `u16::clamp(min, max)`
// call: `clamp` panics when `min > max`. On bad config, fall back to
@@ -519,7 +514,8 @@ impl App {
sidebar_width_auto: false,
sidebar_collapsed: false,
sidebar_section_split,
- agent_panel_scope,
+ agent_panel_sort,
+ next_agent_state_change_seq: 0,
mouse_capture: config.ui.mouse_capture,
right_click_passthrough_modifiers: config.ui.right_click_passthrough_modifiers(),
right_click_passthrough: None,
@@ -693,7 +689,6 @@ impl App {
app.state.selected = snapshot
.selected
.min(app.state.workspaces.len().saturating_sub(1));
- app.state.agent_panel_scope = snapshot.agent_panel_scope;
if let Some(width) = snapshot.sidebar_width {
app.state.sidebar_width = width;
app.state.sidebar_width_source = state::SidebarWidthSource::Persisted;
@@ -1246,8 +1241,8 @@ impl App {
self.state.prompt_new_tab_name = config.ui.prompt_new_tab_name;
self.state.show_agent_labels_on_pane_borders =
config.ui.show_agent_labels_on_pane_borders;
- self.state.agent_panel_scope =
- agent_panel_scope_from_config(config.ui.agent_panel_scope);
+ self.state.agent_panel_sort =
+ agent_panel_sort_from_config(config.ui.agent_panel_sort);
self.state.agent_panel_scroll = 0;
self.state.accent = crate::config::parse_color(&config.ui.accent);
if !self.state.local_sound_playback && self.state.sound != config.ui.sound {
@@ -2065,17 +2060,14 @@ mod tests {
}
#[test]
- fn startup_uses_configured_agent_panel_scope() {
+ fn startup_uses_configured_agent_panel_sort() {
let mut config = Config::default();
- config.ui.agent_panel_scope = crate::config::AgentPanelScopeConfig::Current;
+ config.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority;
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default());
- assert_eq!(
- app.state.agent_panel_scope,
- state::AgentPanelScope::CurrentWorkspace
- );
+ assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Priority);
}
#[test]
@@ -2199,7 +2191,7 @@ mod tests {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
- "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\nredraw_on_focus_gained = false\nright_click_passthrough_modifier = \"ctrl\"\n[ui.toast]\ndelivery = \"herdr\"\n[experimental]\nswitch_ascii_input_source_in_prefix = true\n",
+ "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\nagent_panel_sort = \"priority\"\nredraw_on_focus_gained = false\nright_click_passthrough_modifier = \"ctrl\"\n[ui.toast]\ndelivery = \"herdr\"\n[experimental]\nswitch_ascii_input_source_in_prefix = true\n",
)
.unwrap();
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
@@ -2219,10 +2211,7 @@ mod tests {
app.state.toast_config.delivery,
crate::config::ToastDelivery::Herdr
);
- assert_eq!(
- app.state.agent_panel_scope,
- state::AgentPanelScope::CurrentWorkspace
- );
+ assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Priority);
assert!(!app.state.redraw_on_focus_gained);
assert_eq!(
app.state.right_click_passthrough_modifiers,
@@ -2561,27 +2550,21 @@ mod tests {
}
#[test]
- fn save_agent_panel_scope_persists_then_applies_live_config() {
+ fn save_agent_panel_sort_persists_then_applies_live_config() {
let _guard = config_env_lock().lock().unwrap();
- let path = temp_config_path("save-agent-panel-scope");
+ let path = temp_config_path("save-agent-panel-sort");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "onboarding = false\n").unwrap();
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
let mut app = test_app();
- assert_eq!(
- app.state.agent_panel_scope,
- state::AgentPanelScope::AllWorkspaces
- );
+ assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Spaces);
- app.save_agent_panel_scope(state::AgentPanelScope::CurrentWorkspace);
+ app.save_agent_panel_sort(state::AgentPanelSort::Priority);
- assert_eq!(
- app.state.agent_panel_scope,
- state::AgentPanelScope::CurrentWorkspace
- );
+ assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Priority);
let content = std::fs::read_to_string(&path).unwrap();
- assert!(content.contains("agent_panel_scope = \"current\""));
+ assert!(content.contains("agent_panel_sort = \"priority\""));
assert!(app.state.config_diagnostic.is_none());
std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index cb2e5712..815701b7 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -336,18 +336,10 @@ impl App {
}
fn agent_panel_has_animation(&self) -> bool {
- match self.state.agent_panel_scope {
- crate::app::state::AgentPanelScope::CurrentWorkspace => self
- .state
- .active
- .and_then(|idx| self.state.workspaces.get(idx))
- .is_some_and(|ws| ws.has_working_pane(&self.state.terminals)),
- crate::app::state::AgentPanelScope::AllWorkspaces => self
- .state
- .workspaces
- .iter()
- .any(|ws| ws.has_working_pane(&self.state.terminals)),
- }
+ self.state
+ .workspaces
+ .iter()
+ .any(|ws| ws.has_working_pane(&self.state.terminals))
}
pub(crate) fn tick_selection_autoscroll(&mut self, now: Instant) {
diff --git a/src/app/session.rs b/src/app/session.rs
index f9b38e79..5e174507 100644
--- a/src/app/session.rs
+++ b/src/app/session.rs
@@ -31,7 +31,6 @@ impl App {
&self.terminal_runtimes,
self.state.active,
self.state.selected,
- self.state.agent_panel_scope,
self.state.sidebar_width,
self.state.sidebar_section_split,
self.state.collapsed_space_keys.clone(),
diff --git a/src/app/state.rs b/src/app/state.rs
index 089834d8..585bb3e4 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -832,11 +832,11 @@ pub(crate) enum CopyModeSelection {
Linewise { anchor_row: u32 },
}
-#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-pub enum AgentPanelScope {
- CurrentWorkspace,
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub enum AgentPanelSort {
#[default]
- AllWorkspaces,
+ Spaces,
+ Priority,
}
// ---------------------------------------------------------------------------
@@ -1340,7 +1340,8 @@ pub struct AppState {
pub sidebar_collapsed: bool,
/// Ratio of sidebar height allocated to the workspaces section.
pub sidebar_section_split: f32,
- pub agent_panel_scope: AgentPanelScope,
+ pub agent_panel_sort: AgentPanelSort,
+ pub next_agent_state_change_seq: u64,
/// Capture mouse input for Herdr's own mouse UI. When false, Herdr only
/// captures mouse while the focused pane app requests mouse reporting.
pub mouse_capture: bool,
@@ -1689,7 +1690,8 @@ impl AppState {
sidebar_width_auto: false,
sidebar_collapsed: false,
sidebar_section_split: 0.5,
- agent_panel_scope: AgentPanelScope::AllWorkspaces,
+ agent_panel_sort: AgentPanelSort::Spaces,
+ next_agent_state_change_seq: 0,
mouse_capture: true,
right_click_passthrough_modifiers: None,
right_click_passthrough: None,
diff --git a/src/config.rs b/src/config.rs
index 758ff331..bf5fceb4 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -18,7 +18,7 @@ pub use self::{
IndexedKeybind, Keybinds, LiveKeybindConfig,
},
model::{
- validated_sidebar_bounds, AgentPanelScopeConfig, Config, ConfigReloadReport,
+ validated_sidebar_bounds, AgentPanelSortConfig, Config, ConfigReloadReport,
ConfigReloadStatus, KeysConfig, NewTerminalCwdConfig, ShellModeConfig,
ToastClipboardPosition, ToastConfig, ToastDelivery, ToastHerdrPosition,
UpdateChannelConfig, MAX_TOAST_DELAY_SECONDS,
diff --git a/src/config/model.rs b/src/config/model.rs
index 95915590..c8fa7f71 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -84,17 +84,18 @@ pub enum ToastClipboardPosition {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
#[serde(rename_all = "lowercase")]
-pub enum AgentPanelScopeConfig {
- Current,
+pub enum AgentPanelSortConfig {
#[default]
- All,
+ #[serde(alias = "workspaces")]
+ Spaces,
+ Priority,
}
-impl AgentPanelScopeConfig {
+impl AgentPanelSortConfig {
pub fn as_str(self) -> &'static str {
match self {
- Self::Current => "current",
- Self::All => "all",
+ Self::Spaces => "spaces",
+ Self::Priority => "priority",
}
}
}
@@ -440,8 +441,8 @@ pub struct UiConfig {
pub prompt_new_tab_name: bool,
/// Show agent labels in split pane borders when no manual pane label is set. Default: false.
pub show_agent_labels_on_pane_borders: bool,
- /// Agent sidebar scope. Saved values are "current" or "all". Default: "all".
- pub agent_panel_scope: AgentPanelScopeConfig,
+ /// Agent sidebar ordering. Saved values are "spaces" or "priority". Default: "spaces".
+ pub agent_panel_sort: AgentPanelSortConfig,
/// Accent color for highlights, borders, and navigation UI.
/// Accepts hex (#89b4fa), named colors (cyan, blue), or RGB (rgb(137,180,250)).
pub accent: String,
@@ -626,7 +627,7 @@ impl Default for UiConfig {
confirm_close: true,
prompt_new_tab_name: true,
show_agent_labels_on_pane_borders: false,
- agent_panel_scope: AgentPanelScopeConfig::All,
+ agent_panel_sort: AgentPanelSortConfig::Spaces,
accent: "cyan".into(),
toast: ToastConfig::default(),
sound: SoundConfig::default(),
@@ -797,13 +798,32 @@ resume_agents_on_restore = false
}
#[test]
- fn agent_panel_scope_config_parses() {
+ fn agent_panel_sort_config_parses_alias_and_defaults() {
+ assert_eq!(
+ Config::default().ui.agent_panel_sort,
+ AgentPanelSortConfig::Spaces
+ );
+
+ let toml = r#"
+[ui]
+agent_panel_sort = "priority"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ assert_eq!(config.ui.agent_panel_sort, AgentPanelSortConfig::Priority);
+
+ let toml = r#"
+[ui]
+agent_panel_sort = "workspaces"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ assert_eq!(config.ui.agent_panel_sort, AgentPanelSortConfig::Spaces);
+
let toml = r#"
[ui]
-agent_panel_scope = "all"
+agent_panel_scope = "current"
"#;
let config: Config = toml::from_str(toml).unwrap();
- assert_eq!(config.ui.agent_panel_scope, AgentPanelScopeConfig::All);
+ assert_eq!(config.ui.agent_panel_sort, AgentPanelSortConfig::Spaces);
}
#[test]
diff --git a/src/main.rs b/src/main.rs
index d78dd2ad..11bd2550 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -250,8 +250,9 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# Show detected/reported agent labels in split pane borders when no manual pane name is set.
# show_agent_labels_on_pane_borders = false
-# Agent panel scope: "current" or "all". Toggling it in the sidebar saves this setting.
-# agent_panel_scope = "all"
+# Agent panel ordering: "spaces" (grouped by space) or "priority" (attention queue).
+# "workspaces" is accepted as an alias for "spaces".
+# agent_panel_sort = "spaces"
# Accent color for highlights, borders, and navigation UI.
# Accepts: hex (#89b4fa), named colors (cyan, blue, magenta), or rgb(r,g,b)
diff --git a/src/persist/io.rs b/src/persist/io.rs
index 12f3c9c5..3344f5ec 100644
--- a/src/persist/io.rs
+++ b/src/persist/io.rs
@@ -175,7 +175,6 @@ pub fn load_history() -> Option<SessionHistorySnapshot> {
#[cfg(test)]
mod tests {
use super::*;
- use crate::app::state::AgentPanelScope;
use crate::persist::snapshot::{
PaneHistorySnapshot, TabHistorySnapshot, WorkspaceHistorySnapshot,
};
@@ -205,7 +204,6 @@ mod tests {
workspaces: vec![],
active: None,
selected: 0,
- agent_panel_scope: AgentPanelScope::CurrentWorkspace,
sidebar_width: Some(26),
sidebar_section_split: Some(0.5),
collapsed_space_keys: std::collections::HashSet::new(),
diff --git a/src/persist/restore.rs b/src/persist/restore.rs
index 7c046698..162c13ed 100644
--- a/src/persist/restore.rs
+++ b/src/persist/restore.rs
@@ -1188,7 +1188,6 @@ mod tests {
}],
active: Some(0),
selected: 0,
- agent_panel_scope: Default::default(),
sidebar_width: None,
sidebar_section_split: None,
collapsed_space_keys: Default::default(),
@@ -1278,7 +1277,6 @@ mod tests {
}],
active: Some(0),
selected: 0,
- agent_panel_scope: Default::default(),
sidebar_width: None,
sidebar_section_split: None,
collapsed_space_keys: Default::default(),
@@ -1384,7 +1382,6 @@ mod tests {
}],
active: Some(0),
selected: 0,
- agent_panel_scope: Default::default(),
sidebar_width: None,
sidebar_section_split: None,
collapsed_space_keys: Default::default(),
@@ -1496,7 +1493,6 @@ mod tests {
}],
active: Some(0),
selected: 0,
- agent_panel_scope: Default::default(),
sidebar_width: None,
sidebar_section_split: None,
collapsed_space_keys: Default::default(),
@@ -1687,7 +1683,6 @@ mod tests {
}],
active: Some(0),
selected: 0,
- agent_panel_scope: crate::app::state::AgentPanelScope::CurrentWorkspace,
sidebar_width: Some(26),
sidebar_section_split: Some(0.5),
collapsed_space_keys: Default::default(),
diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs
index 765fa2b6..506a4d45 100644
--- a/src/persist/snapshot.rs
+++ b/src/persist/snapshot.rs
@@ -21,8 +21,6 @@ pub struct SessionSnapshot {
pub active: Option<usize>,
pub selected: usize,
#[serde(default)]
- pub agent_panel_scope: crate::app::state::AgentPanelScope,
- #[serde(default)]
pub sidebar_width: Option<u16>,
#[serde(default)]
pub sidebar_section_split: Option<f32>,
@@ -179,8 +177,6 @@ struct RawSessionSnapshot {
#[serde(default)]
selected: usize,
#[serde(default)]
- agent_panel_scope: crate::app::state::AgentPanelScope,
- #[serde(default)]
sidebar_width: Option<u16>,
#[serde(default)]
sidebar_section_split: Option<f32>,
@@ -198,7 +194,6 @@ fn migrate_snapshot(raw: RawSessionSnapshot) -> Result<SessionSnapshot, String>
.collect::<Result<Vec<_>, _>>()?,
active: raw.active,
selected: raw.selected,
- agent_panel_scope: raw.agent_panel_scope,
sidebar_width: raw.sidebar_width,
sidebar_section_split: raw.sidebar_section_split,
collapsed_space_keys: raw.collapsed_space_keys,
@@ -261,7 +256,6 @@ pub fn capture(
terminal_runtimes: &TerminalRuntimeRegistry,
active: Option<usize>,
selected: usize,
- agent_panel_scope: crate::app::state::AgentPanelScope,
sidebar_width: u16,
sidebar_section_split: f32,
collapsed_space_keys: std::collections::HashSet<String>,
@@ -274,7 +268,6 @@ pub fn capture(
.collect(),
active,
selected,
- agent_panel_scope,
sidebar_width: Some(sidebar_width),
sidebar_section_split: Some(sidebar_section_split),
collapsed_space_keys,
@@ -489,7 +482,7 @@ mod tests {
use ratatui::layout::{Direction, Rect};
use super::*;
- use crate::app::{state::AgentPanelScope, AppState, Mode};
+ use crate::app::{AppState, Mode};
use crate::layout::NavDirection;
use crate::workspace::Workspace;
@@ -543,7 +536,6 @@ mod tests {
terminal_runtimes,
state.active,
state.selected,
- state.agent_panel_scope,
state.sidebar_width,
state.sidebar_section_split,
state.collapsed_space_keys.clone(),
@@ -571,7 +563,6 @@ mod tests {
workspaces: vec![],
active: None,
selected: 0,
- agent_panel_scope: AgentPanelScope::CurrentWorkspace,
sidebar_width: Some(26),
sidebar_section_split: Some(0.5),
collapsed_space_keys: std::collections::HashSet::new(),
@@ -657,7 +648,6 @@ mod tests {
}],
active: Some(0),
selected: 0,
- agent_panel_scope: AgentPanelScope::CurrentWorkspace,
sidebar_width: Some(26),
sidebar_section_split: Some(0.5),
collapsed_space_keys: std::collections::HashSet::new(),
@@ -683,10 +673,6 @@ mod tests {
restored.workspaces[0].tabs[0].panes[&1].label.as_deref(),
Some("website")
);
- assert_eq!(
- restored.agent_panel_scope,
- AgentPanelScope::CurrentWorkspace
- );
assert_eq!(restored.sidebar_width, Some(26));
assert_eq!(restored.sidebar_section_split, Some(0.5));
}
@@ -699,7 +685,6 @@ mod tests {
assert_eq!(snap.workspaces.len(), 2);
assert_eq!(snap.active, Some(0));
assert_eq!(snap.selected, 0);
- assert_eq!(snap.agent_panel_scope, AgentPanelScope::AllWorkspaces);
assert_eq!(snap.sidebar_width, None);
assert_eq!(snap.sidebar_section_split, None);
assert_eq!(snap.workspaces[0].tabs.len(), 2);
@@ -715,14 +700,13 @@ mod tests {
assert_eq!(snap.version, 3);
assert_eq!(snap.workspaces.len(), 2);
- assert_eq!(snap.agent_panel_scope, AgentPanelScope::CurrentWorkspace);
assert_eq!(snap.sidebar_section_split, Some(0.4));
assert_eq!(snap.workspaces[0].active_tab, 1);
assert_eq!(snap.workspaces[1].tabs[0].panes.len(), 2);
}
#[test]
- fn old_snapshot_defaults_agent_panel_scope() {
+ fn old_snapshot_defaults_sidebar_fields() {
let json = serde_json::json!({
"version": SNAPSHOT_VERSION,
"workspaces": [],
@@ -733,7 +717,6 @@ mod tests {
let restored = parse_snapshot(&json).unwrap();
- assert_eq!(restored.agent_panel_scope, AgentPanelScope::AllWorkspaces);
assert_eq!(restored.sidebar_width, None);
assert_eq!(restored.sidebar_section_split, None);
}
@@ -847,13 +830,11 @@ mod tests {
let mut state = state_with_workspaces(&["one"]);
state.sidebar_width = 31;
state.sidebar_section_split = 0.4;
- state.agent_panel_scope = AgentPanelScope::AllWorkspaces;
state.collapsed_space_keys.insert("repo-key".into());
let snapshot = capture_from_state(&state);
assert_eq!(snapshot.sidebar_width, Some(31));
assert_eq!(snapshot.sidebar_section_split, Some(0.4));
- assert_eq!(snapshot.agent_panel_scope, AgentPanelScope::AllWorkspaces);
assert!(snapshot.collapsed_space_keys.contains("repo-key"));
}
@@ -1222,7 +1203,6 @@ mod tests {
}],
active: Some(0),
selected: 0,
- agent_panel_scope: AgentPanelScope::CurrentWorkspace,
sidebar_width: Some(26),
sidebar_section_split: Some(0.5),
collapsed_space_keys: std::collections::HashSet::new(),
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 750e9250..67a876f9 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -861,7 +861,6 @@ impl HeadlessServer {
&self.app.terminal_runtimes,
self.app.state.active,
self.app.state.selected,
- self.app.state.agent_panel_scope,
self.app.state.sidebar_width,
self.app.state.sidebar_section_split,
self.app.state.collapsed_space_keys.clone(),
diff --git a/src/terminal/state.rs b/src/terminal/state.rs
index 07e84354..b1c0640c 100644
--- a/src/terminal/state.rs
+++ b/src/terminal/state.rs
@@ -73,6 +73,7 @@ pub struct TerminalState {
suppressed_full_lifecycle_hook_reports: HashMap<String, SuppressedFullLifecycleHookReport>,
metadata_report_sequences: HashMap<String, u64>,
pub state: AgentState,
+ pub last_agent_state_change_seq: Option<u64>,
pub revision: u64,
pub launch_argv: Option<Vec<String>>,
pub respawn_shell_on_exit: bool,
@@ -97,6 +98,7 @@ impl TerminalState {
suppressed_full_lifecycle_hook_reports: HashMap::new(),
metadata_report_sequences: HashMap::new(),
state: AgentState::Unknown,
+ last_agent_state_change_seq: None,
revision: 0,
launch_argv: None,
respawn_shell_on_exit: false,
@@ -904,6 +906,7 @@ impl TerminalState {
self.agent_metadata.clear();
self.suppressed_full_lifecycle_hook_reports.clear();
self.state = AgentState::Unknown;
+ self.last_agent_state_change_seq = None;
self.launch_argv = None;
self.respawn_shell_on_exit = false;
self.pending_agent_resume_plan = None;
diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs
index d074b68f..80cee6b5 100644
--- a/src/ui/mobile.rs
+++ b/src/ui/mobile.rs
@@ -959,6 +959,7 @@ mod tests {
agent_label: agent_label.map(str::to_string),
state: AgentState::Idle,
seen: true,
+ last_agent_state_change_seq: None,
custom_status: None,
state_labels: std::collections::HashMap::new(),
}
diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs
index 0447b119..15c0a37e 100644
--- a/src/ui/sidebar.rs
+++ b/src/ui/sidebar.rs
@@ -8,7 +8,7 @@ use ratatui::{
use super::scrollbar::{render_scrollbar, should_show_scrollbar};
use super::status::{agent_icon, state_dot, state_label, state_label_color};
-use crate::app::state::{AgentPanelScope, Palette};
+use crate::app::state::{AgentPanelSort, Palette};
use crate::app::{AppState, Mode};
use crate::detect::AgentState;
use crate::terminal::TerminalRuntimeRegistry;
@@ -25,6 +25,7 @@ pub(crate) struct AgentPanelEntry {
pub agent_label: Option<String>,
pub state: AgentState,
pub seen: bool,
+ pub last_agent_state_change_seq: Option<u64>,
pub custom_status: Option<String>,
pub state_labels: std::collections::HashMap<String, String>,
}
@@ -68,39 +69,19 @@ pub(crate) fn sidebar_section_divider_rect(area: Rect, split_ratio: f32) -> Rect
Rect::new(content.x, content.y + ws_h, content.width, 1)
}
-fn agent_panel_current_workspace_idx(app: &AppState) -> Option<usize> {
- if matches!(
- app.mode,
- Mode::Navigate
- | Mode::RenameWorkspace
- | Mode::RenamePane
- | Mode::Resize
- | Mode::ConfirmClose
- | Mode::ContextMenu
- | Mode::Settings
- | Mode::GlobalMenu
- | Mode::KeybindHelp
- | Mode::ProductAnnouncement
- ) {
- Some(app.selected)
- } else {
- app.active
- }
-}
-
-fn agent_panel_toggle_label(scope: AgentPanelScope) -> &'static str {
- match scope {
- AgentPanelScope::CurrentWorkspace => "current",
- AgentPanelScope::AllWorkspaces => "all",
+fn agent_panel_sort_label(sort: AgentPanelSort) -> &'static str {
+ match sort {
+ AgentPanelSort::Spaces => "grouped",
+ AgentPanelSort::Priority => "priority",
}
}
-pub(crate) fn agent_panel_toggle_rect(area: Rect, scope: AgentPanelScope) -> Rect {
+pub(crate) fn agent_panel_toggle_rect(area: Rect, sort: AgentPanelSort) -> Rect {
if area.width == 0 || area.height < 2 {
return Rect::default();
}
- let label = agent_panel_toggle_label(scope);
+ let label = agent_panel_sort_label(sort);
let width = label.chars().count() as u16;
Rect::new(
area.x + area.width.saturating_sub(width),
@@ -134,54 +115,41 @@ fn agent_panel_entries_with_runtimes(
}
};
- match app.agent_panel_scope {
- AgentPanelScope::CurrentWorkspace => {
- let Some(ws_idx) = agent_panel_current_workspace_idx(app) else {
- return Vec::new();
- };
- let Some(ws) = app.workspaces.get(ws_idx) else {
- return Vec::new();
- };
+ let mut entries: Vec<_> = app
+ .workspaces
+ .iter()
+ .enumerate()
+ .flat_map(|(ws_idx, ws)| {
+ let multi_tab = ws.tabs.len() > 1;
+ let workspace_label = ws.display_name_from(&app.terminals, terminal_runtimes);
ws.pane_details(&app.terminals)
.into_iter()
- .map(|detail| AgentPanelEntry {
+ .map(move |detail| AgentPanelEntry {
ws_idx,
tab_idx: detail.tab_idx,
pane_id: detail.pane_id,
- primary_label: detail.label,
- primary_tab_label: None,
- agent_label: None,
+ primary_label: workspace_label.clone(),
+ primary_tab_label: multi_tab.then_some(detail.tab_label),
+ agent_label: Some(detail.agent_label),
state: detail.state,
seen: detail.seen,
+ last_agent_state_change_seq: detail.last_agent_state_change_seq,
custom_status: detail.custom_status,
state_labels: detail.state_labels,
})
- .collect()
- }
- AgentPanelScope::AllWorkspaces => app
- .workspaces
- .iter()
- .enumerate()
- .flat_map(|(ws_idx, ws)| {
- let multi_tab = ws.tabs.len() > 1;
- let workspace_label = ws.display_name_from(&app.terminals, terminal_runtimes);
- ws.pane_details(&app.terminals)
- .into_iter()
- .map(move |detail| AgentPanelEntry {
- ws_idx,
- tab_idx: detail.tab_idx,
- pane_id: detail.pane_id,
- primary_label: workspace_label.clone(),
- primary_tab_label: multi_tab.then_some(detail.tab_label),
- agent_label: Some(detail.agent_label),
- state: detail.state,
- seen: detail.seen,
- custom_status: detail.custom_status,
- state_labels: detail.state_labels,
- })
- })
- .collect(),
+ })
+ .collect();
+
+ if matches!(app.agent_panel_sort, AgentPanelSort::Priority) {
+ entries.sort_by_key(|entry| {
+ (
+ std::cmp::Reverse(workspace_attention_priority(entry.state, entry.seen)),
+ std::cmp::Reverse(entry.last_agent_state_change_seq),
+ )
+ });
}
+
+ entries
}
pub(super) fn agent_panel_status_key(state: AgentState, seen: bool) -> &'static str {
@@ -1067,11 +1035,11 @@ fn render_agent_detail(
)])),
Rect::new(area.x, area.y + 1, area.width, 1),
);
- let toggle_rect = agent_panel_toggle_rect(area, app.agent_panel_scope);
+ let toggle_rect = agent_panel_toggle_rect(area, app.agent_panel_sort);
if toggle_rect != Rect::default() {
frame.render_widget(
Paragraph::new(Span::styled(
- agent_panel_toggle_label(app.agent_panel_scope),
+ agent_panel_sort_label(app.agent_panel_sort),
Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD),
))
.alignment(Alignment::Right),
@@ -1271,7 +1239,6 @@ mod tests {
.detected_agent = Some(Agent::Claude);
app.active = Some(0);
app.selected = 0;
- app.agent_panel_scope = AgentPanelScope::AllWorkspaces;
let entries = agent_panel_entries(&app);
assert_eq!(entries[0].primary_label, "one");
@@ -1282,6 +1249,49 @@ mod tests {
assert_eq!(entries[1].agent_label.as_deref(), Some("claude"));
}
+ #[test]
+ fn priority_agent_panel_sort_uses_attention_then_space_order() {
+ let mut app = crate::app::state::AppState::test_new();
+ app.workspaces = vec![
+ Workspace::test_new("one"),
+ Workspace::test_new("two"),
+ Workspace::test_new("three"),
+ Workspace::test_new("four"),
+ ];
+ app.ensure_test_terminals();
+ app.active = Some(0);
+ app.selected = 0;
+ app.agent_panel_sort = crate::app::state::AgentPanelSort::Priority;
+
+ let set_state = |app: &mut crate::app::state::AppState, ws_idx: usize, state| {
+ let pane = app.workspaces[ws_idx].tabs[0].root_pane;
+ let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane]
+ .attached_terminal_id
+ .clone();
+ let terminal = app.terminals.get_mut(&terminal_id).unwrap();
+ terminal.detected_agent = Some(Agent::Claude);
+ terminal.state = state;
+ };
+ set_state(&mut app, 0, AgentState::Working);
+ set_state(&mut app, 1, AgentState::Idle);
+ set_state(&mut app, 2, AgentState::Working);
+ set_state(&mut app, 3, AgentState::Blocked);
+
+ let done_pane = app.workspaces[1].tabs[0].root_pane;
+ app.workspaces[1].tabs[0]
+ .panes
+ .get_mut(&done_pane)
+ .unwrap()
+ .seen = false;
+
+ let labels: Vec<String> = agent_panel_entries(&app)
+ .into_iter()
+ .map(|entry| entry.primary_label)
+ .collect();
+
+ assert_eq!(labels, ["four", "two", "one", "three"]);
+ }
+
#[cfg(unix)]
#[tokio::test]
async fn all_workspaces_agent_panel_entries_use_live_root_runtime_cwd_for_workspace_label() {
@@ -1315,7 +1325,6 @@ mod tests {
terminal.detected_agent = Some(Agent::Pi);
app.active = Some(0);
app.selected = 0;
- app.agent_panel_scope = AgentPanelScope::AllWorkspaces;
let (events, _) = tokio::sync::mpsc::channel(4);
let runtime = crate::terminal::TerminalRuntime::spawn(
@@ -1372,7 +1381,6 @@ mod tests {
.set_agent_name("planner".into());
app.active = Some(0);
app.selected = 0;
- app.agent_panel_scope = AgentPanelScope::AllWorkspaces;
let entries = agent_panel_entries(&app);
assert_eq!(entries[0].primary_label, "bridge");
@@ -1390,6 +1398,7 @@ mod tests {
agent_label: Some("claude".into()),
state: AgentState::Idle,
seen: true,
+ last_agent_state_change_seq: None,
custom_status: None,
state_labels: std::collections::HashMap::new(),
};
diff --git a/src/workspace/aggregate.rs b/src/workspace/aggregate.rs
index d1fcf984..365decbf 100644
--- a/src/workspace/aggregate.rs
+++ b/src/workspace/aggregate.rs
@@ -17,6 +17,7 @@ pub struct PaneDetail {
pub agent: Option<Agent>,
pub state: AgentState,
pub seen: bool,
+ pub last_agent_state_change_seq: Option<u64>,
pub custom_status: Option<String>,
pub state_labels: HashMap<String, String>,
}
@@ -60,6 +61,7 @@ impl Tab {
agent: terminal.effective_known_agent(),
state: terminal.state,
seen: pane.seen,
+ last_agent_state_change_seq: terminal.last_agent_state_change_seq,
custom_status: presentation.custom_status,
state_labels: presentation.state_labels,
})