mirror of
https://github.com/openai/codex.git
synced 2026-10-20 08:19:50 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+29
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user