Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/next/website/src/data/config-reference.json
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,16 @@
"priority"
]
},
{
"key": "ui.spaces_sort",
"type": "enum",
"default": "\"manual\"",
"description": "Spaces sidebar ordering. Saved values are \"manual\" or \"priority\". Manual keeps the order in which spaces were added or reordered.",
"values": [
"manual",
"priority"
]
},
{
"key": "ui.status_indicators",
"type": "enum",
Expand Down
21 changes: 21 additions & 0 deletions src/app/config_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,4 +125,25 @@ impl App {
self.apply_config_from_disk(false);
}
}

pub(super) fn save_spaces_sort(&mut self, sort: crate::app::state::SpacesSort) {
let value = match sort {
crate::app::state::SpacesSort::Manual => {
crate::config::SpacesSortConfig::Manual.as_str()
}
crate::app::state::SpacesSort::Priority => {
crate::config::SpacesSortConfig::Priority.as_str()
}
};
if self.update_config_file("spaces sort", |content| {
crate::config::upsert_section_value(
content,
"ui",
"spaces_sort",
&format!("\"{value}\""),
)
}) {
self.apply_config_from_disk(false);
}
}
}
4 changes: 4 additions & 0 deletions src/app/input/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ impl App {
}

let previous_agent_panel_sort = self.state.agent_panel_sort;
let previous_spaces_sort = self.state.spaces_sort;
let previous_settings_section = self.state.settings.section;
if !handled_pane_double_click {
if let Some(action) =
Expand Down Expand Up @@ -466,6 +467,9 @@ impl App {
if self.state.agent_panel_sort != previous_agent_panel_sort {
self.save_agent_panel_sort(self.state.agent_panel_sort);
}
if self.state.spaces_sort != previous_spaces_sort {
self.save_spaces_sort(self.state.spaces_sort);
}

self.dispatch_pending_clipboard_write();

Expand Down
12 changes: 11 additions & 1 deletion src/app/input/mouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use tracing::warn;
use crate::{
app::state::{
AgentPanelSort, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget,
MenuListState, Mode, RightClickPassthroughGesture, TabPressState, ViewLayout,
MenuListState, Mode, RightClickPassthroughGesture, SpacesSort, TabPressState, ViewLayout,
WorkspacePressState,
},
layout::{PaneInfo, SplitBorder},
Expand Down Expand Up @@ -541,6 +541,16 @@ impl AppState {
return None;
}

if self.on_spaces_sort_toggle(mouse.column, mouse.row) {
self.spaces_sort = match self.spaces_sort {
SpacesSort::Manual => SpacesSort::Priority,
SpacesSort::Priority => SpacesSort::Manual,
};
self.workspace_scroll = 0;
self.mark_session_dirty();
return None;
}

let new_button = self.sidebar_new_button_rect();
let on_new_button = mouse.row >= new_button.y
&& mouse.row < new_button.y + new_button.height
Expand Down
91 changes: 89 additions & 2 deletions src/app/input/sidebar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,8 +326,9 @@ impl AppState {
return None;
}

let order = crate::ui::workspace_index_order(self);
let idx = (row - ws_area.y) as usize;
(idx < self.workspaces.len()).then_some(idx)
order.get(idx).copied()
}

pub(super) fn collapsed_agent_detail_target_at(
Expand Down Expand Up @@ -482,6 +483,23 @@ impl AppState {
&& row < rect.y + rect.height
}

pub(super) fn on_spaces_sort_toggle(&self, col: u16, row: u16) -> bool {
if self.sidebar_collapsed {
return false;
}

let (ws_area, _) = crate::ui::expanded_sidebar_sections(
self.view.sidebar_rect,
self.sidebar_section_split,
);
let rect = crate::ui::spaces_sort_toggle_rect(ws_area, self.spaces_sort);
rect.width > 0
&& col >= rect.x
&& col < rect.x + rect.width
&& row >= rect.y
&& row < rect.y + rect.height
}

pub(super) fn agent_detail_target_at(
&self,
row: u16,
Expand Down Expand Up @@ -530,7 +548,7 @@ mod tests {

use super::super::{app_for_mouse_test, capture_snapshot, mouse, unique_temp_path};
use crate::{
app::state::{AgentPanelSort, DragTarget, Mode},
app::state::{AgentPanelSort, DragTarget, Mode, SpacesSort},
config::SidebarCollapsedModeConfig,
detect::{Agent, AgentState},
workspace::Workspace,
Expand Down Expand Up @@ -867,6 +885,30 @@ mod tests {
assert_eq!(app.state.agent_panel_scroll, 0);
}

#[test]
fn clicking_spaces_toggle_switches_sort() {
let mut app = app_for_mouse_test();
app.state.workspaces = vec![Workspace::test_new("test")];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
app.state.workspace_scroll = 3;

let (ws_area, _) = crate::ui::expanded_sidebar_sections(
app.state.view.sidebar_rect,
app.state.sidebar_section_split,
);
let toggle = crate::ui::spaces_sort_toggle_rect(ws_area, app.state.spaces_sort);
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
toggle.x,
toggle.y,
));

assert_eq!(app.state.spaces_sort, SpacesSort::Priority);
assert_eq!(app.state.workspace_scroll, 0);
}

#[test]
fn clicking_all_workspaces_agent_row_switches_to_correct_workspace() {
let mut app = app_for_mouse_test();
Expand Down Expand Up @@ -1137,6 +1179,51 @@ mod tests {
);
}

#[test]
fn clicking_collapsed_workspace_glance_uses_priority_spaces_sort() {
let mut app = app_for_mouse_test();
let idle = Workspace::test_new("idle");
let idle_pane = idle.tabs[0].root_pane;
let working = Workspace::test_new("working");
let working_pane = working.tabs[0].root_pane;
let blocked = Workspace::test_new("blocked");
let blocked_pane = blocked.tabs[0].root_pane;

app.state.workspaces = vec![idle, working, blocked];
app.state.ensure_test_terminals();
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
app.state.sidebar_collapsed = true;
app.state.spaces_sort = SpacesSort::Priority;
app.state.view.sidebar_rect = Rect::new(0, 0, 4, 20);
app.state.view.terminal_area = Rect::new(4, 0, 80, 20);

let set_state = |app: &mut crate::app::App, ws_idx: usize, pane_id, state| {
let terminal_id = app.state.workspaces[ws_idx].tabs[0].panes[&pane_id]
.attached_terminal_id
.clone();
let terminal = app.state.terminals.get_mut(&terminal_id).unwrap();
terminal.detected_agent = Some(Agent::Claude);
terminal.state = state;
};
set_state(&mut app, 0, idle_pane, AgentState::Idle);
set_state(&mut app, 1, working_pane, AgentState::Working);
set_state(&mut app, 2, blocked_pane, AgentState::Blocked);

let (ws_area, _, _) =
crate::ui::collapsed_sidebar_sections(app.state.view.sidebar_rect);
// Top glance row is the blocked workspace (idx 2) under priority sort.
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
ws_area.x,
ws_area.y,
));

assert_eq!(app.state.active, Some(2));
assert_eq!(app.state.selected, 2);
}

#[test]
fn clicking_collapsed_sidebar_toggle_expands_sidebar() {
let mut app = app_for_mouse_test();
Expand Down
43 changes: 43 additions & 0 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,13 @@ fn agent_panel_sort_from_config(
}
}

fn spaces_sort_from_config(sort: crate::config::SpacesSortConfig) -> state::SpacesSort {
match sort {
crate::config::SpacesSortConfig::Manual => state::SpacesSort::Manual,
crate::config::SpacesSortConfig::Priority => state::SpacesSort::Priority,
}
}

/// Parse the configured agent name list into a deduplicated set of `Agent`
/// values. Unknown agent names are silently dropped so a typo cannot disable
/// other valid entries.
Expand Down Expand Up @@ -476,6 +483,7 @@ impl App {
};

let agent_panel_sort = agent_panel_sort_from_config(config.ui.agent_panel_sort);
let spaces_sort = spaces_sort_from_config(config.ui.spaces_sort);

// Validate sidebar bounds before they reach any `u16::clamp(min, max)`
// call: `clamp` panics when `min > max`. On bad config, fall back to
Expand Down Expand Up @@ -630,6 +638,7 @@ impl App {
sidebar_collapsed_mode: config.ui.sidebar_collapsed_mode,
sidebar_section_split,
agent_panel_sort,
spaces_sort,
status_indicators: config.ui.status_indicators,
agent_view_override: None,
sidebar_agents: config.ui.sidebar.agents.clone(),
Expand Down Expand Up @@ -1501,6 +1510,7 @@ impl App {
self.configure_window_title(&config.ui.window_title);
self.state.agent_panel_sort =
agent_panel_sort_from_config(config.ui.agent_panel_sort);
self.state.spaces_sort = spaces_sort_from_config(config.ui.spaces_sort);
self.state.status_indicators = config.ui.status_indicators;
self.state.sidebar_agents = config.ui.sidebar.agents.clone();
self.state.sidebar_spaces = config.ui.sidebar.spaces.clone();
Expand Down Expand Up @@ -2738,6 +2748,17 @@ mod tests {
assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Priority);
}

#[test]
fn startup_uses_configured_spaces_sort() {
let mut config = Config::default();
config.ui.spaces_sort = crate::config::SpacesSortConfig::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.spaces_sort, state::SpacesSort::Priority);
}

#[test]
fn startup_uses_configured_sidebar_state() {
let mut config = Config::default();
Expand Down Expand Up @@ -3586,6 +3607,28 @@ mod tests {
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}

#[test]
fn save_spaces_sort_persists_then_applies_live_config() {
let _guard = config_env_lock().lock().unwrap();
let path = temp_config_path("save-spaces-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.spaces_sort, state::SpacesSort::Manual);

app.save_spaces_sort(state::SpacesSort::Priority);

assert_eq!(app.state.spaces_sort, state::SpacesSort::Priority);
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("spaces_sort = \"priority\""));
assert!(app.state.config_diagnostic.is_none());

std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}

#[test]
fn save_agent_panel_sort_persists_then_applies_live_config() {
let _guard = config_env_lock().lock().unwrap();
Expand Down
14 changes: 14 additions & 0 deletions src/app/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -991,6 +991,17 @@ pub enum AgentPanelSort {
Priority,
}

/// How entries in the sidebar spaces section are ordered.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum SpacesSort {
/// Manual order (the order in which spaces/workspaces were added or
/// reordered by the user). This is the default behavior.
#[default]
Manual,
/// Sort by agent attention priority, most urgent first.
Priority,
}

// ---------------------------------------------------------------------------
// Settings UI state
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1440,6 +1451,8 @@ pub struct AppState {
/// Ratio of sidebar height allocated to the workspaces section.
pub sidebar_section_split: f32,
pub agent_panel_sort: AgentPanelSort,
/// How entries in the sidebar spaces section are ordered.
pub spaces_sort: SpacesSort,
pub status_indicators: crate::config::StatusIndicatorStyle,
/// Transient session-wide projection override for the built-in Agents view.
pub agent_view_override: Option<crate::api::schema::AgentViewSetParams>,
Expand Down Expand Up @@ -1811,6 +1824,7 @@ impl AppState {
sidebar_collapsed_mode: crate::config::SidebarCollapsedModeConfig::Compact,
sidebar_section_split: 0.5,
agent_panel_sort: AgentPanelSort::Spaces,
spaces_sort: SpacesSort::Manual,
status_indicators: crate::config::StatusIndicatorStyle::Dots,
agent_view_override: None,
sidebar_agents: crate::config::AgentsSidebarConfig::default(),
Expand Down
2 changes: 1 addition & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ pub use self::{
model::{
validated_sidebar_bounds, AgentPanelSortConfig, Config, ConfigReloadReport,
ConfigReloadStatus, HostCursorModeConfig, NewTerminalCwdConfig, ShellModeConfig,
SidebarCollapsedModeConfig, StatusIndicatorStyle, TabBarPositionConfig,
SidebarCollapsedModeConfig, SpacesSortConfig, StatusIndicatorStyle, TabBarPositionConfig,
ToastClipboardPosition, ToastConfig, ToastDelivery, ToastHerdrPosition,
UpdateChannelConfig, MAX_TOAST_DELAY_SECONDS,
},
Expand Down
20 changes: 20 additions & 0 deletions src/config/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,23 @@ impl AgentPanelSortConfig {
}
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SpacesSortConfig {
#[default]
Manual,
Priority,
}

impl SpacesSortConfig {
pub fn as_str(self) -> &'static str {
match self {
Self::Manual => "manual",
Self::Priority => "priority",
}
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
enum LegacyAgentPanelScopeConfig {
Expand Down Expand Up @@ -895,6 +912,8 @@ pub struct UiConfig {
pub window_title: String,
/// Agent sidebar ordering. Saved values are "spaces" or "priority". Default: "spaces".
pub agent_panel_sort: AgentPanelSortConfig,
/// Spaces sidebar ordering. Saved values are "manual" or "priority". Default: "manual".
pub spaces_sort: SpacesSortConfig,
/// Retired setting that Herdr wrote before the workspace filter was removed.
#[serde(rename = "agent_panel_scope")]
_legacy_agent_panel_scope: Option<LegacyAgentPanelScopeConfig>,
Expand Down Expand Up @@ -1113,6 +1132,7 @@ impl Default for UiConfig {
tab_bar_right_separator: " ".into(),
window_title: super::window_title::default_window_title(),
agent_panel_sort: AgentPanelSortConfig::Spaces,
spaces_sort: SpacesSortConfig::Manual,
_legacy_agent_panel_scope: None,
status_indicators: StatusIndicatorStyle::Dots,
sidebar: SidebarConfig::default(),
Expand Down
9 changes: 5 additions & 4 deletions src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,11 @@ pub(crate) use self::{
agent_panel_toggle_rect, all_agent_panel_entries, collapsed_sidebar_sections,
collapsed_sidebar_toggle_rect, compute_workspace_card_areas, expanded_sidebar_sections,
expanded_sidebar_toggle_rect, normalized_workspace_scroll, sidebar_section_divider_rect,
workspace_drop_slots, workspace_group_chevron_rect, workspace_list_entries,
workspace_list_entries_expanded, workspace_list_rect, workspace_list_scroll_metrics,
workspace_list_scrollbar_rect, workspace_parent_group_state, AgentPanelEntry,
WorkspaceListEntry,
spaces_sort_toggle_rect, workspace_drop_slots, workspace_group_chevron_rect,
workspace_index_order,
workspace_list_entries, workspace_list_entries_expanded, workspace_list_rect,
workspace_list_scroll_metrics, workspace_list_scrollbar_rect, workspace_parent_group_state,
AgentPanelEntry, WorkspaceListEntry,
},
};

Expand Down
Loading
Loading