Return focus to the agents overview composer on Escape (#44360)

## Why

Pressing `Esc` in the agents overview task list dismissed the overview or
exited Codex when no primary thread existed, instead of returning to the draft.

## What changed

Make list cancellation return focus to the composer, including while
reconnecting, preserving the draft and cursor position.

## Testing

Add regression coverage for returning from the list to the composer with Vim
mode enabled or disabled, both online and reconnecting. Verify draft and cursor
preservation, resumed text entry, and the absence of app events.

GitOrigin-RevId: 8920677c42b0c9ff5023979e198f069abcfcf54b
This commit is contained in:
Eric Traut
2026-09-10 01:06:18 +00:00
committed by copyberry
parent d996b4f02a
commit a62e98d18c
4 changed files with 74 additions and 15 deletions
-1
View File
@@ -284,7 +284,6 @@ impl App {
AgentsOverviewView::new(
rows,
selected_thread_id,
self.primary_thread_id.is_none(),
self.config.features.enabled(Feature::Worktrees)
&& !crate::uses_remote_workspace_or_environment(
&self.app_server_target,
+64 -5
View File
@@ -59,6 +59,7 @@ async fn older_server_notice_falls_back_in_short_overview() {
.collect::<String>();
insta::assert_snapshot!(header.trim_end(), @" Old srv");
}
use crate::app::agents_overview_view::AgentsOverviewFocus;
use crate::app::test_support::make_test_app;
use crate::app_event::AgentsOverviewThreadRefresh;
use crate::bottom_pane::BottomPaneView;
@@ -106,6 +107,66 @@ fn overview_draft(app: &App) -> (String, usize) {
(composer.current_text_with_pending(), composer.cursor())
}
#[tokio::test]
async fn overview_escape_returns_from_list_to_composer() {
for (vim, offline) in [(false, false), (true, false), (false, true), (true, 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 (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
app.app_event_tx = AppEventSender::new(tx);
let view = app.agents_overview_view(
vec![overview_thread(
ThreadId::new(),
/*parent_thread_id*/ None,
"Existing task",
ThreadStatus::Idle,
)],
/*selected_thread_id*/ None,
);
app.chat_widget.show_bottom_pane_view(Box::new(view));
app.chat_widget.handle_paste("task draft".into());
app.chat_widget.handle_key_event(KeyCode::Home.into());
let draft = overview_draft(&app);
if offline {
app.agents_overview
.view_state
.lock()
.unwrap()
.connection_notice = Some("Reconnecting");
}
if vim {
app.chat_widget.handle_key_event(KeyCode::Esc.into());
}
app.chat_widget.handle_key_event(KeyCode::Esc.into());
assert!(matches!(
app.agents_overview.view_state.lock().unwrap().focus,
AgentsOverviewFocus::List
));
app.chat_widget.handle_key_event(KeyCode::Down.into());
app.chat_widget.handle_key_event(KeyCode::Esc.into());
assert!(matches!(
app.agents_overview.view_state.lock().unwrap().focus,
AgentsOverviewFocus::Composer
));
assert_eq!(
app.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID),
Some(0)
);
assert_eq!(overview_draft(&app), draft);
assert!(rx.try_recv().is_err());
if !vim && !offline {
insta::assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 80).lines().last().unwrap(), @" enter create task ctrl+j newline esc tasks");
}
app.chat_widget.handle_key_event(KeyCode::Char('!').into());
assert_eq!(overview_draft(&app).0, "!task draft");
}
}
#[tokio::test]
async fn overview_right_opens_current_or_highlighted_task() {
let mut app = make_test_app().await;
@@ -658,7 +719,7 @@ async fn shared_overview_seeds_once_and_retains_locally_resumed_history() -> Res
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
finish_overview_refresh(&mut app, &app_server, &mut event_rx).await;
let retained: HashSet<_> = app.agents_overview.threads.keys().copied().collect();
assert_eq!(retained, expected);
@@ -1164,7 +1225,6 @@ async fn shared_overview_shows_only_root_sessions() {
let mut action_view = AgentsOverviewView::new(
view.rows.clone(),
Some(first_root),
/*exit_on_cancel*/ false,
/*worktrees_enabled*/ false,
crate::app_event_sender::AppEventSender::new(event_tx),
app.keymap.clone(),
@@ -1282,7 +1342,7 @@ async fn shared_overview_shows_only_root_sessions() {
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let view = app.agents_overview_view(threads, /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 96);
@@ -1304,7 +1364,7 @@ async fn shared_overview_shows_only_root_sessions() {
.expect("render full-screen dashboard");
assert_eq!(tui.terminal.viewport_area.height, screen_size.height);
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
app.render_chat_widget_frame(&mut tui, screen_size)
.expect("restore conversation after closing dashboard");
assert!(tui.terminal.viewport_area.height < screen_size.height);
@@ -1406,7 +1466,6 @@ async fn filtered_dashboard_actions_use_configured_shortcuts() {
)
.rows,
Some(first),
/*exit_on_cancel*/ false,
/*worktrees_enabled*/ false,
crate::app_event_sender::AppEventSender::new(event_tx),
app.keymap.clone(),
+4 -9
View File
@@ -180,7 +180,6 @@ pub(super) struct AgentsOverviewView {
project_groups: Vec<AgentsOverviewProjectGroup>,
selected: usize,
state: Arc<Mutex<AgentsOverviewViewState>>,
exit_on_cancel: bool,
app_event_tx: AppEventSender,
keymap: ListKeymap,
agents_keymap: AgentsKeymap,
@@ -192,7 +191,6 @@ impl AgentsOverviewView {
pub(super) fn new(
rows: Vec<AgentsOverviewRow>,
selected_thread_id: Option<ThreadId>,
exit_on_cancel: bool,
worktrees_enabled: bool,
app_event_tx: AppEventSender,
keymap: RuntimeKeymap,
@@ -223,7 +221,6 @@ impl AgentsOverviewView {
project_groups,
selected,
state,
exit_on_cancel,
app_event_tx,
keymap: keymap.list,
agents_keymap: keymap.agents,
@@ -650,7 +647,9 @@ impl BottomPaneView for AgentsOverviewView {
return;
}
if self.state().connection_notice.is_some() && !self.agents_keymap.new_task.is_pressed(key)
if self.state().connection_notice.is_some()
&& !self.agents_keymap.new_task.is_pressed(key)
&& self.keymap.action_for(key) != Some(ListAction::Cancel)
{
match self.keymap.action_for(key) {
Some(ListAction::MoveUp) => self.move_selection(/*forward*/ false),
@@ -727,11 +726,7 @@ impl BottomPaneView for AgentsOverviewView {
state.input.clear();
state.renaming = false;
} else {
if self.exit_on_cancel {
self.app_event_tx
.send(AppEvent::Exit(crate::app::ExitMode::Immediate));
}
state.completion = Some(ViewCompletion::Cancelled);
state.focus_composer();
}
}
ListAction::PageUp | ListAction::PageDown => {
@@ -415,6 +415,12 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
)
.await?;
assert!(app.chat_widget.has_active_view());
// Esc returns to the overview composer. Dismiss the retained view explicitly
// to inspect the unavailable conversation and its cached draft below.
app.agents_overview.view_state.lock().unwrap().completion =
Some(crate::bottom_pane::ViewCompletion::Accepted);
app.chat_widget.handle_key_event(KeyCode::Null.into());
assert!(!app.chat_widget.has_active_view());
assert_eq!(app.current_displayed_thread_id(), Some(id));
let history = drain_history(&mut app, &mut tui, &mut session, &mut events).await?;