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
This commit is contained in:
joshyin-oai
2026-08-07 03:48:15 +00:00
committed by copyberry
parent 6622546169
commit 1efd8fcb26
6 changed files with 403 additions and 7 deletions
+3
View File
@@ -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?
{
+38
View File
@@ -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;
+4 -2
View File
@@ -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)
{
+67 -5
View File
@@ -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<ThreadId> },
}
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<TranscriptCells>,
},
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<SessionSelection> {
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<ThreadId>,
) -> Result<SessionSelection> {
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<SessionSelection> {
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<SessionSelection> {
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<PathBuf>,
@@ -648,6 +672,22 @@ fn spawn_app_server_page_loader(
_ = cancellation => {}
}
}
PickerLoadRequest::Archive { thread_id } => {
let result = archive_request_handle
.request_typed::<ThreadArchiveResponse>(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<String>,
archive_state: archive::ArchiveState,
expanded_thread_id: Option<ThreadId>,
transcript_previews: HashMap<ThreadId, TranscriptPreviewState>,
transcript_cells: HashMap<ThreadId, SessionTranscriptState>,
@@ -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<Line<'static>> {
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<Line<'static>> {
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"));
+134
View File
@@ -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;
@@ -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<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 {
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::<Vec<_>>()
.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
");
}