Keep agent command center rows stable across progress updates (#52946)

## Why

Progress updates change task timestamps, causing recency-based sorting to move rows within the same status group on refresh.

## What changed

Retain each task's relative order while it remains in the same status group. Append new tasks and tasks that change groups after existing rows in their destination group. Forget the order of removed tasks so rediscovered tasks behave like new arrivals.

Search and status filters preserve the retained order, and pinned tasks continue to use their explicit ordering.

## Testing

Add regression tests and a snapshot covering progress refreshes, selection stability, arrivals, status transitions (including changes caused by descendants), removal and rediscovery, filters, and pins.

GitOrigin-RevId: 54008a66de5c4cf509759e25ee8c1cb6e2a70100
This commit is contained in:
Felipe Coury
2026-10-10 18:04:13 +00:00
committed by copyberry
parent 6c0c6759d0
commit 1badea29ab
5 changed files with 274 additions and 3 deletions
+207
View File
@@ -23,6 +23,213 @@ fn screen(view: &AgentsOverviewView, width: u16, height: u16) -> String {
)
}
#[tokio::test]
async fn live_center_keeps_status_order_across_progress_refreshes() {
let mut app = make_test_app().await;
app.agents_overview.view_state.lock().unwrap().grouping = AgentsOverviewGrouping::Status;
let ids = [1, 2, 3, 4, 5].map(ThreadId::from_u128);
let mut threads = [
(
"Alpha",
ThreadStatus::Active {
active_flags: vec![],
},
10,
),
(
"Bravo",
ThreadStatus::Active {
active_flags: vec![],
},
20,
),
(
"Charlie",
ThreadStatus::Active {
active_flags: vec![],
},
20,
),
("Ready task", ThreadStatus::Idle, 40),
("Needs input", ThreadStatus::SystemError, 0),
]
.into_iter()
.zip(ids)
.map(|((name, status, updated_at), id)| {
let mut thread = overview_thread(id, /*parent_thread_id*/ None, name, status);
// Future timestamps render as "now", keeping the visual fixture clock-independent.
thread.updated_at = 4_000_000_000 + updated_at;
thread
})
.collect::<Vec<_>>();
threads.reverse();
let view = app.agents_overview_view(threads.clone(), Some(ids[0]));
let expected = vec![ids[4], ids[1], ids[2], ids[0], ids[3]];
assert_eq!(view.thread_ids(), expected);
let before = screen(&view, /*width*/ 100, /*height*/ 18);
app.agents_overview.visible_thread_ids = view.thread_ids();
app.chat_widget.show_bottom_pane_view(Box::new(view));
for (step, id) in [ids[0], ids[2], ids[1], ids[0]].into_iter().enumerate() {
let thread = threads
.iter_mut()
.find(|thread| thread.id == id.to_string())
.unwrap();
thread.updated_at = 4_000_000_100 + step as i64;
thread.preview = format!("Progress update {step}");
threads.rotate_left(/*mid*/ 1);
app.agents_overview.threads = threads
.iter()
.cloned()
.map(|thread| (ThreadId::from_string(&thread.id).unwrap(), Some(thread)))
.collect();
app.repaint_agents_overview();
assert_eq!(app.agents_overview.visible_thread_ids, expected);
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]);
}
let view = app.agents_overview_view(threads, Some(ids[0]));
let after = screen(&view, /*width*/ 100, /*height*/ 18);
insta::assert_snapshot!(format!(
"Initial display:\n{before}\n\nAfter progress updates:\n{after}"
));
}
#[tokio::test]
async fn live_center_appends_arrivals_and_status_transitions() {
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 {
active_flags: vec![],
};
let mut threads = vec![
overview_thread(
alpha,
/*parent_thread_id*/ None,
"Alpha",
working.clone(),
),
overview_thread(
bravo,
/*parent_thread_id*/ None,
"Bravo",
working.clone(),
),
overview_thread(
charlie,
/*parent_thread_id*/ None,
"Charlie",
ThreadStatus::Idle,
),
];
assert_eq!(
app.agents_overview_view(threads.clone(), Some(bravo))
.thread_ids(),
vec![alpha, bravo, charlie]
);
let mut arrival = overview_thread(delta, /*parent_thread_id*/ None, "Delta", working);
arrival.updated_at += 100;
threads.push(arrival);
assert_eq!(
app.agents_overview_view(threads.clone(), Some(bravo))
.thread_ids(),
vec![alpha, bravo, delta, charlie]
);
threads[1].status = ThreadStatus::Idle;
assert_eq!(
app.agents_overview_view(threads.clone(), Some(bravo))
.thread_ids(),
vec![alpha, delta, charlie, bravo]
);
// A descendant can change the root's effective group without changing its own status.
threads.push(overview_thread(
child,
Some(bravo),
"Child",
ThreadStatus::SystemError,
));
assert_eq!(
app.agents_overview_view(threads.clone(), Some(bravo))
.thread_ids(),
vec![bravo, alpha, delta, 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.rows[view.selected_index().unwrap()].thread_id, bravo);
// Removed roots lose their rank, so rediscovery behaves like a new arrival.
let removed = threads.remove(/*index*/ 0);
assert_eq!(
app.agents_overview_view(threads.clone(), Some(bravo))
.thread_ids(),
vec![delta, charlie, bravo]
);
threads.push(removed);
assert_eq!(
app.agents_overview_view(threads, Some(bravo)).thread_ids(),
vec![delta, alpha, charlie, bravo]
);
}
#[tokio::test]
async fn live_center_stable_order_preserves_filters_and_pins() {
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);
let mut threads = ["Alpha", "Bravo", "Charlie"]
.into_iter()
.zip(ids)
.map(|(name, id)| {
overview_thread(
id,
/*parent_thread_id*/ None,
name,
ThreadStatus::Active {
active_flags: vec![],
},
)
})
.collect::<Vec<_>>();
let rendered_titles = |view: &AgentsOverviewView| {
screen(view, /*width*/ 100, /*height*/ 18)
.lines()
.filter_map(|line| {
let (_, task_column) = line.split('│').next()?.split_once("● ")?;
["Alpha", "Bravo", "Charlie"]
.into_iter()
.find(|title| task_column.starts_with(title))
})
.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.
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.
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"]);
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]]);
view = app.agents_overview_view(threads.clone(), Some(ids[1]));
assert_eq!(rendered_titles(&view), vec!["Charlie", "Alpha", "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"]);
}
#[tokio::test]
async fn live_center_columns() {
let mut app = make_test_app().await;
@@ -467,7 +467,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) {
(AgentsOverviewGrouping::Status, false) => vec![3, 2, 1, 4],
// The preceding filtered case retains Other 2; rediscovered tasks append.
(AgentsOverviewGrouping::Status, false) => vec![3, 1, 4, 2],
(AgentsOverviewGrouping::Status, true) => vec![3, 1, 4],
(AgentsOverviewGrouping::Project | AgentsOverviewGrouping::Model, false) => {
vec![3, 4, 2, 1]
+21 -1
View File
@@ -1,5 +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.
#[path = "agent_center/mod.rs"]
pub(super) mod command_center;
@@ -141,6 +142,7 @@ impl AgentsOverviewProjectGroup {
#[derive(Default)]
pub(super) struct AgentsOverviewViewState {
row_order: HashMap<ThreadId, (AgentsOverviewGroup, usize)>,
scroll: usize,
page_height: usize,
status_filter: usize,
@@ -197,7 +199,7 @@ pub(super) struct AgentsOverviewView {
impl AgentsOverviewView {
pub(super) fn new(
rows: Vec<AgentsOverviewRow>,
mut rows: Vec<AgentsOverviewRow>,
selected_thread_id: Option<ThreadId>,
worktrees_enabled: bool,
use_theme_colors: bool,
@@ -205,6 +207,24 @@ 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)
@@ -10,9 +10,9 @@ Project, filtered=true: › ○ Task 3
Project, filtered=true: › ○ Task 4
Project, 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
@@ -0,0 +1,43 @@
---
source: tui/src/app/agent_center_tests.rs
expression: "format!(\"Initial display:\\n{before}\\n\\nAfter progress updates:\\n{after}\")"
---
Initial display:
Agent command center Group: Status g
All 5 Needs you 1 Working 3 Ready 1 Inactive 0 tab/⇧tab filter
────────────────────────────────────────────────────────────────────────────────────────────────
Tasks │ Task details
Needs input 1 │
! Needs input │ Alpha
│ ● Working
Working 3 │
● Bravo │ Model: Unknown
● Charlie │ Reasoning: Unknown
› ● Alpha │
│ Project
Ready 1 │ /tmp/project
○ Ready task │
│ Prompt
│ Alpha
│
? help esc back ↑/↓ move enter open n new
After progress updates:
Agent command center Group: Status g
All 5 Needs you 1 Working 3 Ready 1 Inactive 0 tab/⇧tab filter
────────────────────────────────────────────────────────────────────────────────────────────────
Tasks │ Task details
Needs input 1 │
! Needs input │ Alpha
│ ● Working
Working 3 │
● Bravo │ Model: Unknown
● Charlie │ Reasoning: Unknown
› ● Alpha │
│ Project
Ready 1 │ /tmp/project
○ Ready task │
│ Prompt
│ Progress update 3
│
? help esc back ↑/↓ move enter open n new