mirror of
https://github.com/openai/codex.git
synced 2026-10-21 09:54:56 +00:00
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:
@@ -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?
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
|
||||
");
|
||||
}
|
||||
Reference in New Issue
Block a user