Keep agents navigation available after a thread is archived (#53201)

## Why

After a thread was archived externally, the TUI still advertised the Left-arrow agents shortcut, but pressing it in an empty composer did not open the agents view.

## What changed

Reuse the agents navigation handler when connected to an unavailable thread. Preserve the existing empty-composer and editor guards, and require a key press so repeated Left-arrow events do not open the view.

## Testing

Add a regression test for external thread archiving that verifies draft editing, the shortcut hint, ignored key repeats, and opening the agents command center, with a snapshot of the resulting view.

GitOrigin-RevId: a3f36b42bafee948ed7f5842fcb425e414d7e024
This commit is contained in:
Eric Traut
2026-10-11 20:45:58 +00:00
committed by copyberry
parent 27065a91cc
commit 9f3d25588b
5 changed files with 139 additions and 3 deletions
@@ -49,6 +49,100 @@ async fn agents_navigation_requires_local_daemon() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn externally_archived_thread_keeps_advertised_agents_navigation() -> Result<()> {
let (mut app, mut events, _ops) = make_test_app_with_channels().await;
let mut tui = crate::tui::test_support::make_test_tui()?;
let mut app_server = start_config_write_test_app_server(&app).await?;
app.app_server_target = AppServerTarget::LocalDaemon {
endpoint: crate::RemoteAppServerEndpoint::UnixSocket {
socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?,
},
allow_embedded_fallback: true,
};
let init = app.chatwidget_init_for_forked_or_resumed_thread(
&mut tui,
app.config.clone(),
/*initial_user_message*/ None,
);
app.replace_chat_widget(ChatWidget::new_with_app_event(init));
let id = ThreadId::new();
app.active_thread_id = Some(id);
app.primary_thread_id = Some(id);
app.chat_widget
.handle_thread_session(test_thread_session(id, app.config.cwd.to_path_buf()));
app.ensure_thread_channel(id);
app.handle_app_server_event(
&app_server,
codex_app_server_client::AppServerEvent::ServerNotification(Box::new(
ServerNotification::ThreadArchived(ThreadArchivedNotification {
thread_id: id.to_string(),
}),
)),
)
.await;
while events.try_recv().is_ok() {}
// Left must still edit an existing draft before it can navigate from an empty composer.
app.chat_widget
.restore_user_message_to_composer("ab".into());
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyCode::Left.into()),
)
.await?;
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyCode::Char('!').into()),
)
.await?;
assert_eq!(app.chat_widget.composer_text_with_pending(), "a!b");
assert!(
!std::iter::from_fn(|| events.try_recv().ok())
.any(|event| matches!(event, AppEvent::OpenAgentsOverview))
);
for key in [
KeyCode::End.into(),
KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
] {
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(key))
.await?;
}
assert!(app.chat_widget.composer_is_empty());
assert!(render_bottom_popup(&app.chat_widget, /*width*/ 96).contains("for agents"));
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyEvent::new_with_kind(
KeyCode::Left,
KeyModifiers::NONE,
KeyEventKind::Repeat,
)),
)
.await?;
assert!(
!std::iter::from_fn(|| events.try_recv().ok())
.any(|event| matches!(event, AppEvent::OpenAgentsOverview))
);
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyCode::Left.into()),
)
.await?;
let event = events.try_recv()?;
assert_matches!(event, AppEvent::OpenAgentsOverview);
app.handle_event(&mut tui, &mut app_server, event).await?;
insta::assert_snapshot!(
"externally_archived_thread_agents",
render_bottom_popup(&app.chat_widget, /*width*/ 96)
);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn pending_windows_sandbox_setup_blocks_thread_replacement() -> Result<()> {
use crate::app_event::WindowsSandboxEnableMode;
@@ -0,0 +1,28 @@
---
source: tui/src/app/tests/agents_navigation_tests.rs
expression: "render_bottom_popup(&app.chat_widget, 96)"
---
Agent command center Group: Project g
All 0 Needs you 0 Working 0 Ready 0 Inactive 0 tab/⇧tab filter
────────────────────────────────────────────────────────────────────────────────────────────
Loading tasks… │
│
│
│
│
│
│
│
│
│
│
│
│
│
│
│
│
│
│
│
? help esc back ↑/↓ move enter open n new
@@ -163,6 +163,7 @@
//! intact until reconnection succeeds, except for the local `/warnings` command.
//! When connected but the thread is unavailable, configured
//! submit keys may dispatch explicitly allowed recovery and local commands; other drafts stay put.
//! Empty-prompt agents navigation remains available with its usual editor and draft guards.
//! Collapsed pastes expand into editable text so the full draft can be copied before quitting.
//!
//! # Reasoning Effort Animations
@@ -3785,9 +3786,7 @@ impl ChatComposer {
return false;
}
if key_hint::plain(KeyCode::Left).is_press(*key_event) && self.agents_navigation_available()
{
self.app_event_tx.send(AppEvent::OpenAgentsOverview);
if self.handle_agents_navigation(*key_event) {
return true;
}
@@ -30,4 +30,15 @@ impl ChatComposer {
&& !self.popup_active()
&& !self.draft.textarea.is_vim_operator_pending()
}
pub(super) fn handle_agents_navigation(&mut self, key: KeyEvent) -> bool {
if key.kind == KeyEventKind::Press
&& key_hint::plain(KeyCode::Left).is_press(key)
&& self.agents_navigation_available()
{
self.app_event_tx.send(AppEvent::OpenAgentsOverview);
return true;
}
false
}
}
@@ -1,5 +1,6 @@
//! Restricted editing retains drafts and allows the local warnings viewer.
//! Unavailable threads also allow recovery and other local commands.
//! Connected unavailable threads retain the empty-prompt agents shortcut.
//! Paste Enter handling is shared with normal submission so buffered newlines survive both paths.
//! Recovery commands must occupy one line and be visible before the submit key expands pastes.
//! Offline draft edits also consume the Astra sparkle opportunity before rendering.
@@ -56,6 +57,9 @@ impl ChatComposer {
key: KeyEvent,
mode: RestrictedInputMode,
) -> InputResult {
if mode == RestrictedInputMode::UnavailableThread && self.handle_agents_navigation(key) {
return InputResult::None;
}
self.cancel_history_search();
self.attachments.clear_remote_image_selection();
self.popups.active = ActivePopup::None;