Keep the agent dashboard browsable during task removal (#53908)

## Why

Archiving or deleting a task blocked dashboard navigation until the server request finished.

## What changed

Keep the dashboard interactive for browsing while archive and delete requests are pending. Hide the affected task immediately and show an inline progress message. Allow selection movement and search while preventing task switching, new tasks, and leaving the dashboard until the request completes.

Preserve the dashboard selection when removing the current task detaches the session.

## Testing

Update archive and delete progress tests and snapshots to cover navigation, resize handling, immediate task hiding, and suppression of task switching and creation while requests are pending.

GitOrigin-RevId: ff6555602bcd84d38e00de55c3032f442589fdcb
This commit is contained in:
Eric Traut
2026-10-14 08:05:19 +00:00
committed by copyberry
parent 97771b1d78
commit 95273b84fb
6 changed files with 233 additions and 192 deletions
@@ -217,6 +217,16 @@ impl Renderable for AgentsOverviewView {
.creating_worktree
.then(|| "Creating worktree…".to_owned())
})
.or_else(|| {
state.removal.map(|action| match action {
AgentsOverviewAction::Archive => {
"Archiving task… You can still browse.".to_owned()
}
AgentsOverviewAction::Delete => {
"Deleting task… You can still browse.".to_owned()
}
})
})
.or_else(|| {
(state.refresh_failed && !state.loading).then(|| "Error loading tasks".to_owned())
})
@@ -1,9 +1,10 @@
//! Progress rendering and input isolation while lifecycle requests are pending.
//! The dashboard remains browsable while lifecycle requests hold the server lock.
use super::*;
use crate::app::agents_overview::tests::overview_thread;
use crate::chatwidget::tests::helpers::normalize_agent_center_snapshot;
use codex_app_server_protocol::ThreadStatus;
use crate::test_support::PathBufExt;
use crate::test_support::test_path_buf;
use crossterm::event::KeyCode;
use futures::stream;
use pretty_assertions::assert_eq;
use ratatui::Terminal;
@@ -14,78 +15,105 @@ use std::time::Duration;
#[tokio::test]
async fn modal_consumes_input_and_finishes_after_input_closes() -> color_eyre::Result<()> {
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.pause_events();
let (finished_input, wait_for_input) = tokio::sync::oneshot::channel();
let mut finished_input = Some(finished_input);
let mut events = VecDeque::from([
tui::TuiEvent::Key(crossterm::event::KeyCode::Esc.into()),
tui::TuiEvent::Paste("ignore this input".into()),
tui::TuiEvent::Draw,
tui::TuiEvent::Resize(ratatui::layout::Size::new(80, 24)),
tui::TuiEvent::FocusGained,
]);
let events = stream::poll_fn(move |_| {
if let Some(event) = events.pop_front() {
Poll::Ready(Some(event))
} else {
if let Some(finished_input) = finished_input.take() {
let _ = finished_input.send(());
}
Poll::Ready(None)
}
});
let result = tokio::time::timeout(
Duration::from_secs(5),
run_lifecycle_modal(
&mut tui,
AgentsOverviewAction::Archive,
&Paragraph::new("Agent command center"),
async {
wait_for_input.await.expect("all modal input was consumed");
tokio::task::yield_now().await;
"operation finished"
},
events,
),
)
.await??;
assert_eq!(result, "operation finished");
Ok(())
}
#[tokio::test]
async fn lifecycle_progress_wraps_at_narrow_widths() {
let (app, _rx, _op_rx) = crate::app::tests::make_test_app_with_channels().await;
let threads = ["First task", "Selected task", "Last task"]
.into_iter()
.enumerate()
.map(|(index, name)| {
overview_thread(
ThreadId::from_u128(index as u128 + 1),
/*parent_thread_id*/ None,
name,
ThreadStatus::Idle,
)
})
.collect();
let backdrop = app.agents_overview_view(threads, Some(ThreadId::from_u128(/*value*/ 2)));
for (action, name) in [
for (action, snapshot) in [
(AgentsOverviewAction::Archive, "archive_progress"),
(AgentsOverviewAction::Delete, "delete_progress"),
] {
let progress = LifecycleProgress(action);
let progress = DialogOverlay {
backdrop: RenderableItem::Borrowed(&backdrop),
dialog: CenteredView(&progress),
};
let mut terminal = Terminal::new(TestBackend::new(36, 24)).unwrap();
terminal
.draw(|frame| progress.render(frame.area(), frame.buffer_mut()))
.unwrap();
insta::assert_snapshot!(
name,
normalize_agent_center_snapshot(terminal.backend().to_string())
let (mut app, mut rx, _) = crate::app::tests::make_test_app_with_channels().await;
let mut server = crate::start_embedded_app_server_for_picker(&app.config).await?;
for index in 1..=3 {
let id = ThreadId::from_string(
&app_test_support::create_fake_rollout(
&app.config.codex_home,
&format!("2025-01-0{index}T12-00-00"),
&format!("2025-01-0{index}T12:00:00Z"),
&format!("Task {index}"),
Some(&app.config.model_provider_id),
/*git_info*/ None,
)
.expect("materialize task"),
)?;
let mut thread = server.thread_read(id, /*include_turns*/ false).await?;
thread.cwd = test_path_buf("/tmp/project").abs();
app.agents_overview.threads.insert(id, Some(thread));
}
let threads = app
.agents_overview
.threads
.values()
.flatten()
.cloned()
.collect();
let view = app.agents_overview_view(threads, /*selected_thread_id*/ None);
app.agents_overview.visible_thread_ids = view.thread_ids();
app.chat_widget.show_bottom_pane_view(Box::new(view));
let removed = app.agents_overview.visible_thread_ids[0];
app.prepare_agents_overview_removal(&HashSet::from([removed]));
app.agents_overview.hidden_threads.insert(removed);
app.agents_overview.view_state.lock().unwrap().removal = Some(action);
app.repaint_agents_overview();
assert!(!app.agents_overview.visible_thread_ids.contains(&removed));
while rx.try_recv().is_ok() {}
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.pause_events();
let (finished_input, wait_for_input) = tokio::sync::oneshot::channel();
let mut finished_input = Some(finished_input);
let mut events = VecDeque::from([
tui::TuiEvent::Key(KeyCode::End.into()),
// Leaving and server mutations wait without accumulating actions.
tui::TuiEvent::Key(KeyCode::Esc.into()),
tui::TuiEvent::Key(KeyCode::Enter.into()),
tui::TuiEvent::Key(KeyCode::Char('n').into()),
tui::TuiEvent::Draw,
tui::TuiEvent::Resize(ratatui::layout::Size::new(66, 20)),
tui::TuiEvent::FocusGained,
]);
let events = stream::poll_fn(move |_| {
if let Some(event) = events.pop_front() {
Poll::Ready(Some(event))
} else {
if let Some(finished_input) = finished_input.take() {
let _ = finished_input.send(());
}
Poll::Ready(None)
}
});
let result = tokio::time::timeout(
Duration::from_secs(5),
run_lifecycle_modal(
&mut tui,
&mut app,
async {
wait_for_input.await.expect("all modal input was consumed");
"operation finished"
},
events,
),
)
.await??;
assert_eq!(result, "operation finished");
assert_eq!(
tui.terminal.last_known_screen_size,
ratatui::layout::Size::new(66, 20)
);
let selected = app
.chat_widget
.selected_index_for_active_view(AGENTS_OVERVIEW_VIEW_ID)
.expect("dashboard stays open");
assert_eq!(selected, app.agents_overview.visible_thread_ids.len() - 1);
assert!(
!std::iter::from_fn(|| rx.try_recv().ok()).any(|event| matches!(
event,
AppEvent::SelectAgentsOverviewThread { .. }
| AppEvent::NewAgentsOverviewSession { .. }
))
);
let mut terminal = Terminal::new(TestBackend::new(80, 24))?;
terminal.draw(|frame| app.chat_widget.render(frame.area(), frame.buffer_mut()))?;
let text = normalize_agent_center_snapshot(terminal.backend().to_string());
let age = regex_lite::Regex::new(r" +[0-9]+d ago").unwrap();
insta::assert_snapshot!(snapshot, age.replace_all(&text, " [age]"));
server.shutdown().await?;
}
Ok(())
}
+59 -67
View File
@@ -1,19 +1,16 @@
//! Server RPCs own lifecycle outcomes; successful actions invalidate dashboard group caches.
//! A modal keeps rendering during RPCs while deferring navigation and task notifications.
//! Keep the dashboard browsable during RPCs while deferring task notifications.
//! Removing the current root leaves an unattached dashboard, even when it is empty.
use super::App;
use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID;
use crate::app_event::AgentsOverviewAction;
use crate::app_event::AppEvent;
use crate::app_server_session::AppServerSession;
use crate::bottom_pane::BottomPaneView;
use crate::bottom_pane::CenteredView;
use crate::bottom_pane::DialogOverlay;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::chatwidget::ChatWidget;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableItem;
use crate::tui;
use crate::wrapping::word_wrap_lines;
use codex_app_server_protocol::ClientRequest;
@@ -52,51 +49,14 @@ impl Renderable for LifecycleHeader {
}
}
struct LifecycleProgress(AgentsOverviewAction);
impl LifecycleProgress {
fn header(&self) -> LifecycleHeader {
let title = match self.0 {
AgentsOverviewAction::Archive => "Archiving task…",
AgentsOverviewAction::Delete => "Deleting task…",
};
LifecycleHeader(vec![
title.bold().into(),
"Please wait. Task switching is unavailable until this finishes."
.dim()
.into(),
])
}
}
impl Renderable for LifecycleProgress {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.header().render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
self.header().desired_height(width)
}
}
impl BottomPaneView for LifecycleProgress {}
/// Keep the terminal responsive without dispatching navigation or queued task events.
/// Keep browsing responsive without queuing actions against a locked server.
async fn run_lifecycle_modal<T>(
tui: &mut tui::Tui,
action: AgentsOverviewAction,
backdrop: &dyn Renderable,
app: &mut App,
operation: impl Future<Output = T>,
events: impl Stream<Item = tui::TuiEvent> + Unpin,
) -> color_eyre::Result<T> {
let progress = LifecycleProgress(action);
let progress = DialogOverlay {
backdrop: RenderableItem::Borrowed(backdrop),
dialog: CenteredView(&progress),
};
tui.draw(u16::MAX, |frame| {
progress.render(frame.area(), frame.buffer_mut());
})?;
app.render_chat_widget_frame(tui, tui.terminal.last_known_screen_size)?;
let mut events = events.fuse();
tokio::pin!(operation);
let result = loop {
@@ -104,13 +64,28 @@ async fn run_lifecycle_modal<T>(
biased;
result = &mut operation => break result,
Some(event) = events.next() => {
tui.screen_size_for_event(&event)?;
let screen_size = tui.screen_size_for_event(&event)?;
if matches!(&event, tui::TuiEvent::Paste(_) | tui::TuiEvent::FocusLost) {
app.cancel_pending_key_chord();
}
let dashboard_active = app.chat_widget
.selected_index_for_active_view(AGENTS_OVERVIEW_VIEW_ID)
.is_some();
match event {
tui::TuiEvent::Key(key) if dashboard_active => {
if let Some(key) = app.route_key_chord_event(tui, key) {
app.chat_widget.handle_key_event_with_activation_key(key, app.routed_key_activation);
}
tui.frame_requester().schedule_frame();
}
tui::TuiEvent::Paste(text) if dashboard_active => {
app.chat_widget.handle_paste(text);
tui.frame_requester().schedule_frame();
}
tui::TuiEvent::Key(_) | tui::TuiEvent::Paste(_) | tui::TuiEvent::FocusLost | tui::TuiEvent::Mouse(_) => {}
tui::TuiEvent::Draw | tui::TuiEvent::Resize(_) | tui::TuiEvent::Resume | tui::TuiEvent::FocusGained => {
tui.draw(u16::MAX, |frame| {
progress.render(frame.area(), frame.buffer_mut());
})?;
app.expire_pending_key_chord();
app.render_chat_widget_frame(tui, screen_size)?;
}
}
}
@@ -276,25 +251,26 @@ impl App {
let mut removes_primary = self.primary_thread_id == Some(thread_id);
let mut attempted = false;
let primary_thread_id = self.primary_thread_id;
// Retain the displayed selection and shared scroll/filter state while the RPC
// borrows the live widget to stop voice. Re-render on resize rather than freezing pixels.
let selected_thread_id = self
.chat_widget
.selected_index_for_present_view(super::agents_overview::AGENTS_OVERVIEW_VIEW_ID)
.and_then(|index| self.agents_overview.visible_thread_ids.get(index).copied());
let threads = self
.agents_overview
.threads
.values()
.flatten()
.cloned()
.collect();
let backdrop = self.agents_overview_view(threads, selected_thread_id);
let events = tui.event_stream();
self.prepare_agents_overview_removal(&HashSet::from([thread_id]));
let hidden = self.agents_overview.hidden_threads.insert(thread_id);
{
let mut state = self
.agents_overview
.view_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.removal = Some(action);
// Confirmation may have been opened from the rename editor.
state.rename_target = None;
}
self.repaint_agents_overview();
let voice_result = self
.stop_voice_for_removed_thread(app_server, thread_id)
.await;
let operation = async {
let result = async {
self.stop_voice_for_removed_thread(app_server, thread_id)
.await?;
voice_result?;
if let Some(primary) = primary_thread_id
&& primary != thread_id
{
@@ -335,8 +311,16 @@ impl App {
}
(result, detach_primary)
};
let (result, detach_primary) =
run_lifecycle_modal(tui, action, &backdrop, operation, events).await?;
let outcome = run_lifecycle_modal(tui, self, operation, events).await;
if hidden {
self.agents_overview.hidden_threads.remove(&thread_id);
}
self.agents_overview
.view_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.removal = None;
let (result, detach_primary) = outcome?;
if attempted {
// A read started before the RPC can contain stale task and approval state.
if let Some(refresh) = self.agents_overview.refresh_task.take() {
@@ -398,6 +382,14 @@ impl App {
}
}
if detach_primary {
self.agents_overview.selection_after_removal =
self.agents_overview.selection_after_removal.or_else(|| {
self.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
.and_then(|index| {
self.agents_overview.visible_thread_ids.get(index).copied()
})
});
self.reset_for_thread_switch(tui)?;
self.pending_thread_switch_resets += 1;
self.app_event_tx
+14 -1
View File
@@ -156,6 +156,8 @@ pub(super) struct AgentsOverviewViewState {
pub(super) has_more: bool,
show_more_selected: bool,
pub(super) connection_notice: Option<&'static str>,
/// Browse only while the server holds its lifecycle lock.
pub(super) removal: Option<AgentsOverviewAction>,
pub(super) server_version_notice: Option<String>,
search: String,
searching: bool,
@@ -588,6 +590,9 @@ impl BottomPaneView for AgentsOverviewView {
self.reconcile_command_center_selection();
return CancellationEvent::Handled;
}
if state.removal.is_some() {
return CancellationEvent::Handled;
}
CancellationEvent::NotHandled
}
@@ -647,7 +652,9 @@ impl BottomPaneView for AgentsOverviewView {
return;
}
if (self.state().connection_notice.is_some() || self.state().creating_worktree)
if (self.state().connection_notice.is_some()
|| self.state().creating_worktree
|| self.state().removal.is_some())
&& self.keymap.action_for(key) != Some(ListAction::Cancel)
{
match self.keymap.action_for(key) {
@@ -656,6 +663,12 @@ impl BottomPaneView for AgentsOverviewView {
Some(action @ (ListAction::PageUp | ListAction::PageDown)) => {
self.page_selection(action)
}
Some(ListAction::JumpTop) => {
self.selected = self.visible_indices().first().copied().unwrap_or(0);
}
Some(ListAction::JumpBottom) => {
self.selected = self.visible_indices().last().copied().unwrap_or(0);
}
_ => {}
}
return;
@@ -1,29 +1,28 @@
---
source: tui/src/app/agents_overview_action_progress_tests.rs
assertion_line: 86
expression: normalize_agent_center_snapshot(terminal.backend().to_string())
expression: "age.replace_all(&text, \" [age]\")"
---
" Agent command center Group: Pr… "
" All 3 Needs you 0 › "
" ──────────────────────────────── "
" Tasks "
" /tmp/project 3 "
" ○ First task "
" › ○ Selected task "
" ○ Last task "
" "
" "
" Archiving task… "
" Please wait. Task switching is "
" unavailable until this finishes. "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" ? help esc back ↑/↓ move ent… "
" Agent command center Group: Project g "
" All 2 Needs you 0 Working 0 Ready 0 Inactive 2 "
" ──────────────────────────────────────────────────────────────────────────── "
" Tasks Status Updated "
" /tmp/project 2 "
" ○ Task 2 Inactive [age] "
" › ○ Task 1 Inactive [age] "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" Archiving task… You can still browse. "
@@ -1,29 +1,28 @@
---
source: tui/src/app/agents_overview_action_progress_tests.rs
assertion_line: 86
expression: normalize_agent_center_snapshot(terminal.backend().to_string())
expression: "age.replace_all(&text, \" [age]\")"
---
" Agent command center Group: Pr… "
" All 3 Needs you 0 › "
" ──────────────────────────────── "
" Tasks "
" /tmp/project 3 "
" ○ First task "
" › ○ Selected task "
" ○ Last task "
" "
" "
" Deleting task… "
" Please wait. Task switching is "
" unavailable until this finishes. "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" ? help esc back ↑/↓ move ent… "
" Agent command center Group: Project g "
" All 2 Needs you 0 Working 0 Ready 0 Inactive 2 "
" ──────────────────────────────────────────────────────────────────────────── "
" Tasks Status Updated "
" /tmp/project 2 "
" ○ Task 2 Inactive [age] "
" › ○ Task 1 Inactive [age] "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" Deleting task… You can still browse. "