diff --git a/codex-rs/tui/src/app/agent_center/rows.rs b/codex-rs/tui/src/app/agent_center/rows.rs index c06300a8c2..629694e6a9 100644 --- a/codex-rs/tui/src/app/agent_center/rows.rs +++ b/codex-rs/tui/src/app/agent_center/rows.rs @@ -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() { diff --git a/codex-rs/tui/src/app/agent_center_tests.rs b/codex-rs/tui/src/app/agent_center_tests.rs index 7fd5fafc93..e3d96442e9 100644 --- a/codex-rs/tui/src/app/agent_center_tests.rs +++ b/codex-rs/tui/src/app/agent_center_tests.rs @@ -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::>() }; 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] diff --git a/codex-rs/tui/src/app/agents_overview_actions_tests.rs b/codex-rs/tui/src/app/agents_overview_actions_tests.rs index 406e7ea04d..dd48bb15cb 100644 --- a/codex-rs/tui/src/app/agents_overview_actions_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_actions_tests.rs @@ -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 diff --git a/codex-rs/tui/src/app/agents_overview_grouping.rs b/codex-rs/tui/src/app/agents_overview_grouping.rs index bdfe06e2c7..d3a5d9d7f7 100644 --- a/codex-rs/tui/src/app/agents_overview_grouping.rs +++ b/codex-rs/tui/src/app/agents_overview_grouping.rs @@ -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) } diff --git a/codex-rs/tui/src/app/agents_overview_view.rs b/codex-rs/tui/src/app/agents_overview_view.rs index 6d6c6e438d..f28570a2ac 100644 --- a/codex-rs/tui/src/app/agents_overview_view.rs +++ b/codex-rs/tui/src/app/agents_overview_view.rs @@ -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, 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, + rows: Vec, selected_thread_id: Option, worktrees_enabled: bool, use_theme_colors: bool, @@ -225,24 +224,6 @@ impl AgentsOverviewView { keymap: RuntimeKeymap, state: Arc>, ) -> 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) diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__hiding_tasks_keeps_selection_adjacent_in_display_order.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__hiding_tasks_keeps_selection_adjacent_in_display_order.snap index 64285800da..dc6bfe63eb 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__hiding_tasks_keeps_selection_adjacent_in_display_order.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__hiding_tasks_keeps_selection_adjacent_in_display_order.snap @@ -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 diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_keeps_status_order_across_progress_refreshes.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_keeps_status_order_across_progress_refreshes.snap index a0a543c046..a486e1987d 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_keeps_status_order_across_progress_refreshes.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_keeps_status_order_across_progress_refreshes.snap @@ -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 │