From b9852fe6f7c73c98277da38cccb238083c843d4e Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 10 Sep 2026 19:55:15 +0000 Subject: [PATCH] Focus the task list when reopening the agent command center (#44631) ## Why Reopening the agent command center focused the composer, requiring an extra focus change before browsing tasks. ## What changed - Focus the task list when opening the overview from a session or returning after task lifecycle actions. Keep composer focus when starting directly in the overview. - Fall back to the composer when no visible tasks remain, after discovery and pending metadata have resolved. - Preserve unsent drafts and explicit focus changes across populated refreshes. ## Testing Add regression coverage for requested focus, draft and cursor preservation, arrow-key and Vim navigation, and empty-list fallback while metadata is pending. Add a snapshot of the reopened overview with list focus. GitOrigin-RevId: 8713eadc4a2a72e2db4a962184d43c4028c7178d --- codex-rs/tui/src/app/agents_overview.rs | 36 +++++-- .../tui/src/app/agents_overview_actions.rs | 3 +- .../src/app/agents_overview_actions_tests.rs | 5 +- codex-rs/tui/src/app/agents_overview_tests.rs | 100 +++++++++++++++++- codex-rs/tui/src/app/event_dispatch.rs | 7 +- codex-rs/tui/src/app/input.rs | 3 +- ...ts__overview_reopened_with_list_focus.snap | 29 +++++ codex-rs/tui/src/app/startup.rs | 3 +- .../src/app/tests/agents_navigation_tests.rs | 4 + .../app/tests/session_lifecycle_requests.rs | 5 +- ...__agents_command_center_after_archive.snap | 1 + 11 files changed, 175 insertions(+), 21 deletions(-) create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_reopened_with_list_focus.snap diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index f12800ba19..3e9a42d245 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -3,6 +3,7 @@ #[path = "agents_overview_composer.rs"] mod composer; +use super::agents_overview_view::AgentsOverviewFocus; use super::agents_overview_view::AgentsOverviewGroup; use super::agents_overview_view::AgentsOverviewRow; use super::agents_overview_view::AgentsOverviewView; @@ -59,7 +60,11 @@ impl Drop for AgentsOverviewState { } impl App { - pub(super) fn open_agents_overview(&mut self, app_server: &AppServerSession) { + pub(super) fn open_agents_overview( + &mut self, + app_server: &AppServerSession, + focus: AgentsOverviewFocus, + ) { if matches!(self.app_server_target, AppServerTarget::Embedded) { let workload_identity_selected = codex_login::is_workload_identity_selected(); self.chat_widget.show_selection_view(SelectionViewParams { @@ -109,11 +114,19 @@ impl App { return; } - self.agents_overview - .view_state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .focus_composer(); + { + let mut state = self + .agents_overview + .view_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // Launch into composition; returning from a task starts in browsing mode. + // Populated refreshes preserve any subsequent, explicit focus change. + match focus { + AgentsOverviewFocus::Composer => state.focus_composer(), + AgentsOverviewFocus::List => state.focus = AgentsOverviewFocus::List, + } + } let threads = self .agents_overview .threads @@ -285,6 +298,17 @@ impl App { } self.sync_agents_overview_composer(); + // Wait for discovery and retained metadata before treating the list as empty. + if self.agents_overview.initialized + && rows.is_empty() + && self.agents_overview.threads.iter().all(|(id, thread)| { + thread.is_some() || self.agents_overview.hidden_threads.contains(id) + }) + && let Ok(mut state) = self.agents_overview.view_state.lock() + && state.focus == AgentsOverviewFocus::List + { + state.focus_composer(); + } AgentsOverviewView::new( rows, diff --git a/codex-rs/tui/src/app/agents_overview_actions.rs b/codex-rs/tui/src/app/agents_overview_actions.rs index 8ecc6a51db..5b6fa74a27 100644 --- a/codex-rs/tui/src/app/agents_overview_actions.rs +++ b/codex-rs/tui/src/app/agents_overview_actions.rs @@ -3,6 +3,7 @@ //! Removing the current root leaves an unattached dashboard, even when it is empty. use super::App; +use super::agents_overview_view::AgentsOverviewFocus; use crate::app_event::AgentsOverviewAction; use crate::app_event::AppEvent; use crate::app_server_session::AppServerSession; @@ -279,7 +280,7 @@ impl App { /*initial_user_message*/ None, ); self.replace_chat_widget(ChatWidget::new_with_app_event(init)); - self.open_agents_overview(app_server); + self.open_agents_overview(app_server, AgentsOverviewFocus::List); } else { self.repaint_agents_overview(); if attempted { 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 c6dd7435cf..344cf51a2c 100644 --- a/codex-rs/tui/src/app/agents_overview_actions_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_actions_tests.rs @@ -315,7 +315,7 @@ async fn lifecycle_removes_background_and_current_tasks_without_losing_the_dashb }; let mut tui = crate::tui::test_support::make_test_tui()?; tui.pause_events(); - app.open_agents_overview(&app_server); + app.open_agents_overview(&app_server, AgentsOverviewFocus::List); if action == AgentsOverviewAction::Archive { let rollout = app_server .thread_read(id, /*include_turns*/ false) @@ -360,7 +360,7 @@ async fn lifecycle_removes_background_and_current_tasks_without_losing_the_dashb .await?; app.enqueue_primary_thread_session(resumed.session, resumed.turns) .await?; - app.open_agents_overview(&app_server); + app.open_agents_overview(&app_server, AgentsOverviewFocus::List); } let background = ThreadId::from_string( &app_test_support::create_fake_rollout( @@ -434,7 +434,6 @@ async fn lifecycle_removes_background_and_current_tasks_without_losing_the_dashb render_bottom_popup(&app.chat_widget, /*width*/ 80) ); app.chat_widget.handle_key_event(KeyCode::Enter.into()); - app.chat_widget.handle_key_event(KeyCode::Esc.into()); app_server .request_handle() .request_typed::(ClientRequest::TurnStart { diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index f018402b26..b33f4b8a47 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -257,6 +257,98 @@ async fn overview_right_preserves_editors_and_offline_state() { assert!(rx.try_recv().is_err()); } +#[tokio::test] +async fn overview_open_preserves_draft_and_uses_requested_focus() -> Result<()> { + for vim in [false, true] { + let mut app = make_test_app().await; + app.config.disable_paste_burst = true; + if vim { + app.chat_widget.toggle_vim_mode_and_notify(); + } + let server = crate::start_embedded_app_server_for_picker(&app.config).await?; + app.app_server_target = AppServerTarget::LocalDaemon { + endpoint: crate::RemoteAppServerEndpoint::UnixSocket { + socket_path: test_path_buf("/tmp/unused.sock").abs(), + }, + }; + for (id, title) in [(1, "First task"), (2, "Second task")] { + let id = ThreadId::from_u128(id); + app.agents_overview.threads.insert( + id, + Some(overview_thread( + id, + /*parent_thread_id*/ None, + title, + ThreadStatus::Idle, + )), + ); + } + app.open_agents_overview(&server, AgentsOverviewFocus::Composer); + assert!(matches!( + app.agents_overview.view_state.lock().unwrap().focus, + AgentsOverviewFocus::Composer + )); + app.chat_widget.handle_paste("Unsent draft".into()); + let draft = overview_draft(&app); + + app.open_agents_overview(&server, AgentsOverviewFocus::List); + app.repaint_agents_overview(); + let down = if vim { + KeyCode::Char('j') + } else { + KeyCode::Down + }; + app.chat_widget.handle_key_event(down.into()); + assert_eq!( + app.chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID), + Some(1) + ); + assert_eq!(overview_draft(&app), draft); + if !vim { + let project = test_path_display("/tmp/project"); + let group = format!( + "/tmp/project 2{}", + " ".repeat(project.len().saturating_sub("/tmp/project".len())) + ); + insta::assert_snapshot!( + "overview_reopened_with_list_focus", + render_bottom_popup(&app.chat_widget, /*width*/ 96) + .replace(&format!("{project} 2"), &group) + .replace(&project, "/tmp/project") + .replace("fwd del", "del") + ); + } + + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)); + app.repaint_agents_overview(); + app.chat_widget.handle_key_event(KeyCode::Left.into()); + assert_eq!(overview_draft(&app), (draft.0, draft.1 - 1)); + + app.agents_overview.threads.clear(); + app.open_agents_overview(&server, AgentsOverviewFocus::List); + assert!(matches!( + app.agents_overview.view_state.lock().unwrap().focus, + AgentsOverviewFocus::List + )); + app.agents_overview.initialized = true; + let pending = ThreadId::new(); + app.agents_overview.threads.insert(pending, None); + app.repaint_agents_overview(); + assert!(matches!( + app.agents_overview.view_state.lock().unwrap().focus, + AgentsOverviewFocus::List + )); + app.agents_overview.threads.clear(); + app.repaint_agents_overview(); + app.chat_widget.handle_key_event(KeyCode::Char('x').into()); + assert_eq!(overview_draft(&app), ("Unsent drafxt".into(), 12)); + server.shutdown().await?; + } + Ok(()) +} + #[tokio::test] async fn overview_composer_preserves_editing_and_routes_focus() { let mut app = make_test_app().await; @@ -708,7 +800,7 @@ async fn shared_overview_seeds_once_and_retains_locally_resumed_history() -> Res let created = app_server.start_thread(&config).await?.session.thread_id; // Closing the view must not cancel a metadata refresh or forget unloaded entries. app.primary_thread_id = Some(ids[0]); - app.open_agents_overview(&app_server); + app.open_agents_overview(&app_server, AgentsOverviewFocus::List); let visible: HashSet<_> = app .agents_overview .visible_thread_ids @@ -716,8 +808,6 @@ async fn shared_overview_seeds_once_and_retains_locally_resumed_history() -> Res .copied() .collect(); assert_eq!(visible, expected); - app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); finish_overview_refresh(&mut app, &app_server, &mut event_rx).await; @@ -749,7 +839,7 @@ async fn shared_overview_seeds_once_and_retains_locally_resumed_history() -> Res .await?; app.enqueue_primary_thread_session(resumed.session, resumed.turns) .await?; - app.open_agents_overview(&app_server); + app.open_agents_overview(&app_server, AgentsOverviewFocus::List); finish_overview_refresh(&mut app, &app_server, &mut event_rx).await; expected.insert(ids[1]); let visible: HashSet<_> = app @@ -1379,7 +1469,7 @@ async fn embedded_sessions_offer_to_start_a_background_server_without_migrating( .await .expect("embedded app server"); - app.open_agents_overview(&app_server); + app.open_agents_overview(&app_server, AgentsOverviewFocus::List); insta::with_settings!({snapshot_path => "../snapshots"}, { insta::assert_snapshot!( diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 3643390f30..c6a3cec367 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -3,6 +3,7 @@ //! This module contains the exhaustive `AppEvent` dispatcher and exit-mode handling. Large domain //! actions are delegated to focused app submodules so the central match remains the routing layer. +use super::agents_overview_view::AgentsOverviewFocus; use super::rate_limit_refresh::RateLimitReadStatus; use super::rate_limit_refresh::RateLimitRefreshOutcome; use super::resize_reflow::trailing_run_start; @@ -2710,7 +2711,7 @@ impl App { } } AppEvent::OpenAgentsOverview => { - self.open_agents_overview(app_server); + self.open_agents_overview(app_server, AgentsOverviewFocus::List); } AppEvent::AgentsOverviewThreadsLoaded { request_id, result } => { self.apply_agents_overview_thread_refresh(app_server, request_id, result); @@ -2721,7 +2722,7 @@ impl App { .await? { AppRunControl::Continue if self.primary_thread_id.is_none() => { - self.open_agents_overview(app_server); + self.open_agents_overview(app_server, AgentsOverviewFocus::List); } AppRunControl::Continue => {} AppRunControl::Exit(reason) => return Ok(AppRunControl::Exit(reason)), @@ -3511,7 +3512,7 @@ impl App { /*initial_user_message*/ None, ); self.replace_chat_widget(ChatWidget::new_with_app_event(init)); - self.open_agents_overview(app_server); + self.open_agents_overview(app_server, AgentsOverviewFocus::List); AppRunControl::Continue } Err(err) => { diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index a599bc6186..b5350192e1 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -3,6 +3,7 @@ //! This module owns global key bindings that sit above ChatWidget, including transcript overlay //! entry, Ctrl-L clear, external editor launch, and agent navigation shortcuts. +use super::agents_overview_view::AgentsOverviewFocus; use super::*; use crate::app_backtrack::SIDE_EDIT_PREVIOUS_UNAVAILABLE_MESSAGE; use crate::keymap::bindings_for_action; @@ -525,7 +526,7 @@ impl App { } if self.keymap.app.open_agents.is_pressed(key_event) { - self.open_agents_overview(app_server); + self.open_agents_overview(app_server, AgentsOverviewFocus::List); return true; } diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_reopened_with_list_focus.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_reopened_with_list_focus.snap new file mode 100644 index 0000000000..ab2e1eae4b --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_reopened_with_list_focus.snap @@ -0,0 +1,29 @@ +--- +source: tui/src/app/agents_overview_tests.rs +assertion_line: 314 +expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{project} 2\"),\n&group).replace(&project, \"/tmp/project\").replace(\"fwd del\", \"del\")" +--- + Agent command center + 0 need input 0 working 2 ready + ──────────────────────────────────────────────────────────────────────────────────────────── + /tmp/project 2 │ Task details + ○ First task Ready │ + › ○ Second task Ready │ Second task + │ ○ Ready + │ + │ Project + │ /tmp/project + │ + │ Prompt + │ Second task + │ + │ + │ + │ + │ + New task + +› Unsent draft + + ↑↓ navigate ctrl+o resume → open ctrl+n new task ctrl+f search ctrl+s group ctrl+r rename + ctrl+x stop ctrl+w hide ctrl+e archive del delete esc back diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 074b11433a..02356f9181 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -3,6 +3,7 @@ //! Owns the main app run loop from app-server bootstrap through terminal shutdown. Startup input //! remains isolated from protected interactive requests until the initialized composer owns it. +use super::agents_overview_view::AgentsOverviewFocus; use super::reconnect::ReconnectState; use super::*; use crate::session_start::SessionStartAction; @@ -785,7 +786,7 @@ See the Codex keymap documentation for supported actions and examples." ); } if start_in_agents_overview { - app.open_agents_overview(&app_server); + app.open_agents_overview(&app_server, AgentsOverviewFocus::Composer); } else if !matches!(app.app_server_target, AppServerTarget::Embedded) { app.refresh_agents_overview_threads(&app_server); } diff --git a/codex-rs/tui/src/app/tests/agents_navigation_tests.rs b/codex-rs/tui/src/app/tests/agents_navigation_tests.rs index 2b82a80a91..33b8638645 100644 --- a/codex-rs/tui/src/app/tests/agents_navigation_tests.rs +++ b/codex-rs/tui/src/app/tests/agents_navigation_tests.rs @@ -35,6 +35,10 @@ async fn agents_navigation_requires_local_daemon() -> Result<()> { assert_matches!(event, AppEvent::OpenAgentsOverview); app.handle_event(&mut tui, &mut app_server, event).await?; assert!(!app.chat_widget.no_modal_or_popup_active()); + assert!(matches!( + app.agents_overview.view_state.lock().unwrap().focus, + crate::app::agents_overview_view::AgentsOverviewFocus::List + )); } else { assert!(events.try_recv().is_err()); assert!(app.chat_widget.no_modal_or_popup_active()); diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index 6bf901e229..4698065e7f 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -2781,7 +2781,10 @@ async fn agents_overview_seeds_loaded_threads_when_recent_listing_is_unavailable if attempt == 0 { app.refresh_agents_overview_threads(&app_server); } else { - app.open_agents_overview(&app_server); + app.open_agents_overview( + &app_server, + crate::app::agents_overview_view::AgentsOverviewFocus::List, + ); } let Some(AppEvent::AgentsOverviewThreadsLoaded { request_id, result }) = tokio::time::timeout(Duration::from_secs(10), rx.recv()).await? diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__agents_command_center_after_archive.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__agents_command_center_after_archive.snap index b3375278fa..030cc194aa 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__agents_command_center_after_archive.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__agents_command_center_after_archive.snap @@ -1,5 +1,6 @@ --- source: tui/src/app/tests/session_lifecycle_requests.rs +assertion_line: 906 expression: "render_bottom_popup(&app.chat_widget, 100)" --- Agent command center