From db2e09106c5ec4a94a4d47c8352cc53bf4681dfd Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 10 Sep 2026 21:28:37 +0000 Subject: [PATCH] Keep command center errors visible and preserve drafts (#44651) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Command center failures need to remain visible while users retain their task selection and composer draft, including when a task is already managed by another app server. ## What changed - Show action errors above the retained dashboard with a return option and a clear explanation for tasks open elsewhere. - Keep the dashboard open until task attachment succeeds, and return to the conversation when selecting the already displayed task. - Offer “View unsent task” in a pager when restoring a failed submission would overwrite newer input. - Show task refresh failures inline and clear the notice after a successful refresh. ## Testing Add regression tests and snapshots for attachment conflicts, preserved selection and drafts, successful attachment, inline refresh recovery, unsent task viewing, and visible action failures. GitOrigin-RevId: 19670c5ea506414a835296141004db567b0cafef --- codex-rs/tui/src/app/agents_overview.rs | 80 +++--- .../tui/src/app/agents_overview_composer.rs | 6 + .../tui/src/app/agents_overview_errors.rs | 61 +++++ codex-rs/tui/src/app/agents_overview_input.rs | 8 +- .../tui/src/app/agents_overview_render.rs | 2 + codex-rs/tui/src/app/agents_overview_tests.rs | 241 +++++++++++++++++- codex-rs/tui/src/app/agents_overview_view.rs | 2 +- codex-rs/tui/src/app/config_persistence.rs | 2 +- codex-rs/tui/src/app/event_dispatch.rs | 21 +- codex-rs/tui/src/app/session_lifecycle.rs | 20 +- codex-rs/tui/src/app/side.rs | 4 +- codex-rs/tui/src/app_event.rs | 4 + ...ests__agents_overview_attach_conflict.snap | 12 + ...w__tests__agents_overview_unsent_task.snap | 13 + 14 files changed, 411 insertions(+), 65 deletions(-) create mode 100644 codex-rs/tui/src/app/agents_overview_errors.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_attach_conflict.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_unsent_task.snap diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index 3e9a42d245..336f1a0318 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -3,6 +3,9 @@ #[path = "agents_overview_composer.rs"] mod composer; +#[path = "agents_overview_errors.rs"] +mod errors; + use super::agents_overview_view::AgentsOverviewFocus; use super::agents_overview_view::AgentsOverviewGroup; use super::agents_overview_view::AgentsOverviewRow; @@ -38,6 +41,7 @@ pub(super) struct AgentsOverviewState { pub(super) last_messages: HashMap, pub(super) activity: HashMap, pub(super) initialized: bool, + pub(super) unsent_prompt: Option, pub(super) request_id: Option, pub(super) refresh_pending: bool, pub(super) refresh_thread_ids: HashSet, @@ -151,6 +155,13 @@ impl App { } self.agents_overview.request_id = None; self.agents_overview.refresh_task = None; + self.agents_overview + .view_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .refresh_failed = !result + .as_ref() + .is_ok_and(|refresh| refresh.recent_seed_complete); match result { Ok(refresh) => { self.agents_overview.initialized = refresh.recent_seed_complete; @@ -174,14 +185,6 @@ impl App { } Err(error) => { tracing::warn!(%error, "failed to refresh shared agents"); - if self - .chat_widget - .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) - .is_some() - { - self.chat_widget - .add_error_message(format!("Failed to load shared agents: {error}")); - } } } for notifications in @@ -333,6 +336,10 @@ impl App { if self.current_displayed_thread_id() == Some(root_thread_id) && !self.thread_unavailable(root_thread_id) { + if let Ok(mut state) = self.agents_overview.view_state.lock() { + state.completion = Some(crate::bottom_pane::ViewCompletion::Accepted); + } + self.chat_widget.pre_draw_tick(); return Ok(AppRunControl::Continue); } if self.primary_thread_id != Some(root_thread_id) { @@ -387,7 +394,7 @@ impl App { { Ok(thread) => thread, Err(error) => { - self.chat_widget.add_error_message(format!( + self.add_agents_overview_error(format!( "Agent session {root_thread_id} is unavailable: {error}" )); return Ok(AppRunControl::Continue); @@ -424,8 +431,9 @@ impl App { { Ok(config) => config, Err(error) => { - self.chat_widget - .add_error_message(format!("Failed to load task settings: {error}")); + self.add_agents_overview_error(format!( + "Failed to load task settings: {error}" + )); return Ok(AppRunControl::Continue); } } @@ -454,7 +462,7 @@ impl App { && !profile.matches_config(&resume_config) }) { - self.chat_widget.add_error_message( + self.add_agents_overview_error( "Cannot resume task without preserving the selected permissions." .to_string(), ); @@ -479,8 +487,13 @@ impl App { { Ok(resumed) => resumed, Err(error) => { - self.chat_widget - .add_error_message(format!("Failed to attach to task: {error}")); + let message = if crate::app_server_session::is_active_writer_error(&error) { + tracing::warn!("failed to attach to task managed by another server"); + "Task is open elsewhere. Another app server is managing this task, so it can’t be attached here.".to_string() + } else { + format!("Failed to attach to task: {error}") + }; + self.add_agents_overview_error(message); return Ok(AppRunControl::Continue); } }; @@ -543,8 +556,7 @@ impl App { ) .await { - self.chat_widget - .add_error_message(format!("Failed to attach to task: {error}")); + self.add_agents_overview_error(format!("Failed to attach to task: {error}")); return Ok(AppRunControl::Continue); } let mut destination_config = self.chat_widget.config_ref().clone(); @@ -666,9 +678,9 @@ impl App { Ok(config) => config, Err(error) => { self.restore_agents_overview_prompt(prompt); - return self - .chat_widget - .add_error_message(format!("Failed to load project settings: {error}")); + return self.add_agents_overview_error(format!( + "Failed to load project settings: {error}" + )); } }, None => self.fresh_session_config(), @@ -680,9 +692,9 @@ impl App { != self.config.permissions.profile_workspace_roots()) { self.restore_agents_overview_prompt(prompt); - return self - .chat_widget - .add_error_message("Permission profile has different settings.".to_string()); + return self.add_agents_overview_error( + "Permission profile has different settings.".to_string(), + ); } self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::All); let defaults_cwd = match app_server.thread_params_mode() { @@ -732,7 +744,7 @@ impl App { Ok(None) => {} Err(error) => { self.restore_agents_overview_prompt(prompt); - return self.chat_widget.add_error_message(format!( + return self.add_agents_overview_error(format!( "Failed to load background task settings: {error}" )); } @@ -775,7 +787,7 @@ impl App { "Model {model} does not support image inputs. Remove images or switch models." ); self.restore_agents_overview_prompt(prompt); - self.chat_widget.add_error_message(message); + self.add_agents_overview_error(message); return; } let images = prompt @@ -792,8 +804,7 @@ impl App { Ok(images) => images, Err(error) => { self.restore_agents_overview_prompt(prompt); - self.chat_widget - .add_error_message(format!("Failed to prepare image: {error}")); + self.add_agents_overview_error(format!("Failed to prepare image: {error}")); return; } }; @@ -812,8 +823,7 @@ impl App { Ok(images) => images, Err(error) => { self.restore_agents_overview_prompt(prompt); - self.chat_widget - .add_error_message(format!("Failed to prepare image: {error}")); + self.add_agents_overview_error(format!("Failed to prepare image: {error}")); return; } } @@ -845,8 +855,7 @@ impl App { } Err(error) => { self.restore_agents_overview_prompt(prompt); - self.chat_widget - .add_error_message(format!("Failed to start background task: {error}")); + self.add_agents_overview_error(format!("Failed to start background task: {error}")); } } } @@ -882,8 +891,7 @@ impl App { .await; if let Err(error) = result { self.restore_agents_overview_prompt(prompt); - self.chat_widget - .add_error_message(format!("Failed to send task message: {error}")); + self.add_agents_overview_error(format!("Failed to send task message: {error}")); } } @@ -923,8 +931,9 @@ impl App { { Ok(turn_id) => turn_id, Err(error) => { - self.chat_widget - .add_error_message(format!("Failed to stop background task: {error}")); + self.add_agents_overview_error(format!( + "Failed to stop background task: {error}" + )); self.refresh_agents_overview_threads(app_server); return; } @@ -934,8 +943,7 @@ impl App { return; }; if let Err(error) = app_server.turn_interrupt(thread_id, turn_id).await { - self.chat_widget - .add_error_message(format!("Failed to stop background task: {error}")); + self.add_agents_overview_error(format!("Failed to stop background task: {error}")); self.refresh_agents_overview_threads(app_server); } } diff --git a/codex-rs/tui/src/app/agents_overview_composer.rs b/codex-rs/tui/src/app/agents_overview_composer.rs index 5d482a9ea4..394390403b 100644 --- a/codex-rs/tui/src/app/agents_overview_composer.rs +++ b/codex-rs/tui/src/app/agents_overview_composer.rs @@ -71,6 +71,12 @@ impl App { .join(", ") ) }); + let mut text = prompt.text.clone(); + if let Some(hint) = &hint { + text.push_str("\n\n"); + text.push_str(hint); + } + self.agents_overview.unsent_prompt = Some(text); self.chat_widget .add_info_message(format!("Unsent task: {}", prompt.text), hint); } diff --git a/codex-rs/tui/src/app/agents_overview_errors.rs b/codex-rs/tui/src/app/agents_overview_errors.rs new file mode 100644 index 0000000000..eccdb08c67 --- /dev/null +++ b/codex-rs/tui/src/app/agents_overview_errors.rs @@ -0,0 +1,61 @@ +//! Reports command-center action failures above the retained dashboard, with access +//! to unsent drafts when restoring them would overwrite newer input. + +use super::*; +use crate::wrapping::word_wrap_lines; +use ratatui::buffer::Buffer; + +struct AgentsOverviewErrorHeader(Vec>); + +impl Renderable for AgentsOverviewErrorHeader { + fn render(&self, area: Rect, buf: &mut Buffer) { + Renderable::render( + &Paragraph::new(word_wrap_lines(&self.0, usize::from(area.width))), + area, + buf, + ); + } + + fn desired_height(&self, width: u16) -> u16 { + word_wrap_lines(&self.0, usize::from(width)).len() as u16 + } +} + +impl App { + pub(in crate::app) fn add_agents_overview_error(&mut self, message: String) { + let unsent_prompt = self.agents_overview.unsent_prompt.take(); + if self + .chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) + .is_some() + { + let mut items = vec![SelectionItem { + name: "Return to command center".to_string(), + dismiss_on_select: true, + ..Default::default() + }]; + if let Some(text) = unsent_prompt { + items.push(SelectionItem { + name: "View unsent task".to_string(), + description: Some( + "Your newer draft has been kept in the composer.".to_string(), + ), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ViewAgentsOverviewUnsentPrompt(text.clone())); + })], + dismiss_on_select: false, + ..Default::default() + }); + } + self.chat_widget.show_selection_view(SelectionViewParams { + header: Box::new(AgentsOverviewErrorHeader(vec![ + Line::from("Unable to complete action".bold()), + Line::from(message.clone().dim()), + ])), + items, + ..Default::default() + }); + } + self.chat_widget.add_error_message(message); + } +} diff --git a/codex-rs/tui/src/app/agents_overview_input.rs b/codex-rs/tui/src/app/agents_overview_input.rs index 0ecbcded17..d9d6edd9e4 100644 --- a/codex-rs/tui/src/app/agents_overview_input.rs +++ b/codex-rs/tui/src/app/agents_overview_input.rs @@ -39,9 +39,11 @@ impl AgentsOverviewView { { match paste_image_to_temp_png() { Ok((path, _)) => composer.attach_image(path), - Err(error) => self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - crate::history_cell::new_error_event(format!("Failed to paste image: {error}")), - ))), + Err(error) => self + .app_event_tx + .send(AppEvent::AgentsOverviewError(format!( + "Failed to paste image: {error}" + ))), } return; } diff --git a/codex-rs/tui/src/app/agents_overview_render.rs b/codex-rs/tui/src/app/agents_overview_render.rs index a01b482468..82b51dc2c3 100644 --- a/codex-rs/tui/src/app/agents_overview_render.rs +++ b/codex-rs/tui/src/app/agents_overview_render.rs @@ -224,6 +224,8 @@ impl Renderable for AgentsOverviewView { let attention = format!("{needs_you} need input"); if let Some(notice) = self.state().connection_notice { Line::from(notice.cyan()).render(inset(summary), buf); + } else if self.state().refresh_failed { + Line::from("Error loading tasks".red()).render(inset(summary), buf); } else { Line::from(format!("{attention} {working} working {ready} ready").dim()) .render(inset(summary), buf); diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index b33f4b8a47..44fe24fc48 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -201,7 +201,7 @@ async fn overview_right_opens_current_or_highlighted_task() { assert!( matches!(rx.try_recv(), Ok(AppEvent::SelectAgentsOverviewThread { thread_id }) if thread_id == current) ); - assert!(app.chat_widget.no_modal_or_popup_active()); + assert!(!app.chat_widget.no_modal_or_popup_active()); let mut view = app.agents_overview_view(threads, /*selected_thread_id*/ None); view.handle_key_event(KeyCode::Esc.into()); @@ -216,7 +216,7 @@ async fn overview_right_opens_current_or_highlighted_task() { assert!( matches!(rx.try_recv(), Ok(AppEvent::SelectAgentsOverviewThread { thread_id }) if thread_id == other) ); - assert!(app.chat_widget.no_modal_or_popup_active()); + assert!(!app.chat_widget.no_modal_or_popup_active()); } #[tokio::test] @@ -808,8 +808,9 @@ 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::Enter, KeyModifiers::NONE)); + app.agents_overview.view_state.lock().unwrap().completion = + Some(crate::bottom_pane::ViewCompletion::Accepted); + app.chat_widget.pre_draw_tick(); 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); @@ -1429,10 +1430,9 @@ async fn shared_overview_shows_only_root_sessions() { candidate }) .collect(); - app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + app.agents_overview.view_state.lock().unwrap().completion = + Some(crate::bottom_pane::ViewCompletion::Accepted); + app.chat_widget.pre_draw_tick(); 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); @@ -1453,8 +1453,9 @@ async fn shared_overview_shows_only_root_sessions() { app.render_chat_widget_frame(&mut tui, screen_size) .expect("render full-screen dashboard"); assert_eq!(tui.terminal.viewport_area.height, screen_size.height); - app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + app.agents_overview.view_state.lock().unwrap().completion = + Some(crate::bottom_pane::ViewCompletion::Accepted); + app.chat_widget.pre_draw_tick(); app.render_chat_widget_frame(&mut tui, screen_size) .expect("restore conversation after closing dashboard"); assert!(tui.terminal.viewport_area.height < screen_size.height); @@ -2351,5 +2352,225 @@ async fn command_center_handles_resume_failure_and_success() -> Result<()> { Ok(()) } +#[tokio::test] +async fn command_center_attach_conflict_preserves_selection_and_draft() -> Result<()> { + let mut app = Box::pin(make_test_app()).await; + std::fs::write( + app.config.codex_home.join("config.toml"), + "[tui]\nresume_cwd = \"current\"\n", + )?; + let mut owner = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let started = Box::pin(owner.start_thread(&app.config)).await?; + let thread_id = started.session.thread_id; + // Materialize the lazy rollout so the second server can discover the locked task. + owner.thread_inject_items(thread_id, vec![serde_json::from_value(serde_json::json!({ + "type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Saved task"}] + }))?]).await?; + let thread = owner + .thread_read(thread_id, /*include_turns*/ false) + .await?; + let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + app.app_event_tx = AppEventSender::new(tx); + let mut view = app.agents_overview_view(vec![thread], Some(thread_id)); + view.handle_paste("Keep this draft".into()); + view.handle_key_event(KeyCode::Esc.into()); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + let before = render_bottom_popup(&app.chat_widget, /*width*/ 96); + let draft = overview_draft(&app); + let selection = app + .chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID); + let mut tui = crate::tui::test_support::make_test_tui()?; + + app.chat_widget.handle_key_event(KeyCode::Right.into()); + let event = rx.try_recv()?; + assert!( + matches!(event, AppEvent::SelectAgentsOverviewThread { thread_id: id } if id == thread_id) + ); + Box::pin(app.handle_event(&mut tui, &mut server, event)).await?; + + insta::with_settings!({snapshot_path => "../snapshots"}, { + insta::assert_snapshot!("agents_overview_attach_conflict", render_bottom_popup(&app.chat_widget, /*width*/ 96)); + }); + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 96), before); + assert_eq!(overview_draft(&app), draft); + assert_eq!( + app.chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID), + selection + ); + owner.shutdown().await?; + // Once the owner releases the task, the same keyboard path closes the dashboard. + app.chat_widget.handle_key_event(KeyCode::Right.into()); + Box::pin(app.handle_event(&mut tui, &mut server, rx.try_recv()?)).await?; + assert_eq!(app.chat_widget.thread_id(), Some(thread_id)); + assert!(app.chat_widget.no_modal_or_popup_active()); + + // Opening the already displayed task also returns to its conversation. + let thread = server + .thread_read(thread_id, /*include_turns*/ false) + .await?; + let view = app.agents_overview_view(vec![thread], Some(thread_id)); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + while rx.try_recv().is_ok() {} + app.chat_widget.handle_key_event(KeyCode::Right.into()); + Box::pin(app.handle_event(&mut tui, &mut server, rx.try_recv()?)).await?; + assert!(app.chat_widget.no_modal_or_popup_active()); + server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn command_center_refresh_failure_is_inline_and_clears_on_success() -> Result<()> { + let mut app = make_test_app().await; + let server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + view.handle_paste("Keep typing".into()); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + let before = render_bottom_popup(&app.chat_widget, /*width*/ 48); + for result in [ + Err("unavailable".into()), + Ok(AgentsOverviewThreadRefresh { + threads: HashMap::new(), + last_messages: HashMap::new(), + recent_seed_complete: false, + }), + ] { + let request_id = Uuid::new_v4(); + app.agents_overview.request_id = Some(request_id); + app.apply_agents_overview_thread_refresh(&server, request_id, result); + assert!( + app.agents_overview + .view_state + .lock() + .unwrap() + .refresh_failed + ); + } + let notice = render_bottom_popup(&app.chat_widget, /*width*/ 32) + .lines() + .take(2) + .collect::>() + .join("\n"); + insta::assert_snapshot!(notice, @r" + Agent command center + Error loading tasks + "); + let request_id = Uuid::new_v4(); + app.agents_overview.request_id = Some(request_id); + app.apply_agents_overview_thread_refresh( + &server, + request_id, + Ok(AgentsOverviewThreadRefresh { + threads: HashMap::new(), + last_messages: HashMap::new(), + recent_seed_complete: true, + }), + ); + assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 48), before); + server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn command_center_failed_send_keeps_newer_draft_and_exposes_unsent_task() -> Result<()> { + let (mut app, mut rx, _op_rx) = crate::app::tests::make_test_app_with_channels().await; + let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + view.handle_paste("Newer draft".into()); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + let before = render_bottom_popup(&app.chat_widget, /*width*/ 96); + let draft = overview_draft(&app); + let unsent = "Unsent draft that wraps across multiple lines in a narrow terminal.\nKeep this second line too."; + let thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000001")?; + app.submit_agents_overview_prompt(&server, thread_id, unsent.into(), Vec::new()) + .await; + let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 96); + insta::with_settings!({snapshot_path => "../snapshots"}, { + insta::assert_snapshot!("agents_overview_unsent_task", rendered); + }); + app.chat_widget.handle_key_event(KeyCode::Down.into()); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + let text = std::iter::from_fn(|| rx.try_recv().ok()).find_map(|event| match event { + AppEvent::ViewAgentsOverviewUnsentPrompt(text) => Some(text), + _ => None, + }); + assert_eq!(text, Some(unsent.into())); + let mut tui = crate::tui::test_support::make_test_tui()?; + Box::pin(app.handle_event( + &mut tui, + &mut server, + AppEvent::ViewAgentsOverviewUnsentPrompt(text.unwrap()), + )) + .await?; + let Some(Overlay::Static(mut overlay)) = app.overlay.take() else { + panic!("unsent task opens a static pager"); + }; + overlay.handle_event(&mut tui, TuiEvent::Key(KeyCode::Char('q').into()))?; + assert!(overlay.is_done()); + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + assert_eq!(overview_draft(&app), draft); + assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 96), before); + server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn command_center_action_failures_remain_visible() -> Result<()> { + let mut app = Box::pin(make_test_app()).await; + let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + let thread_id = ThreadId::new(); + let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + view.handle_paste("Keep this draft".into()); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + let draft = overview_draft(&app); + for (event, expected) in [ + ( + AppEvent::SelectAgentsOverviewThread { thread_id }, + "is unavailable", + ), + ( + AppEvent::RenameAgentsOverviewThread { + thread_id, + name: "New name".into(), + }, + "Failed to rename task", + ), + ( + AppEvent::StopAgentsOverviewThread { thread_id }, + "Failed to stop background task", + ), + ( + AppEvent::AgentsOverviewError("Failed to paste image: clipboard unavailable".into()), + "Failed to paste image", + ), + ] { + Box::pin(app.handle_event(&mut tui, &mut server, event)).await?; + let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 96); + assert!(rendered.contains("Unable to complete action"), "{rendered}"); + assert!(rendered.contains(expected), "{rendered}"); + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + assert!( + render_bottom_popup(&app.chat_widget, /*width*/ 96).contains("Agent command center") + ); + assert_eq!(overview_draft(&app), draft); + } + // The retained primary task can also fail in the nested agent-attachment path. + app.primary_thread_id = Some(thread_id); + Box::pin(app.handle_event( + &mut tui, + &mut server, + AppEvent::SelectAgentsOverviewThread { thread_id }, + )) + .await?; + let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 96); + assert!(rendered.contains("Unable to complete action"), "{rendered}"); + assert!(rendered.contains("is no longer available"), "{rendered}"); + server.shutdown().await?; + Ok(()) +} #[path = "agents_overview_actions_tests.rs"] mod actions; diff --git a/codex-rs/tui/src/app/agents_overview_view.rs b/codex-rs/tui/src/app/agents_overview_view.rs index 2032af45c0..3940bca5c3 100644 --- a/codex-rs/tui/src/app/agents_overview_view.rs +++ b/codex-rs/tui/src/app/agents_overview_view.rs @@ -132,6 +132,7 @@ pub(super) struct AgentsOverviewViewState { pub(super) composer: Option, pub(super) key_chord_hint: Option>, pub(super) focus: AgentsOverviewFocus, + pub(super) refresh_failed: bool, pub(super) connection_notice: Option<&'static str>, pub(super) server_version_notice: Option, search: String, @@ -320,7 +321,6 @@ impl AgentsOverviewView { state.search.clear(); state.searching = false; } - self.state().completion = Some(ViewCompletion::Accepted); } } diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index d1ca84813b..f6749fc2a6 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -1075,7 +1075,7 @@ impl App { profile.turn_override == RuntimePermissionProfileTurnOverride::LegacySandbox }); if explicitly_selected { - self.chat_widget.add_error_message( + self.add_agents_overview_error( "Permission overrides are not supported when resuming a remote task.".into(), ); return true; diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index c6a3cec367..6f1f808b87 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -35,6 +35,8 @@ impl App { && !matches!( &event, AppEvent::InsertHistoryCell(_) + | AppEvent::AgentsOverviewError(_) + | AppEvent::ViewAgentsOverviewUnsentPrompt(_) | AppEvent::ResetTranscriptForThreadSwitch | AppEvent::ManagedWorktreeCreated(_) | AppEvent::AppendMessageHistoryEntry { .. } @@ -2713,6 +2715,18 @@ impl App { AppEvent::OpenAgentsOverview => { self.open_agents_overview(app_server, AgentsOverviewFocus::List); } + AppEvent::AgentsOverviewError(message) => { + self.add_agents_overview_error(message); + } + AppEvent::ViewAgentsOverviewUnsentPrompt(text) => { + let _ = tui.enter_alt_screen(); + self.overlay = Some(Overlay::new_static_with_lines( + text.lines().map(|line| Line::from(line.to_string())).collect(), + "Unsent task".to_string(), + self.keymap.pager.clone(), + )); + tui.frame_requester().schedule_frame(); + } AppEvent::AgentsOverviewThreadsLoaded { request_id, result } => { self.apply_agents_overview_thread_refresh(app_server, request_id, result); } @@ -2721,7 +2735,9 @@ impl App { .select_agents_overview_thread(tui, app_server, thread_id) .await? { - AppRunControl::Continue if self.primary_thread_id.is_none() => { + AppRunControl::Continue + if self.primary_thread_id.is_none() + && self.chat_widget.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID).is_none() => { self.open_agents_overview(app_server, AgentsOverviewFocus::List); } AppRunControl::Continue => {} @@ -2740,8 +2756,7 @@ impl App { state.input = name; state.renaming = true; } - self.chat_widget - .add_error_message(format!("Failed to rename task: {error}")); + self.add_agents_overview_error(format!("Failed to rename task: {error}")); } } } diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 98e03ab10b..80f20ad549 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -547,8 +547,9 @@ impl App { .refresh_agent_picker_thread_liveness(app_server, thread_id) .await) { - self.chat_widget - .add_error_message(format!("Agent thread {thread_id} is no longer available.")); + self.add_agents_overview_error(format!( + "Agent thread {thread_id} is no longer available." + )); return Ok(()); } let mut is_replay_only = self @@ -570,15 +571,16 @@ impl App { attached_replay_only = true; } Err(err) => { - self.chat_widget.add_error_message(format!( + self.add_agents_overview_error(format!( "Failed to attach to agent thread {thread_id}: {err}" )); return Ok(()); } } } else if !self.thread_event_channels.contains_key(&thread_id) && is_replay_only { - self.chat_widget - .add_error_message(format!("Agent thread {thread_id} is no longer available.")); + self.add_agents_overview_error(format!( + "Agent thread {thread_id} is no longer available." + )); return Ok(()); } let previous_thread_id = self.active_thread_id; @@ -596,8 +598,7 @@ impl App { self.active_thread_id = None; let Some((receiver, mut snapshot)) = self.activate_thread_for_replay(thread_id).await else { - self.chat_widget - .add_error_message(format!("Agent thread {thread_id} is already active.")); + self.add_agents_overview_error(format!("Agent thread {thread_id} is already active.")); if let Some(previous_thread_id) = previous_thread_id { self.activate_thread_channel(previous_thread_id).await; } @@ -631,8 +632,9 @@ impl App { // Refreshing can merge restored turns into the store, so recap progress must be read only // after the refresh while the activated thread channel is still retained. let Some(channel) = self.thread_event_channels.get(&thread_id) else { - self.chat_widget - .add_error_message(format!("Agent thread {thread_id} is no longer available.")); + self.add_agents_overview_error(format!( + "Agent thread {thread_id} is no longer available." + )); return Ok(()); }; let recap_progress = { diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index b1e56429b0..d809bed2dc 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -425,14 +425,14 @@ impl App { ) -> bool { if let Err(message) = self.interrupt_side_thread(app_server, thread_id).await { tracing::warn!("{message}"); - self.chat_widget.add_error_message(message); + self.add_agents_overview_error(message); return false; } if let Err(err) = app_server.thread_unsubscribe(thread_id).await { let message = format!("Failed to close side conversation {thread_id}; it is still open: {err}"); tracing::warn!("{message}"); - self.chat_widget.add_error_message(message); + self.add_agents_overview_error(message); return false; } self.abandoned_side_threads.insert(thread_id); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 8d34d8ed2e..d585e004cd 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -274,6 +274,10 @@ pub(crate) enum AppEvent { CloseMisalignmentReview, /// Open the daemon-wide overview of recent and locally retained root sessions. OpenAgentsOverview, + /// Present a failed command-center action above the retained dashboard. + AgentsOverviewError(String), + /// Show a failed draft without replacing newer composer input. + ViewAgentsOverviewUnsentPrompt(String), /// Update the daemon-wide overview after a background thread listing finishes. AgentsOverviewThreadsLoaded { request_id: Uuid, diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_attach_conflict.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_attach_conflict.snap new file mode 100644 index 0000000000..5ae8b25aa6 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_attach_conflict.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/app/agents_overview_tests.rs +assertion_line: 2231 +expression: "render_bottom_popup(&app.chat_widget, 96)" +--- + Unable to complete action + Task is open elsewhere. Another app server is managing this task, so it can’t be attached + here. + +› 1. Return to command center + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_unsent_task.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_unsent_task.snap new file mode 100644 index 0000000000..c71dbb711b --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_unsent_task.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/app/agents_overview_tests.rs +assertion_line: 2326 +expression: rendered +--- + Unable to complete action + Failed to send task message: turn/start failed: thread not found: 00000000-0000-0000-0000- + 000000000001 (code -32600) + +› 1. Return to command center + 2. View unsent task Your newer draft has been kept in the composer. + + Press enter to confirm or esc to go back