mirror of
https://github.com/openai/codex.git
synced 2026-10-21 05:22:21 +00:00
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:
@@ -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]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user