From 1efd8fcb2697ccd78cdb502c2f2a07407a6921b0 Mon Sep 17 00:00:00 2001 From: joshyin-oai Date: Fri, 7 Aug 2026 03:32:16 +0000 Subject: [PATCH] Add session archiving to the resume picker (#37369) ## What changed - Add a `Ctrl+A` action and footer hint for archiving the selected session in the resume picker. - Send archive requests through the app server, remove successfully archived sessions from the picker, and surface failures inline. - Prevent duplicate actions while an archive is pending, preserve configured keybindings, and direct users to `/archive` when they select the current session. - Ignore archive notifications for untracked side threads so they do not appear in agent navigation. ## Testing - Cover successful, repeated, rejected, and failed archive requests, shortcut conflicts, footer rendering, and untracked-thread notifications. GitOrigin-RevId: 20ea21c804a9685e46484d67df967859f9be4651 --- codex-rs/tui/src/app/event_dispatch.rs | 3 + codex-rs/tui/src/app/tests.rs | 38 +++++ codex-rs/tui/src/app/thread_routing.rs | 6 +- codex-rs/tui/src/resume_picker.rs | 72 +++++++- codex-rs/tui/src/resume_picker/archive.rs | 134 +++++++++++++++ .../tui/src/resume_picker/archive_tests.rs | 157 ++++++++++++++++++ 6 files changed, 403 insertions(+), 7 deletions(-) create mode 100644 codex-rs/tui/src/resume_picker/archive.rs create mode 100644 codex-rs/tui/src/resume_picker/archive_tests.rs diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 02f1ba13b2..4726c76856 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -132,6 +132,9 @@ impl App { /*show_all*/ false, /*include_non_interactive*/ false, picker_app_server, + app_server.request_handle(), + self.primary_thread_id + .or(self.current_displayed_thread_id()), ) .await? { diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 15fd93a7bc..3c84a7491b 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -76,6 +76,7 @@ use codex_app_server_protocol::RequestId as AppServerRequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadArchivedNotification; use codex_app_server_protocol::ThreadClosedNotification; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadSettings; @@ -1438,6 +1439,43 @@ async fn collab_receiver_notification_does_not_cache_not_found_thread() { assert_eq!(app.agent_navigation.get(&receiver_thread_id), None); } +#[tokio::test] +async fn archived_untracked_threads_do_not_appear_in_agent_picker() -> Result<()> { + let mut app = Box::pin(make_test_app()).await; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( + app.chat_widget.config_ref(), + )) + .await?; + let primary_thread_id = ThreadId::new(); + app.enqueue_primary_thread_session( + test_thread_session(primary_thread_id, test_path_buf("/tmp/project")), + Vec::new(), + ) + .await?; + + let archived_thread_id = ThreadId::new(); + app.handle_app_server_event( + &app_server, + codex_app_server_client::AppServerEvent::ServerNotification(Box::new( + ServerNotification::ThreadArchived(ThreadArchivedNotification { + thread_id: archived_thread_id.to_string(), + }), + )), + ) + .await; + + assert!(!app.thread_event_channels.contains_key(&archived_thread_id)); + + Box::pin(app.open_agent_picker(&mut app_server)).await; + + assert_eq!( + app.agent_navigation.ordered_thread_ids(), + vec![primary_thread_id] + ); + assert_eq!(app.active_thread_id, Some(primary_thread_id)); + Ok(()) +} + #[tokio::test] async fn open_agent_picker_keeps_missing_threads_for_replay() -> Result<()> { let mut app = Box::pin(make_test_app()).await; diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 433f3fb371..124cfeb423 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -957,8 +957,10 @@ impl App { if self.abandoned_side_threads.contains(&thread_id) { return Ok(()); } - if matches!(notification, ServerNotification::ThreadSettingsUpdated(_)) - && self.primary_thread_id.is_some() + if matches!( + notification, + ServerNotification::ThreadSettingsUpdated(_) | ServerNotification::ThreadArchived(_) + ) && self.primary_thread_id.is_some() && self.primary_thread_id != Some(thread_id) && !self.thread_event_channels.contains_key(&thread_id) { diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 2a0e13940b..b4bfaa99c9 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -38,7 +38,12 @@ use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_lines; use chrono::DateTime; use chrono::Utc; +use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadArchiveParams; +use codex_app_server_protocol::ThreadArchiveResponse; use codex_app_server_protocol::ThreadHistoryMode; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadListCwdFilter; @@ -70,7 +75,9 @@ use tokio_stream::StreamExt; use tokio_stream::wrappers::UnboundedReceiverStream; use tracing::warn; use unicode_width::UnicodeWidthStr; +use uuid::Uuid; +mod archive; mod page_loading; use page_loading::PageLoadMode; @@ -123,7 +130,7 @@ pub enum SessionPickerAction { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum SessionPickerLaunchContext { Startup, - ExistingSession, + ExistingSession { current_thread_id: Option }, } impl SessionPickerAction { @@ -170,6 +177,9 @@ enum PickerLoadRequest { thread_id: ThreadId, cancellation: oneshot::Receiver<()>, }, + Archive { + thread_id: ThreadId, + }, } #[derive(Clone)] @@ -272,6 +282,10 @@ enum BackgroundEvent { thread_id: ThreadId, transcript: std::io::Result, }, + Archive { + thread_id: ThreadId, + result: std::io::Result<()>, + }, } #[derive(Clone)] @@ -329,12 +343,14 @@ pub async fn run_resume_picker_with_app_server( include_non_interactive: bool, app_server: AppServerSession, ) -> Result { + let archive_request_handle = app_server.request_handle(); run_resume_picker_with_launch_context( tui, config, show_all, include_non_interactive, app_server, + archive_request_handle, SessionPickerLaunchContext::Startup, ) .await @@ -346,6 +362,8 @@ pub async fn run_resume_picker_from_existing_session_with_app_server( show_all: bool, include_non_interactive: bool, app_server: AppServerSession, + archive_request_handle: AppServerRequestHandle, + current_thread_id: Option, ) -> Result { run_resume_picker_with_launch_context( tui, @@ -353,7 +371,8 @@ pub async fn run_resume_picker_from_existing_session_with_app_server( show_all, include_non_interactive, app_server, - SessionPickerLaunchContext::ExistingSession, + archive_request_handle, + SessionPickerLaunchContext::ExistingSession { current_thread_id }, ) .await } @@ -364,6 +383,7 @@ async fn run_resume_picker_with_launch_context( show_all: bool, include_non_interactive: bool, app_server: AppServerSession, + archive_request_handle: AppServerRequestHandle, launch_context: SessionPickerLaunchContext, ) -> Result { let (bg_tx, bg_rx) = mpsc::unbounded_channel(); @@ -402,6 +422,7 @@ async fn run_resume_picker_with_launch_context( options, spawn_app_server_page_loader( app_server, + archive_request_handle, include_non_interactive, raw_reasoning_visibility(config), (!uses_remote_workspace).then(|| config.codex_home.to_path_buf()), @@ -418,6 +439,7 @@ pub async fn run_fork_picker_with_app_server( show_all: bool, app_server: AppServerSession, ) -> Result { + let archive_request_handle = app_server.request_handle(); let (bg_tx, bg_rx) = mpsc::unbounded_channel(); let uses_remote_workspace = app_server.uses_remote_workspace(); let cwd_filter = picker_cwd_filter( @@ -454,6 +476,7 @@ pub async fn run_fork_picker_with_app_server( options, spawn_app_server_page_loader( app_server, + archive_request_handle, /*include_non_interactive*/ false, raw_reasoning_visibility(config), (!uses_remote_workspace).then(|| config.codex_home.to_path_buf()), @@ -594,6 +617,7 @@ fn picker_cwd_filter( fn spawn_app_server_page_loader( app_server: AppServerSession, + archive_request_handle: AppServerRequestHandle, include_non_interactive: bool, raw_reasoning_visibility: RawReasoningVisibility, codex_home: Option, @@ -648,6 +672,22 @@ fn spawn_app_server_page_loader( _ = cancellation => {} } } + PickerLoadRequest::Archive { thread_id } => { + let result = archive_request_handle + .request_typed::(ClientRequest::ThreadArchive { + request_id: RequestId::String(format!( + "resume-picker-archive-{}", + Uuid::new_v4() + )), + params: ThreadArchiveParams { + thread_id: thread_id.to_string(), + }, + }) + .await + .map(|_| ()) + .map_err(std::io::Error::other); + let _ = bg_tx.send(BackgroundEvent::Archive { thread_id, result }); + } } } if let Err(err) = app_server.shutdown().await { @@ -717,6 +757,7 @@ struct PickerState { action: SessionPickerAction, sort_key: ThreadSortKey, inline_error: Option, + archive_state: archive::ArchiveState, expanded_thread_id: Option, transcript_previews: HashMap, transcript_cells: HashMap, @@ -1054,6 +1095,7 @@ impl PickerState { action, sort_key: ThreadSortKey::UpdatedAt, inline_error: None, + archive_state: archive::ArchiveState::default(), expanded_thread_id: None, transcript_previews: HashMap::new(), transcript_cells: HashMap::new(), @@ -1270,6 +1312,8 @@ impl PickerState { } /* ^O */ => { self.toggle_density().await; } + _ if self.list_keymap.accept.is_pressed(key) + && matches!(self.archive_state, archive::ArchiveState::Pending { .. }) => {} _ if self.list_keymap.accept.is_pressed(key) => { if let Some(row) = self.filtered_rows.get(self.selected) { let path = row.path.clone(); @@ -1376,6 +1420,11 @@ impl PickerState { new_query.pop(); self.set_query(new_query); } + _ if self.archive_shortcut_available() + && crate::key_hint::ctrl(KeyCode::Char('a')).is_press(key) => + { + self.request_archive_for_selected_session(); + } KeyEvent { code: KeyCode::Char(c), modifiers, @@ -1523,6 +1572,9 @@ impl PickerState { self.request_frame(); } }, + BackgroundEvent::Archive { thread_id, result } => { + self.handle_archive_result(thread_id, result); + } } Ok(()) } @@ -2341,14 +2393,14 @@ fn footer_hint_lines(state: &PickerState, width: u16) -> Vec> { let (esc_label, esc_compact_label) = if state.query.is_empty() { match state.launch_context { SessionPickerLaunchContext::Startup => ("start new", "new"), - SessionPickerLaunchContext::ExistingSession => ("exit", "exit"), + SessionPickerLaunchContext::ExistingSession { .. } => ("exit", "exit"), } } else { ("clear search", "clear") }; let ctrl_c_label = match state.launch_context { SessionPickerLaunchContext::Startup => "quit", - SessionPickerLaunchContext::ExistingSession => "exit", + SessionPickerLaunchContext::ExistingSession { .. } => "exit", }; let density_label = match state.density { SessionListDensity::Comfortable => "dense view", @@ -2367,6 +2419,14 @@ fn footer_hint_lines(state: &PickerState, width: u16) -> Vec> { priority: 0, }); } + if !state.filtered_rows.is_empty() && state.archive_shortcut_available() { + first_row_hints.push(PickerFooterHint { + key: "ctrl+a".to_string(), + wide_label: String::from("archive"), + compact_label: String::from("archive"), + priority: 2, + }); + } if let Some(cancel) = state.list_keymap.primary_hint(ListAction::Cancel) { first_row_hints.push(PickerFooterHint { key: cancel.display_label(), @@ -4166,7 +4226,9 @@ mod tests { /*filter_cwd*/ None, SessionPickerAction::Resume, ); - state.launch_context = SessionPickerLaunchContext::ExistingSession; + state.launch_context = SessionPickerLaunchContext::ExistingSession { + current_thread_id: None, + }; let wide = footer_lines_text(&state, /*width*/ 220); assert!(wide.contains("esc exit")); diff --git a/codex-rs/tui/src/resume_picker/archive.rs b/codex-rs/tui/src/resume_picker/archive.rs new file mode 100644 index 0000000000..a44d5c164b --- /dev/null +++ b/codex-rs/tui/src/resume_picker/archive.rs @@ -0,0 +1,134 @@ +use codex_protocol::ThreadId; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; + +use super::LoadTrigger; +use super::PickerLoadRequest; +use super::PickerState; +use super::Row; +use super::SearchState; +use super::SessionPickerAction; +use super::SessionPickerLaunchContext; +use crate::keymap::KeymapContext; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(super) enum ArchiveState { + #[default] + Idle, + Pending { + thread_id: ThreadId, + }, +} + +impl PickerState { + pub(super) fn archive_shortcut_available(&self) -> bool { + if !matches!(self.action, SessionPickerAction::Resume) { + return false; + } + + let archive_key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL); + self.list_keymap.action_for(archive_key).is_none() + && !self.chord_keymap.bindings.iter().any(|binding| { + binding.action.context == KeymapContext::List + && binding.chord.prefix.is_press(archive_key) + }) + } + + pub(super) fn request_archive_for_selected_session(&mut self) { + if !matches!(self.archive_state, ArchiveState::Idle) { + return; + } + + let Some(row) = self.filtered_rows.get(self.selected) else { + return; + }; + let Some(thread_id) = row.thread_id else { + self.inline_error = Some(String::from("Selected session does not have a thread ID.")); + self.request_frame(); + return; + }; + + if matches!( + self.launch_context, + SessionPickerLaunchContext::ExistingSession { + current_thread_id: Some(current_thread_id) + } if current_thread_id == thread_id + ) { + self.inline_error = Some(String::from( + "Use /archive to archive the current session and exit.", + )); + self.request_frame(); + return; + } + + self.archive_state = ArchiveState::Pending { thread_id }; + self.request_frame(); + (self.picker_loader)(PickerLoadRequest::Archive { thread_id }); + } + + pub(super) fn handle_archive_result( + &mut self, + thread_id: ThreadId, + result: std::io::Result<()>, + ) { + if self.archive_state != (ArchiveState::Pending { thread_id }) { + return; + } + self.archive_state = ArchiveState::Idle; + + if let Err(error) = result { + self.inline_error = Some(format!("Failed to archive session: {error}")); + self.request_frame(); + return; + } + + let selected_key = self + .filtered_rows + .get(self.selected) + .filter(|row| row.thread_id != Some(thread_id)) + .and_then(Row::seen_key); + + self.all_rows.retain(|row| row.thread_id != Some(thread_id)); + // Keep the seen-row key as a tombstone so an older page that is already + // in flight cannot put the archived session back into the picker. + self.transcript_previews.remove(&thread_id); + self.transcript_cells.remove(&thread_id); + if self.expanded_thread_id == Some(thread_id) { + self.expanded_thread_id = None; + } + if self.pending_transcript_open == Some(thread_id) { + self.pending_transcript_open = None; + if let Some(cancellation) = self.pending_transcript_cancellation.take() { + let _ = cancellation.send(()); + } + self.transcript_loading_frame_shown = false; + } + self.inline_error = None; + self.apply_filter(); + + if let Some(selected_key) = selected_key + && let Some(selected) = self + .filtered_rows + .iter() + .position(|row| row.seen_key().as_ref() == Some(&selected_key)) + { + self.selected = selected; + self.ensure_selected_visible(); + } + if self.filtered_rows.is_empty() + && !self.query.is_empty() + && self.pagination.next_cursor.is_some() + && !self.pagination.reached_scan_cap + && !self.search_state.is_active() + { + let token = self.allocate_search_token(); + self.search_state = SearchState::Active { token }; + self.load_more_if_needed(LoadTrigger::Search { token }); + } + } +} + +#[cfg(test)] +#[path = "archive_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/resume_picker/archive_tests.rs b/codex-rs/tui/src/resume_picker/archive_tests.rs new file mode 100644 index 0000000000..c379115e5d --- /dev/null +++ b/codex-rs/tui/src/resume_picker/archive_tests.rs @@ -0,0 +1,157 @@ +use super::super::PickerLoadRequest; +use super::super::PickerLoader; +use super::super::PickerState; +use super::super::ProviderFilter; +use super::super::Row; +use super::super::SessionPickerAction; +use super::super::SessionPickerLaunchContext; +use super::ArchiveState; +use crate::key_hint::KeyBinding; +use crate::tui::FrameRequester; +use codex_app_server_client::TypedRequestError; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_protocol::ThreadId; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use pretty_assertions::assert_eq; +use std::sync::Arc; +use std::sync::Mutex; + +fn archive_picker_state() -> (PickerState, Arc>>) { + let requests = Arc::new(Mutex::new(Vec::new())); + let request_sink = Arc::clone(&requests); + let picker_loader: PickerLoader = Arc::new(move |request| { + if let PickerLoadRequest::Archive { thread_id } = request { + request_sink + .lock() + .expect("archive request sink") + .push(thread_id); + } + }); + let state = PickerState::new( + FrameRequester::test_dummy(), + picker_loader, + ProviderFilter::Any, + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + (state, requests) +} + +fn set_selected_session(state: &mut PickerState, thread_id: ThreadId) { + state.all_rows = vec![Row { + path: None, + preview: String::from("Selected session"), + thread_id: Some(thread_id), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }]; + state.apply_filter(); +} + +#[tokio::test] +async fn archive_shortcut_archives_selected_session_once() { + let (mut state, requests) = archive_picker_state(); + let thread_id = ThreadId::new(); + set_selected_session(&mut state, thread_id); + let shortcut = KeyEvent::new(KeyCode::Char('\u{0001}'), KeyModifiers::NONE); + + assert!(state.handle_key(shortcut).await.unwrap().is_none()); + assert!(state.handle_key(shortcut).await.unwrap().is_none()); + assert_eq!(state.archive_state, ArchiveState::Pending { thread_id }); + assert_eq!(*requests.lock().unwrap(), vec![thread_id]); + assert!( + state + .handle_key(KeyEvent::from(KeyCode::Enter)) + .await + .unwrap() + .is_none() + ); + + state.handle_archive_result(thread_id, Ok(())); + + assert_eq!(state.archive_state, ArchiveState::Idle); + assert!(state.filtered_rows.is_empty()); +} + +#[test] +fn archive_request_rejects_current_session() { + let (mut state, requests) = archive_picker_state(); + let thread_id = ThreadId::new(); + state.launch_context = SessionPickerLaunchContext::ExistingSession { + current_thread_id: Some(thread_id), + }; + set_selected_session(&mut state, thread_id); + + state.request_archive_for_selected_session(); + + assert_eq!(state.archive_state, ArchiveState::Idle); + assert_eq!( + state.inline_error.as_deref(), + Some("Use /archive to archive the current session and exit.") + ); + assert!(requests.lock().unwrap().is_empty()); +} + +#[test] +fn archive_failure_preserves_session_and_reports_server_error() { + let (mut state, requests) = archive_picker_state(); + let thread_id = ThreadId::new(); + set_selected_session(&mut state, thread_id); + state.request_archive_for_selected_session(); + let error = TypedRequestError::Server { + method: String::from("thread/archive"), + source: JSONRPCErrorError { + code: -32600, + message: String::from("thread already has an active writer"), + data: None, + }, + }; + + state.handle_archive_result(thread_id, Err(std::io::Error::other(error))); + + assert_eq!(state.archive_state, ArchiveState::Idle); + assert_eq!( + state.inline_error.as_deref(), + Some( + "Failed to archive session: thread/archive failed: thread already has an active writer (code -32600)" + ) + ); + assert_eq!(state.filtered_rows.len(), 1); + + state.request_archive_for_selected_session(); + + assert_eq!(*requests.lock().unwrap(), vec![thread_id, thread_id]); +} + +#[test] +fn archive_shortcut_preserves_configured_list_binding() { + let (mut state, _requests) = archive_picker_state(); + state.list_keymap.move_up.push(KeyBinding::new( + KeyCode::Char('\u{0001}'), + KeyModifiers::NONE, + )); + + assert!(!state.archive_shortcut_available()); +} + +#[test] +fn archive_footer_shows_shortcut_for_resume_sessions() { + let (mut state, _requests) = archive_picker_state(); + set_selected_session(&mut state, ThreadId::new()); + let footer = super::super::footer_hint_lines(&state, /*width*/ 220) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + + insta::assert_snapshot!(footer, @r" + enter resume ctrl+a archive esc start new ctrl+c quit tab focus sort/filter ←/→ change option + ctrl+o dense view ctrl+t transcript ctrl+e expand ↑/↓ browse + "); +}