Keep the Agent command center visible during archive and delete (#53804)

## Why

Archive and delete progress replaced the task list, hiding the selected task and surrounding context while the request was pending.

## What changed

Render progress as a centered dialog over the Agent command center. Preserve the selected task and shared scroll/filter state, and re-render the backdrop on terminal resize.

## Testing

Update narrow-terminal snapshots for both archive and delete to cover the progress overlay, task list, and selected row. Adapt the existing modal input-isolation test to supply a backdrop.

GitOrigin-RevId: e155478aeaddef189fbc53b27b8ff05d54bc3711
This commit is contained in:
Eric Traut
2026-10-13 23:58:39 +00:00
committed by copyberry
parent 9e03960cf8
commit 923f276f57
7 changed files with 111 additions and 19 deletions
+1 -1
View File
@@ -1221,7 +1221,7 @@ impl App {
#[cfg(test)]
#[path = "agents_overview_tests.rs"]
mod tests;
pub(super) mod tests;
fn agents_overview_group(
thread: &Thread,
@@ -1,6 +1,9 @@
//! Progress rendering and input isolation while lifecycle requests are pending.
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 futures::stream;
use pretty_assertions::assert_eq;
use ratatui::Terminal;
@@ -37,6 +40,7 @@ async fn modal_consumes_input_and_finishes_after_input_closes() -> color_eyre::R
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;
@@ -50,18 +54,38 @@ async fn modal_consumes_input_and_finishes_after_input_closes() -> color_eyre::R
Ok(())
}
#[test]
fn lifecycle_progress_wraps_at_narrow_widths() {
#[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 [
(AgentsOverviewAction::Archive, "archive_progress"),
(AgentsOverviewAction::Delete, "delete_progress"),
] {
let progress = LifecycleProgress(action);
let mut terminal =
Terminal::new(TestBackend::new(36, progress.desired_height(/*width*/ 36))).unwrap();
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, terminal.backend());
insta::assert_snapshot!(
name,
normalize_agent_center_snapshot(terminal.backend().to_string())
);
}
}
@@ -6,10 +6,14 @@ use super::App;
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;
@@ -75,14 +79,21 @@ impl Renderable for LifecycleProgress {
}
}
impl BottomPaneView for LifecycleProgress {}
/// Keep the terminal responsive without dispatching navigation or queued task events.
async fn run_lifecycle_modal<T>(
tui: &mut tui::Tui,
action: AgentsOverviewAction,
backdrop: &dyn Renderable,
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());
})?;
@@ -265,6 +276,20 @@ 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();
let operation = async {
let result = async {
@@ -310,7 +335,8 @@ impl App {
}
(result, detach_primary)
};
let (result, detach_primary) = run_lifecycle_modal(tui, action, operation, events).await?;
let (result, detach_primary) =
run_lifecycle_modal(tui, action, &backdrop, operation, events).await?;
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() {
@@ -242,7 +242,7 @@ async fn overview_right_preserves_editors_and_offline_state() {
assert!(rx.try_recv().is_err());
}
fn overview_thread(
pub(in crate::app) fn overview_thread(
thread_id: ThreadId,
parent_thread_id: Option<ThreadId>,
name: &str,
@@ -1,8 +1,29 @@
---
source: tui/src/app/agents_overview_action_progress_tests.rs
assertion_line: 62
expression: terminal.backend()
assertion_line: 86
expression: normalize_agent_center_snapshot(terminal.backend().to_string())
---
"Archiving task… "
"Please wait. Task switching is "
"unavailable until this finishes. "
" 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… "
@@ -1,8 +1,29 @@
---
source: tui/src/app/agents_overview_action_progress_tests.rs
assertion_line: 62
expression: terminal.backend()
assertion_line: 86
expression: normalize_agent_center_snapshot(terminal.backend().to_string())
---
"Deleting task… "
"Please wait. Task switching is "
"unavailable until this finishes. "
" 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… "
+1 -1
View File
@@ -70,7 +70,7 @@ impl Renderable for ViewStack<'_> {
}
/// A dialog placed within the supplied viewport, without taking over its backdrop.
pub(crate) struct CenteredView<'a>(pub(super) &'a dyn BottomPaneView);
pub(crate) struct CenteredView<'a>(pub(crate) &'a dyn BottomPaneView);
impl Renderable for CenteredView<'_> {
fn render(&self, area: Rect, buf: &mut Buffer) {