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
This commit is contained in:
Eric Traut
2026-09-10 20:06:51 +00:00
committed by copyberry
parent f8ab57359d
commit b9852fe6f7
11 changed files with 175 additions and 21 deletions
+30 -6
View File
@@ -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,
@@ -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 {
@@ -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::<TurnStartResponse>(ClientRequest::TurnStart {
+95 -5
View File
@@ -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!(
+4 -3
View File
@@ -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) => {
+2 -1
View File
@@ -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;
}
@@ -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
+2 -1
View File
@@ -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);
}
@@ -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());
@@ -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?
@@ -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