From 0bdce9f424eb9b39d7b3a8811742d10b6fbf8d54 Mon Sep 17 00:00:00 2001 From: joshyin-oai Date: Fri, 7 Aug 2026 03:48:01 +0000 Subject: [PATCH] Restore archived sessions from the resume picker (#37371) ## What changed - Add an Active/Archived status control to the resume picker and use it when listing sessions. - Restore an archived session before resuming it, with inline error handling and duplicate-request prevention while restoration is pending. - Keep directory filtering available for archived sessions and hide the archive shortcut while viewing them. ## Testing - Add coverage for archived-session filtering, successful restoration, failure and retry behavior, toolbar rendering, and keyboard navigation. GitOrigin-RevId: db3f5935e0f0bba12a110ba9c7aaf9d40abd4a4f --- codex-rs/tui/src/resume_picker.rs | 185 ++++++++++++++---- codex-rs/tui/src/resume_picker/archive.rs | 40 +++- .../tui/src/resume_picker/archive_tests.rs | 139 ++++++++++++- ...e_picker_search_line_sort_filter_tabs.snap | 2 +- 4 files changed, 325 insertions(+), 41 deletions(-) diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index b4bfaa99c9..86eef4b1b0 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -49,6 +49,8 @@ use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadListCwdFilter; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadSortKey; +use codex_app_server_protocol::ThreadUnarchiveParams; +use codex_app_server_protocol::ThreadUnarchiveResponse; use codex_config::types::SessionPickerViewMode; use codex_protocol::ThreadId; use codex_utils_path as path_utils; @@ -164,6 +166,7 @@ struct PageLoadRequest { search_token: Option, mode: PageLoadMode, cwd_filter: Option, + status: SessionStatus, provider_filter: ProviderFilter, sort_key: ThreadSortKey, } @@ -180,6 +183,9 @@ enum PickerLoadRequest { Archive { thread_id: ThreadId, }, + Unarchive { + thread_id: ThreadId, + }, } #[derive(Clone)] @@ -212,23 +218,33 @@ impl SessionFilterMode { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SessionStatus { + Active, + Archived, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ToolbarControl { Filter, + Status, Sort, } impl ToolbarControl { - fn previous(self) -> Self { + fn previous(self, action: SessionPickerAction) -> Self { match self { Self::Filter => Self::Sort, + Self::Status => Self::Filter, + Self::Sort if matches!(action, SessionPickerAction::Resume) => Self::Status, Self::Sort => Self::Filter, } } - fn next(self) -> Self { + fn next(self, action: SessionPickerAction) -> Self { match self { - Self::Filter => Self::Sort, + Self::Filter if matches!(action, SessionPickerAction::Resume) => Self::Status, + Self::Filter | Self::Status => Self::Sort, Self::Sort => Self::Filter, } } @@ -286,6 +302,10 @@ enum BackgroundEvent { thread_id: ThreadId, result: std::io::Result<()>, }, + Unarchive { + thread_id: ThreadId, + result: std::io::Result, + }, } #[derive(Clone)] @@ -324,8 +344,8 @@ struct SessionPickerRunOptions { /// lazy transcript previews, and pagination. /// /// Sessions render as compact multi-line records with stable metadata first and -/// the conversation preview last. Users can focus Sort/Filter toolbar controls -/// with Tab, change the focused control with the arrow keys, and expand the +/// the conversation preview last. Users can focus the toolbar controls with +/// Tab, change the focused control with the arrow keys, and expand the /// selected session with Ctrl+E to load recent transcript context on demand. /// /// Sessions are loaded on-demand via cursor-based pagination. The backend @@ -558,7 +578,9 @@ async fn run_session_picker_with_loader( } } Some(event) = background_events.next() => { - state.handle_background_event(event).await?; + if let Some(selection) = state.handle_background_event(event).await? { + return Ok(selection); + } } else => break, } @@ -631,16 +653,16 @@ fn spawn_app_server_page_loader( match request { PickerLoadRequest::Page(request) => { let cursor = request.cursor.map(|PageCursor::AppServer(cursor)| cursor); - let page = load_app_server_page( - &mut app_server, + let params = thread_list_params( cursor, request.cwd_filter.as_deref(), + request.status, request.provider_filter, request.sort_key, include_non_interactive, matches!(request.mode, PageLoadMode::StateDbOnly), - ) - .await; + ); + let page = load_app_server_page(&mut app_server, params).await; let _ = bg_tx.send(BackgroundEvent::Page { request_token: request.request_token, search_token: request.search_token, @@ -688,6 +710,25 @@ fn spawn_app_server_page_loader( .map_err(std::io::Error::other); let _ = bg_tx.send(BackgroundEvent::Archive { thread_id, result }); } + PickerLoadRequest::Unarchive { thread_id } => { + let result = archive_request_handle + .request_typed::(ClientRequest::ThreadUnarchive { + request_id: RequestId::String(format!( + "resume-picker-unarchive-{}", + Uuid::new_v4() + )), + params: ThreadUnarchiveParams { + thread_id: thread_id.to_string(), + }, + }) + .await + .map(|response| SessionTarget { + path: response.thread.path, + thread_id, + }) + .map_err(std::io::Error::other); + let _ = bg_tx.send(BackgroundEvent::Unarchive { thread_id, result }); + } } } if let Err(err) = app_server.shutdown().await { @@ -748,6 +789,7 @@ struct PickerState { view_width: Option, provider_filter: ProviderFilter, filter_mode: SessionFilterMode, + status: SessionStatus, filter_cwd: Option, local_filter_cwd: Option, toolbar_focus: ToolbarControl, @@ -810,22 +852,10 @@ enum LoadTrigger { async fn load_app_server_page( app_server: &mut AppServerSession, - cursor: Option, - cwd_filter: Option<&Path>, - provider_filter: ProviderFilter, - sort_key: ThreadSortKey, - include_non_interactive: bool, - use_state_db_only: bool, + params: ThreadListParams, ) -> std::io::Result { let response = app_server - .thread_list(thread_list_params( - cursor, - cwd_filter, - provider_filter, - sort_key, - include_non_interactive, - use_state_db_only, - )) + .thread_list(params) .await .map_err(std::io::Error::other)?; let num_scanned_files = response.data.len(); @@ -1086,6 +1116,7 @@ impl PickerState { view_width: None, provider_filter, filter_mode: SessionFilterMode::from_show_all(show_all, filter_cwd.as_deref()), + status: SessionStatus::Active, local_filter_cwd: filter_cwd.clone(), filter_cwd, toolbar_focus: ToolbarControl::Filter, @@ -1313,7 +1344,7 @@ impl PickerState { self.toggle_density().await; } _ if self.list_keymap.accept.is_pressed(key) - && matches!(self.archive_state, archive::ArchiveState::Pending { .. }) => {} + && !matches!(self.archive_state, archive::ArchiveState::Idle) => {} _ if self.list_keymap.accept.is_pressed(key) => { if let Some(row) = self.filtered_rows.get(self.selected) { let path = row.path.clone(); @@ -1328,6 +1359,10 @@ impl PickerState { }, }; if let Some(thread_id) = thread_id { + if self.status == SessionStatus::Archived { + self.request_unarchive(thread_id); + return Ok(None); + } return Ok(Some(self.action.selection(path, thread_id))); } self.inline_error = Some(match path { @@ -1489,12 +1524,16 @@ impl PickerState { search_token, mode, cwd_filter: self.active_cwd_filter(), + status: self.status, provider_filter: self.provider_filter.clone(), sort_key: self.sort_key, })); } - async fn handle_background_event(&mut self, event: BackgroundEvent) -> Result<()> { + async fn handle_background_event( + &mut self, + event: BackgroundEvent, + ) -> Result> { match event { BackgroundEvent::Page { request_token, @@ -1502,7 +1541,7 @@ impl PickerState { page, } => { let Some(pending) = self.pagination.finish_load(request_token) else { - return Ok(()); + return Ok(None); }; let page_has_rows = matches!(&page, Ok(page) if !page.rows.is_empty()); // Fall back only when the initial DB listing is unusable. Once SQLite returns @@ -1525,10 +1564,11 @@ impl PickerState { search_token, mode: PageLoadMode::StoreDefault, cwd_filter: self.active_cwd_filter(), + status: self.status, provider_filter: self.provider_filter.clone(), sort_key: self.sort_key, })); - return Ok(()); + return Ok(None); } let page = page.map_err(color_eyre::Report::from)?; self.ingest_page(page); @@ -1575,8 +1615,11 @@ impl PickerState { BackgroundEvent::Archive { thread_id, result } => { self.handle_archive_result(thread_id, result); } + BackgroundEvent::Unarchive { thread_id, result } => { + return Ok(self.handle_unarchive_result(thread_id, result)); + } } - Ok(()) + Ok(None) } fn reset_pagination(&mut self) { @@ -1811,6 +1854,7 @@ impl PickerState { search_token, mode, cwd_filter: self.active_cwd_filter(), + status: self.status, provider_filter: self.provider_filter.clone(), sort_key: self.sort_key, })); @@ -1857,6 +1901,14 @@ impl PickerState { self.start_initial_load(); } + fn toggle_status(&mut self) { + self.status = match self.status { + SessionStatus::Active => SessionStatus::Archived, + SessionStatus::Archived => SessionStatus::Active, + }; + self.start_initial_load(); + } + fn active_cwd_filter(&self) -> Option { match self.filter_mode { SessionFilterMode::Cwd => self.filter_cwd.clone(), @@ -1865,17 +1917,18 @@ impl PickerState { } fn focus_previous_toolbar_control(&mut self) { - self.toolbar_focus = self.toolbar_focus.previous(); + self.toolbar_focus = self.toolbar_focus.previous(self.action); } fn focus_next_toolbar_control(&mut self) { - self.toolbar_focus = self.toolbar_focus.next(); + self.toolbar_focus = self.toolbar_focus.next(self.action); } fn change_focused_toolbar_value(&mut self) { match self.toolbar_focus { ToolbarControl::Sort => self.toggle_sort_key(), ToolbarControl::Filter => self.toggle_filter_mode(), + ToolbarControl::Status => self.toggle_status(), } } @@ -2035,6 +2088,7 @@ fn row_from_app_server_thread(thread: Thread) -> Option { fn thread_list_params( cursor: Option, cwd_filter: Option<&Path>, + status: SessionStatus, provider_filter: ProviderFilter, sort_key: ThreadSortKey, include_non_interactive: bool, @@ -2050,7 +2104,7 @@ fn thread_list_params( ProviderFilter::MatchDefault(default_provider) => Some(vec![default_provider]), }, source_kinds: Some(crate::resume_source_kinds(include_non_interactive)), - archived: Some(false), + archived: Some(status == SessionStatus::Archived), section_id: None, parent_thread_id: None, ancestor_thread_id: None, @@ -2135,11 +2189,11 @@ fn search_line(state: &PickerState, width: u16) -> Line<'_> { } else { format!("Search: {}", state.query).into() }; + let search_width = UnicodeWidthStr::width(search.content.as_ref()); let mut toolbar = toolbar_line(state, /*compact*/ false); - if toolbar.width() as u16 > width.saturating_sub(2) { + if search_width.saturating_add(toolbar.width()) > usize::from(width.saturating_sub(2)) { toolbar = toolbar_line(state, /*compact*/ true); } - let search_width = UnicodeWidthStr::width(search.content.as_ref()); let toolbar_width = toolbar.width(); let spacer_width = width .saturating_sub((search_width + toolbar_width) as u16) @@ -2165,8 +2219,40 @@ fn search_line(state: &PickerState, width: u16) -> Line<'_> { fn toolbar_line(state: &PickerState, compact: bool) -> Line<'static> { let mut spans = Vec::new(); + let separator = if compact && matches!(state.action, SessionPickerAction::Resume) { + " " + } else { + " " + }; spans.extend(filter_control_spans(state, compact)); - spans.push(" ".dim()); + spans.push(separator.dim()); + if matches!(state.action, SessionPickerAction::Resume) { + let status_focused = state.toolbar_focus == ToolbarControl::Status; + if compact { + let active_status = match state.status { + SessionStatus::Active => "Active", + SessionStatus::Archived => "Archived", + }; + spans.push(toolbar_value( + active_status, + /*active*/ true, + status_focused, + )); + } else { + spans.push("Status: ".dim()); + spans.push(toolbar_value( + "Active", + state.status == SessionStatus::Active, + status_focused, + )); + spans.push(toolbar_value( + "Archived", + state.status == SessionStatus::Archived, + status_focused, + )); + } + spans.push(separator.dim()); + } spans.extend(sort_control_spans(state, compact)); spans.into() } @@ -2389,7 +2475,11 @@ fn footer_hint_lines(state: &PickerState, width: u16) -> Vec> { return vec![line, Line::default()]; } - let action_label = state.action.action_label(); + let action_label = if state.status == SessionStatus::Archived { + "restore" + } else { + state.action.action_label() + }; let (esc_label, esc_compact_label) = if state.query.is_empty() { match state.launch_context { SessionPickerLaunchContext::Startup => ("start new", "new"), @@ -3609,6 +3699,7 @@ mod tests { let params = thread_list_params( Some(String::from("cursor-1")), cwd_filter.as_deref(), + SessionStatus::Active, ProviderFilter::MatchDefault(String::from("openai")), ThreadSortKey::UpdatedAt, /*include_non_interactive*/ false, @@ -3987,6 +4078,7 @@ mod tests { let params = thread_list_params( Some(String::from("cursor-1")), Some(Path::new("repo/on/server")), + SessionStatus::Active, ProviderFilter::Any, ThreadSortKey::UpdatedAt, /*include_non_interactive*/ false, @@ -4010,6 +4102,7 @@ mod tests { let params = thread_list_params( Some(String::from("cursor-1")), /*cwd_filter*/ None, + SessionStatus::Active, ProviderFilter::Any, ThreadSortKey::UpdatedAt, /*include_non_interactive*/ true, @@ -5180,6 +5273,7 @@ session_picker_view = "dense" let line = search_line(&state, /*width*/ 40).to_string(); assert!(line.contains("Filter:[Cwd]")); + assert!(line.contains("[Active]")); assert!(line.contains("Sort:[Updated]")); assert!(line.find("Filter:[Cwd]") < line.find("Sort:[Updated]")); } @@ -5853,6 +5947,10 @@ session_picker_view = "dense" assert_eq!(guard[0].sort_key, ThreadSortKey::UpdatedAt); } + state + .handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)) + .await + .unwrap(); state .handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)) .await @@ -5936,7 +6034,7 @@ session_picker_view = "dense" } #[tokio::test] - async fn filter_stays_all_when_no_cwd_candidate_exists() { + async fn status_changes_when_directory_filter_is_unavailable() { let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); let request_sink = recorded_requests.clone(); let loader = page_only_loader(move |req: PageLoadRequest| { @@ -5965,10 +6063,21 @@ session_picker_view = "dense" .handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)) .await .unwrap(); + state + .handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)) + .await + .unwrap(); + state + .handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)) + .await + .unwrap(); let guard = recorded_requests.lock().unwrap(); - assert_eq!(guard.len(), 1); + assert_eq!(guard.len(), 2); + assert_eq!(guard[0].status, SessionStatus::Active); assert_eq!(guard[0].cwd_filter, None); + assert_eq!(guard[1].status, SessionStatus::Archived); + assert_eq!(guard[1].cwd_filter, None); } #[tokio::test] diff --git a/codex-rs/tui/src/resume_picker/archive.rs b/codex-rs/tui/src/resume_picker/archive.rs index a44d5c164b..41f0659290 100644 --- a/codex-rs/tui/src/resume_picker/archive.rs +++ b/codex-rs/tui/src/resume_picker/archive.rs @@ -10,6 +10,9 @@ use super::Row; use super::SearchState; use super::SessionPickerAction; use super::SessionPickerLaunchContext; +use super::SessionSelection; +use super::SessionStatus; +use super::SessionTarget; use crate::keymap::KeymapContext; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] @@ -19,11 +22,16 @@ pub(super) enum ArchiveState { Pending { thread_id: ThreadId, }, + Restoring { + thread_id: ThreadId, + }, } impl PickerState { pub(super) fn archive_shortcut_available(&self) -> bool { - if !matches!(self.action, SessionPickerAction::Resume) { + if !matches!(self.action, SessionPickerAction::Resume) + || self.status == SessionStatus::Archived + { return false; } @@ -127,6 +135,36 @@ impl PickerState { self.load_more_if_needed(LoadTrigger::Search { token }); } } + + pub(super) fn request_unarchive(&mut self, thread_id: ThreadId) { + if !matches!(self.archive_state, ArchiveState::Idle) { + return; + } + + self.archive_state = ArchiveState::Restoring { thread_id }; + self.request_frame(); + (self.picker_loader)(PickerLoadRequest::Unarchive { thread_id }); + } + + pub(super) fn handle_unarchive_result( + &mut self, + thread_id: ThreadId, + result: std::io::Result, + ) -> Option { + if self.archive_state != (ArchiveState::Restoring { thread_id }) { + return None; + } + self.archive_state = ArchiveState::Idle; + + match result { + Ok(target) => Some(SessionSelection::Resume(target)), + Err(error) => { + self.inline_error = Some(format!("Failed to restore archived session: {error}")); + self.request_frame(); + None + } + } + } } #[cfg(test)] diff --git a/codex-rs/tui/src/resume_picker/archive_tests.rs b/codex-rs/tui/src/resume_picker/archive_tests.rs index c379115e5d..51f0d61c11 100644 --- a/codex-rs/tui/src/resume_picker/archive_tests.rs +++ b/codex-rs/tui/src/resume_picker/archive_tests.rs @@ -5,16 +5,21 @@ use super::super::ProviderFilter; use super::super::Row; use super::super::SessionPickerAction; use super::super::SessionPickerLaunchContext; +use super::super::SessionSelection; +use super::super::SessionStatus; +use super::super::SessionTarget; 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_app_server_protocol::ThreadSortKey; use codex_protocol::ThreadId; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; use pretty_assertions::assert_eq; +use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; @@ -22,7 +27,9 @@ 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 { + if let PickerLoadRequest::Archive { thread_id } + | PickerLoadRequest::Unarchive { thread_id } = request + { request_sink .lock() .expect("archive request sink") @@ -155,3 +162,133 @@ fn archive_footer_shows_shortcut_for_resume_sessions() { ctrl+o dense view ctrl+t transcript ctrl+e expand ↑/↓ browse "); } + +#[test] +fn archived_status_preserves_directory_filter_and_hides_archive_shortcut() { + let requested_filters = Arc::new(Mutex::new(Vec::new())); + let request_sink = Arc::clone(&requested_filters); + let picker_loader: PickerLoader = Arc::new(move |request| { + if let PickerLoadRequest::Page(request) = request { + request_sink + .lock() + .expect("page request sink") + .push((request.status, request.cwd_filter)); + } + }); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + picker_loader, + ProviderFilter::Any, + /*show_all*/ false, + Some(PathBuf::from("/tmp/project")), + SessionPickerAction::Resume, + ); + + state.toggle_status(); + + assert_eq!(state.status, SessionStatus::Archived); + let params = super::super::thread_list_params( + /*cursor*/ None, + Some(std::path::Path::new("/tmp/project")), + SessionStatus::Archived, + ProviderFilter::Any, + ThreadSortKey::UpdatedAt, + /*include_non_interactive*/ false, + /*use_state_db_only*/ false, + ); + assert_eq!(params.archived, Some(true)); + + set_selected_session(&mut state, ThreadId::new()); + assert!(!state.archive_shortcut_available()); + 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 restore esc start new ctrl+c quit tab focus sort/filter ←/→ change option + ctrl+o dense view ctrl+t transcript ctrl+e expand ↑/↓ browse + "); + insta::assert_snapshot!( + super::super::toolbar_line(&state, /*compact*/ true).to_string(), + @"Filter:[Cwd] [Archived] Sort:[Updated]" + ); + + state.toggle_filter_mode(); + + assert_eq!( + *requested_filters.lock().unwrap(), + vec![ + (SessionStatus::Archived, Some(PathBuf::from("/tmp/project")),), + (SessionStatus::Archived, None), + ] + ); +} + +#[tokio::test] +async fn archived_session_restore_resumes_after_completion() { + let (mut state, requests) = archive_picker_state(); + let thread_id = ThreadId::new(); + state.status = SessionStatus::Archived; + set_selected_session(&mut state, thread_id); + + assert!( + state + .handle_key(KeyEvent::from(KeyCode::Enter)) + .await + .unwrap() + .is_none() + ); + assert_eq!(state.archive_state, ArchiveState::Restoring { thread_id }); + assert_eq!(*requests.lock().unwrap(), vec![thread_id]); + + assert!( + state + .handle_key(KeyEvent::from(KeyCode::Enter)) + .await + .unwrap() + .is_none() + ); + assert_eq!(*requests.lock().unwrap(), vec![thread_id]); + + let selection = state.handle_unarchive_result( + thread_id, + Ok(SessionTarget { + path: None, + thread_id, + }), + ); + + assert!(matches!( + selection, + Some(SessionSelection::Resume(SessionTarget { + path: None, + thread_id: resumed_thread_id, + })) if resumed_thread_id == thread_id + )); + assert_eq!(state.archive_state, ArchiveState::Idle); +} + +#[test] +fn archived_session_restore_failure_preserves_session_and_allows_retry() { + let (mut state, requests) = archive_picker_state(); + let thread_id = ThreadId::new(); + state.status = SessionStatus::Archived; + set_selected_session(&mut state, thread_id); + state.request_unarchive(thread_id); + + let selection = + state.handle_unarchive_result(thread_id, Err(std::io::Error::other("restore denied"))); + + assert!(selection.is_none()); + assert_eq!(state.archive_state, ArchiveState::Idle); + assert_eq!( + state.inline_error.as_deref(), + Some("Failed to restore archived session: restore denied") + ); + assert_eq!(state.filtered_rows.len(), 1); + + state.request_unarchive(thread_id); + + assert_eq!(*requests.lock().unwrap(), vec![thread_id, thread_id]); +} diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_search_line_sort_filter_tabs.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_search_line_sort_filter_tabs.snap index f48b6543cd..7d9be2926d 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_search_line_sort_filter_tabs.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_search_line_sort_filter_tabs.snap @@ -2,4 +2,4 @@ source: tui/src/resume_picker.rs expression: terminal.backend().to_string() --- -Type to search Filter: [Cwd] All Sort: [Updated] Created +Type to search Filter: [Cwd] All Status: [Active] Archived Sort: [Updated] Created