diff --git a/codex-rs/tui/src/app/agent_center_tests.rs b/codex-rs/tui/src/app/agent_center_tests.rs index 409523e358..e8c3022b84 100644 --- a/codex-rs/tui/src/app/agent_center_tests.rs +++ b/codex-rs/tui/src/app/agent_center_tests.rs @@ -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::>(); + 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::>(); + 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::>() + }; + 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; 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 5098d0c28b..c741db3658 100644 --- a/codex-rs/tui/src/app/agents_overview_actions_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_actions_tests.rs @@ -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] diff --git a/codex-rs/tui/src/app/agents_overview_view.rs b/codex-rs/tui/src/app/agents_overview_view.rs index 094bd63c11..4beec41237 100644 --- a/codex-rs/tui/src/app/agents_overview_view.rs +++ b/codex-rs/tui/src/app/agents_overview_view.rs @@ -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, scroll: usize, page_height: usize, status_filter: usize, @@ -197,7 +199,7 @@ pub(super) struct AgentsOverviewView { impl AgentsOverviewView { pub(super) fn new( - rows: Vec, + mut rows: Vec, selected_thread_id: Option, worktrees_enabled: bool, use_theme_colors: bool, @@ -205,6 +207,24 @@ 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 6104fda025..e6bbb767e9 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 @@ -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 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 new file mode 100644 index 0000000000..eb6f4c954e --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_keeps_status_order_across_progress_refreshes.snap @@ -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