Sort agent center tasks by recency within status groups (#58714)

## What changed

Keep unpinned tasks ordered by descending update time within each status group as tasks arrive, change status, or refresh. Clearing a search or status filter also reflects timestamp changes to hidden tasks.

Keep pinned tasks at the top of their respective status groups in pin order, and mark their titles with 📌 instead of placing them in a separate Pinned group when grouping by status.

## Testing

Update ordering tests and snapshots to cover arrivals, status transitions, rediscovered tasks, filters, pins, and adjacent selection after hiding tasks.

GitOrigin-RevId: 2f3320a17b1932294f421dd28af848f0e0be45ef
This commit is contained in:
Eric Traut
2026-11-01 15:39:27 +00:00
committed by copyberry
parent 456ae93b63
commit f7f1f0196c
7 changed files with 56 additions and 67 deletions
+18 -17
View File
@@ -140,23 +140,18 @@ impl AgentsOverviewView {
let task = &self.rows[index];
let rect = row(viewport, offset as u16, /*height*/ 1);
if matches!(entry, CenterRow::Group(_)) {
let group = if self.is_pinned(index) {
"Pinned".to_owned()
} else {
match state.grouping {
AgentsOverviewGrouping::Project => {
format_directory_display(
&self.project_groups[index].heading,
/*max_width*/ None,
)
}
AgentsOverviewGrouping::Directory => format_directory_display(
self.rows[index].thread.cwd.as_path(),
/*max_width*/ None,
),
AgentsOverviewGrouping::Status => task.group.label().to_owned(),
AgentsOverviewGrouping::Model => model_name(&task.thread).to_owned(),
}
let group = match state.grouping {
AgentsOverviewGrouping::Status => task.group.label().to_owned(),
_ if self.is_pinned(index) => "Pinned".to_owned(),
AgentsOverviewGrouping::Project => format_directory_display(
&self.project_groups[index].heading,
/*max_width*/ None,
),
AgentsOverviewGrouping::Directory => format_directory_display(
self.rows[index].thread.cwd.as_path(),
/*max_width*/ None,
),
AgentsOverviewGrouping::Model => model_name(&task.thread).to_owned(),
};
let count = indices
.iter()
@@ -249,6 +244,12 @@ impl AgentsOverviewView {
} else {
task_title.to_owned()
};
let task_title =
if state.grouping == AgentsOverviewGrouping::Status && self.is_pinned(index) {
format!("📌 {task_title}")
} else {
task_title
};
line(Line::from(task_title).style(title_style), title, buf);
line(Line::from(status).style(style), status_area, buf);
if !updated.is_empty() {
+19 -17
View File
@@ -84,13 +84,14 @@ async fn live_center_keeps_status_order_across_progress_refreshes() {
.map(|thread| (ThreadId::from_string(&thread.id).unwrap(), Some(thread)))
.collect();
app.repaint_agents_overview();
assert_eq!(app.agents_overview.visible_thread_ids, expected);
assert_eq!(app.agents_overview.visible_thread_ids[1], id);
let selected = app
.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
.unwrap();
assert_eq!(app.agents_overview.visible_thread_ids[selected], ids[0]);
}
app.agents_overview.pinned_thread_ids = Some(vec![ids[3], ids[2]]);
for parent in [ids[0], ids[3]] {
threads.push(overview_thread(
ThreadId::new(),
@@ -143,7 +144,7 @@ async fn failed_background_worker_only_needs_attention_for_a_pending_request() {
}
#[tokio::test]
async fn live_center_appends_arrivals_and_status_transitions() {
async fn live_center_sorts_arrivals_and_status_transitions_by_update_time() {
let app = make_test_app().await;
let [alpha, bravo, charlie, delta, child] = [1, 2, 3, 4, 5].map(ThreadId::from_u128);
let working = ThreadStatus::Active {
@@ -188,11 +189,11 @@ async fn live_center_appends_arrivals_and_status_transitions() {
assert_eq!(
app.agents_overview_view(threads.clone(), Some(bravo))
.thread_ids(),
vec![alpha, bravo, delta, charlie]
vec![delta, alpha, bravo, charlie]
);
threads[1].status = ThreadStatus::Idle;
let view = app.agents_overview_view(threads.clone(), Some(bravo));
assert_eq!(view.thread_ids(), vec![alpha, delta, charlie, bravo]);
assert_eq!(view.thread_ids(), vec![delta, alpha, bravo, charlie]);
let selected = &view.rows[view.selected_index().unwrap()];
assert_eq!(selected.group, AgentsOverviewGroup::Ready);
assert!(selected.details.lines.is_empty());
@@ -203,28 +204,28 @@ async fn live_center_appends_arrivals_and_status_transitions() {
assert_eq!(
app.agents_overview_view(threads.clone(), Some(bravo))
.thread_ids(),
vec![bravo, alpha, delta, charlie]
vec![bravo, delta, alpha, charlie]
);
threads.pop();
let view = app.agents_overview_view(threads.clone(), Some(bravo));
assert_eq!(view.thread_ids(), vec![alpha, delta, charlie, bravo]);
assert_eq!(view.thread_ids(), vec![delta, alpha, bravo, charlie]);
assert_eq!(view.rows[view.selected_index().unwrap()].thread_id, bravo);
// Removed roots lose their rank, so rediscovery behaves like a new arrival.
// Rediscovery restores the task to its position by update time.
let removed = threads.remove(/*index*/ 0);
assert_eq!(
app.agents_overview_view(threads.clone(), Some(bravo))
.thread_ids(),
vec![delta, charlie, bravo]
vec![delta, bravo, charlie]
);
threads.push(removed);
assert_eq!(
app.agents_overview_view(threads, Some(bravo)).thread_ids(),
vec![delta, alpha, charlie, bravo]
vec![delta, alpha, bravo, charlie]
);
}
#[tokio::test]
async fn live_center_stable_order_preserves_filters_and_pins() {
async fn live_center_status_order_tracks_updates_while_filtered_and_pinned() {
let mut app = make_test_app().await;
app.agents_overview.view_state.lock().unwrap().grouping = AgentsOverviewGrouping::Status;
let ids = [1, 2, 3].map(ThreadId::from_u128);
@@ -247,6 +248,7 @@ async fn live_center_stable_order_preserves_filters_and_pins() {
.lines()
.filter_map(|line| {
let (_, task_column) = line.split('│').next()?.split_once("● ")?;
let task_column = task_column.trim_start_matches('📌').trim_start();
["Alpha", "Bravo", "Charlie"]
.into_iter()
.find(|title| task_column.starts_with(title))
@@ -254,29 +256,29 @@ async fn live_center_stable_order_preserves_filters_and_pins() {
.collect::<Vec<_>>()
};
let mut view = app.agents_overview_view(threads.clone(), Some(ids[1]));
// Searching only changes visibility, not the retained order of hidden rows.
// Hidden rows also pick up timestamp changes when the filter is cleared.
view.handle_key_event(KeyCode::Char('/').into());
view.handle_paste("Bravo".into());
threads[2].updated_at += 100;
view = app.agents_overview_view(threads.clone(), Some(ids[1]));
assert_eq!(rendered_titles(&view), vec!["Bravo"]);
view.handle_key_event(KeyCode::Esc.into());
assert_eq!(view.thread_ids(), ids.to_vec());
// A refresh while another status tab is empty must not discard the Working order.
assert_eq!(rendered_titles(&view), vec!["Charlie", "Alpha", "Bravo"]);
// Update another working task while the empty Needs input tab is selected.
view.handle_key_event(KeyCode::Tab.into());
assert!(rendered_titles(&view).is_empty());
threads[1].updated_at += 200;
view = app.agents_overview_view(threads.clone(), /*selected_thread_id*/ None);
view.handle_key_event(KeyCode::Tab.into());
assert_eq!(rendered_titles(&view), vec!["Alpha", "Bravo", "Charlie"]);
assert_eq!(rendered_titles(&view), vec!["Bravo", "Charlie", "Alpha"]);
view.handle_key_event(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT));
view.handle_key_event(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT));
app.agents_overview.pinned_thread_ids = Some(vec![ids[2], ids[0]]);
app.agents_overview.pinned_thread_ids = Some(vec![ids[0], ids[2]]);
view = app.agents_overview_view(threads.clone(), Some(ids[1]));
assert_eq!(rendered_titles(&view), vec!["Charlie", "Alpha", "Bravo"]);
assert_eq!(rendered_titles(&view), vec!["Alpha", "Charlie", "Bravo"]);
app.agents_overview.pinned_thread_ids = Some(vec![]);
view = app.agents_overview_view(threads, Some(ids[1]));
assert_eq!(rendered_titles(&view), vec!["Alpha", "Bravo", "Charlie"]);
assert_eq!(rendered_titles(&view), vec!["Bravo", "Charlie", "Alpha"]);
}
#[tokio::test]
@@ -481,8 +481,8 @@ async fn hiding_tasks_keeps_selection_adjacent_in_display_order() -> Result<()>
app.agents_overview.visible_thread_ids = view.thread_ids();
app.chat_widget.show_bottom_pane_view(Box::new(view));
let expected = match (grouping, filtered) {
// The preceding filtered case retains Other 2; rediscovered tasks append.
(AgentsOverviewGrouping::Status, false) => vec![3, 1, 4, 2],
// Status displays tasks newest first: 4, 3, 2, 1.
(AgentsOverviewGrouping::Status, false) => vec![3, 2, 1, 4],
(AgentsOverviewGrouping::Status, true) => vec![3, 1, 4],
(
AgentsOverviewGrouping::Project
@@ -91,7 +91,12 @@ impl AgentsOverviewView {
std::cmp::Reverse(self.rows[*index].thread.updated_at),
)
}),
AgentsOverviewGrouping::Status => {}
AgentsOverviewGrouping::Status => {
pinned.extend(visible);
// Stable grouping preserves pin order, then the supplied newest-first order.
pinned.sort_by_key(|index| self.rows[*index].group);
return pinned;
}
AgentsOverviewGrouping::Model => visible.sort_by_key(|index| {
(
model_name(&self.rows[*index].thread),
@@ -109,17 +114,17 @@ impl AgentsOverviewView {
left: usize,
right: usize,
) -> bool {
if self.is_pinned(left) || self.is_pinned(right) {
return self.is_pinned(left) && self.is_pinned(right);
}
match grouping {
AgentsOverviewGrouping::Status => self.rows[left].group == self.rows[right].group,
_ if self.is_pinned(left) || self.is_pinned(right) => {
self.is_pinned(left) && self.is_pinned(right)
}
AgentsOverviewGrouping::Project => {
self.project_groups[left].key == self.project_groups[right].key
}
AgentsOverviewGrouping::Directory => {
self.directory_group_keys[left] == self.directory_group_keys[right]
}
AgentsOverviewGrouping::Status => self.rows[left].group == self.rows[right].group,
AgentsOverviewGrouping::Model => {
model_name(&self.rows[left].thread) == model_name(&self.rows[right].thread)
}
+2 -21
View File
@@ -1,6 +1,6 @@
//! Dashboard for inspecting and managing the TUI's retained daemon tasks.
//! Search, rename, status filters and selection survive metadata refreshes.
//! Rows retain their relative order while they remain in the same status group.
//! The caller supplies rows in status, descending update time, and thread ID order.
#[path = "agent_center/mod.rs"]
pub(super) mod command_center;
@@ -157,7 +157,6 @@ pub(super) fn directory_group_key(cwd: &Path, worktrees_enabled: bool) -> PathBu
#[derive(Default)]
pub(super) struct AgentsOverviewViewState {
row_order: HashMap<ThreadId, (AgentsOverviewGroup, usize)>,
scroll: usize,
page_height: usize,
status_filter: usize,
@@ -217,7 +216,7 @@ pub(super) struct AgentsOverviewView {
impl AgentsOverviewView {
pub(super) fn new(
mut rows: Vec<AgentsOverviewRow>,
rows: Vec<AgentsOverviewRow>,
selected_thread_id: Option<ThreadId>,
worktrees_enabled: bool,
use_theme_colors: bool,
@@ -225,24 +224,6 @@ impl AgentsOverviewView {
keymap: RuntimeKeymap,
state: Arc<Mutex<AgentsOverviewViewState>>,
) -> Self {
{
let mut state = state.lock().unwrap_or_else(PoisonError::into_inner);
// The caller supplies recency order. Keep existing rows in place, and append
// new arrivals or status transitions in that order within their new group.
rows.sort_by_key(|row| {
let rank = state
.row_order
.get(&row.thread_id)
.filter(|(group, _)| *group == row.group)
.map_or(usize::MAX, |(_, rank)| *rank);
(row.group, rank)
});
state.row_order = rows
.iter()
.enumerate()
.map(|(rank, row)| (row.thread_id, (row.group, rank)))
.collect();
}
let selected = state
.lock()
.unwrap_or_else(PoisonError::into_inner)
@@ -17,9 +17,9 @@ Directory, filtered=true: › ○ Task 3
Directory, filtered=true: › ○ Task 4
Directory, filtered=true: › ○ Task 1
Status, filtered=false: › ○ Task 3
Status, filtered=false: › ○ Other 2
Status, filtered=false: › ○ Task 1
Status, filtered=false: › ○ Task 4
Status, filtered=false: › ○ Other 2
Status, filtered=true: › ○ Task 3
Status, filtered=true: › ○ Task 1
Status, filtered=true: › ○ Task 4
@@ -31,12 +31,12 @@ After subagents fail under Alpha and Ready task:
! Needs input │ Alpha
│ ● Working
Working 3 │
● Bravo │ Model: Unknown
● Charlie │ Reasoning: Unknown
› ● Alpha │
● 📌 Charlie │ Model: Unknown
› ● Alpha │ Reasoning: Unknown
● Bravo │
│ Project
Ready 1 │ /tmp/project
○ Ready task │
○ 📌 Ready task │
│ Prompt
│ Progress update 3
│