mirror of
https://github.com/openai/codex.git
synced 2026-11-01 19:33:35 +00:00
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:
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+4
-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
|
||||
│
|
||||
|
||||
Reference in New Issue
Block a user