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
This commit is contained in:
joshyin-oai
2026-08-07 03:54:23 +00:00
committed by copyberry
parent 1efd8fcb26
commit 0bdce9f424
4 changed files with 325 additions and 41 deletions
+147 -38
View File
@@ -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<usize>,
mode: PageLoadMode,
cwd_filter: Option<PathBuf>,
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<SessionTarget>,
},
}
#[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::<ThreadUnarchiveResponse>(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<u16>,
provider_filter: ProviderFilter,
filter_mode: SessionFilterMode,
status: SessionStatus,
filter_cwd: Option<PathBuf>,
local_filter_cwd: Option<PathBuf>,
toolbar_focus: ToolbarControl,
@@ -810,22 +852,10 @@ enum LoadTrigger {
async fn load_app_server_page(
app_server: &mut AppServerSession,
cursor: Option<String>,
cwd_filter: Option<&Path>,
provider_filter: ProviderFilter,
sort_key: ThreadSortKey,
include_non_interactive: bool,
use_state_db_only: bool,
params: ThreadListParams,
) -> std::io::Result<PickerPage> {
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<Option<SessionSelection>> {
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<PathBuf> {
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<Row> {
fn thread_list_params(
cursor: Option<String>,
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<Line<'static>> {
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<Mutex<Vec<PageLoadRequest>>> = 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]
+39 -1
View File
@@ -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<SessionTarget>,
) -> Option<SessionSelection> {
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)]
+138 -1
View File
@@ -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<Mutex<Vec<ThreadId>>>) {
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::<Vec<_>>()
.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]);
}
@@ -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