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:
Eric Traut
2026-09-10 21:42:01 +00:00
committed by copyberry
parent 1aaa453ce2
commit db2e09106c
14 changed files with 411 additions and 65 deletions
+44 -36
View File
@@ -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);
+231 -10
View File
@@ -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;
+1 -1
View File
@@ -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);
}
}
+1 -1
View File
@@ -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;
+18 -3
View File
@@ -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}"));
}
}
}
+11 -9
View File
@@ -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 = {
+2 -2
View File
@@ -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);
+4
View File
@@ -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,
@@ -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
@@ -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