mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
Keep command center errors visible and preserve drafts (#44651)
## 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
This commit is contained in:
@@ -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<ThreadId, String>,
|
||||
pub(super) activity: HashMap<ThreadId, super::agents_overview_details::AgentsOverviewActivity>,
|
||||
pub(super) initialized: bool,
|
||||
pub(super) unsent_prompt: Option<String>,
|
||||
pub(super) request_id: Option<Uuid>,
|
||||
pub(super) refresh_pending: bool,
|
||||
pub(super) refresh_thread_ids: HashSet<ThreadId>,
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<Line<'static>>);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
.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;
|
||||
|
||||
@@ -132,6 +132,7 @@ pub(super) struct AgentsOverviewViewState {
|
||||
pub(super) composer: Option<ChatComposer>,
|
||||
pub(super) key_chord_hint: Option<Vec<(String, String)>>,
|
||||
pub(super) focus: AgentsOverviewFocus,
|
||||
pub(super) refresh_failed: bool,
|
||||
pub(super) connection_notice: Option<&'static str>,
|
||||
pub(super) server_version_notice: Option<String>,
|
||||
search: String,
|
||||
@@ -320,7 +321,6 @@ impl AgentsOverviewView {
|
||||
state.search.clear();
|
||||
state.searching = false;
|
||||
}
|
||||
self.state().completion = Some(ViewCompletion::Accepted);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+12
@@ -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
|
||||
+13
@@ -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
|
||||
Reference in New Issue
Block a user