mirror of
https://github.com/openai/codex.git
synced 2026-11-11 21:10:39 +00:00
Support archiving and restoring cloud threads (#60635)
## What changed - Add cloud archive and unarchive requests and support listing archived threads. - Detach successfully archived threads and emit archive notifications. Report loaded cloud threads from live attachments rather than cached resume metadata, including after archive failures. - Scope resume picker archive state and transcript previews by backend and thread so archiving a cloud session preserves a local session with the same ID. - Limit cloud task archive cleanup to the selected thread, preserving child agents and their active session state, and update the confirmation text accordingly. ## Testing Add regression coverage for archive acknowledgments, archived-thread discovery and restoration, attachment state after archive failures, same-ID sessions across backends, and preservation of attached child agents. GitOrigin-RevId: bd155be85ee911375d4d326b18f9b5abe8e82b3d
This commit is contained in:
committed by
copyberry
parent
627b579219
commit
df82e2fe0a
@@ -1,4 +1,4 @@
|
||||
//! Server RPCs own lifecycle outcomes; successful actions invalidate dashboard group caches.
|
||||
//! Server RPCs own lifecycle outcomes; cache cleanup follows the backend's removal scope.
|
||||
//! Keep the dashboard browsable during RPCs while deferring task notifications.
|
||||
//! Removing the current root leaves an unattached dashboard, even when it is empty.
|
||||
|
||||
@@ -202,6 +202,15 @@ impl App {
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or("Untitled task");
|
||||
let (title, description, label) = match action {
|
||||
AgentsOverviewAction::Archive
|
||||
if self.app_server_target == crate::AppServerTarget::Cloud =>
|
||||
{
|
||||
(
|
||||
format!("Archive “{name}”?"),
|
||||
"This stops any running work in this task and archives it. Child agents stay unchanged. Its history can be restored from the resume picker.",
|
||||
"Archive task",
|
||||
)
|
||||
}
|
||||
AgentsOverviewAction::Archive => (
|
||||
format!("Archive “{name}”?"),
|
||||
"This stops any running work in this task and its child agents, then archives them. Their history can be restored from the resume picker.",
|
||||
@@ -278,6 +287,10 @@ impl App {
|
||||
if self.windows_sandbox_blocks_thread_switch() {
|
||||
return Ok(());
|
||||
}
|
||||
// Cloud archives only the selected thread; Local lifecycle actions include descendants.
|
||||
let removes_subtree = action != AgentsOverviewAction::Archive
|
||||
|| app_server.thread_params_mode()
|
||||
!= crate::app_server_session::ThreadParamsMode::Cloud;
|
||||
// The overview may lack intermediate ancestors, or even the primary's metadata.
|
||||
let mut removes_primary = self.primary_thread_id == Some(thread_id);
|
||||
let mut attempted = false;
|
||||
@@ -296,13 +309,17 @@ impl App {
|
||||
state.rename_target = None;
|
||||
}
|
||||
self.repaint_agents_overview();
|
||||
let voice_result = self
|
||||
.stop_voice_for_removed_thread(app_server, thread_id)
|
||||
.await;
|
||||
let voice_result = if removes_subtree || self.voice_owner_thread_id() == Some(thread_id) {
|
||||
self.stop_voice_for_removed_thread(app_server, thread_id)
|
||||
.await
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
let operation = async {
|
||||
let result = async {
|
||||
voice_result?;
|
||||
if let Some(primary) = primary_thread_id
|
||||
if removes_subtree
|
||||
&& let Some(primary) = primary_thread_id
|
||||
&& primary != thread_id
|
||||
{
|
||||
removes_primary = app_server
|
||||
@@ -369,8 +386,11 @@ impl App {
|
||||
self.agents_overview.refresh_notifications.clear();
|
||||
}
|
||||
if result.is_ok() {
|
||||
// Invalidate cached details for the displayed group, without inferring child outcomes.
|
||||
let mut removed = self.known_thread_subtree(thread_id);
|
||||
let mut removed = if removes_subtree {
|
||||
self.known_thread_subtree(thread_id)
|
||||
} else {
|
||||
HashSet::from([thread_id])
|
||||
};
|
||||
if let Some(primary) = self.primary_thread_id
|
||||
&& removes_primary
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use crate::app::agents_overview_discovery::list_pinned_threads;
|
||||
use crate::app::tests::test_thread_session;
|
||||
use crate::app_event::AgentsOverviewAction;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
@@ -66,6 +67,120 @@ async fn pin_action_updates_shared_section_state() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cloud_archive_preserves_attached_child_and_other_tasks() -> Result<()> {
|
||||
use codex_app_server_client::AppServerClient;
|
||||
use codex_app_server_client::CloudClient;
|
||||
use codex_app_server_client::CloudOperation;
|
||||
use serde_json::json;
|
||||
|
||||
let mut app = make_test_app().await;
|
||||
app.app_server_target = AppServerTarget::Cloud;
|
||||
let parent = ThreadId::new();
|
||||
let child = ThreadId::new();
|
||||
let other = ThreadId::new();
|
||||
let threads = HashMap::from([
|
||||
(
|
||||
parent,
|
||||
Some(overview_thread(
|
||||
parent,
|
||||
/*parent_thread_id*/ None,
|
||||
"Cloud parent",
|
||||
ThreadStatus::Idle,
|
||||
)),
|
||||
),
|
||||
(
|
||||
child,
|
||||
Some(overview_thread(
|
||||
child,
|
||||
Some(parent),
|
||||
"Cloud child",
|
||||
ThreadStatus::Active {
|
||||
active_flags: Vec::new(),
|
||||
},
|
||||
)),
|
||||
),
|
||||
(
|
||||
other,
|
||||
Some(overview_thread(
|
||||
other,
|
||||
/*parent_thread_id*/ None,
|
||||
"Other task",
|
||||
ThreadStatus::Idle,
|
||||
)),
|
||||
),
|
||||
]);
|
||||
app.agents_overview.threads = threads.clone();
|
||||
app.primary_thread_id = Some(child);
|
||||
app.active_thread_id = Some(child);
|
||||
app.chat_widget
|
||||
.handle_thread_session(test_thread_session(child, app.config.cwd.to_path_buf()));
|
||||
app.chat_widget
|
||||
.apply_external_edit("Keep child draft".into());
|
||||
let draft = app.chat_widget.capture_thread_input_state();
|
||||
crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, child);
|
||||
|
||||
app.confirm_agents_overview_action(parent, AgentsOverviewAction::Archive);
|
||||
insta::assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 80), @"
|
||||
Archive “Cloud parent”?
|
||||
› Ke This stops any running work in this task and archives it. Child
|
||||
agents stay unchanged. Its history can be restored from the resume
|
||||
gp picker.
|
||||
|
||||
|
||||
› 1. Cancel
|
||||
2. Archive task
|
||||
|
||||
enter select · esc back
|
||||
");
|
||||
app.chat_widget.handle_key_event(KeyCode::Esc.into());
|
||||
|
||||
let (client, mut commands, _events) = CloudClient::channel(/*capacity*/ 1);
|
||||
let mut session = AppServerSession::new(
|
||||
crate::backend_id::BackendId::new(),
|
||||
AppServerClient::Cloud(client),
|
||||
crate::app_server_session::ThreadParamsMode::Cloud,
|
||||
);
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
tui.pause_events();
|
||||
let (result, ()) = tokio::join!(
|
||||
Box::pin(app.run_agents_overview_action(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
parent,
|
||||
AgentsOverviewAction::Archive
|
||||
)),
|
||||
async {
|
||||
let command = commands.recv().await.expect("archive request");
|
||||
let CloudOperation::Request(request) = command.operation else {
|
||||
panic!("expected archive request");
|
||||
};
|
||||
assert!(
|
||||
matches!(*request, ClientRequest::ThreadArchive { params, .. } if params.thread_id == parent.to_string())
|
||||
);
|
||||
command.response.send(Ok(Ok(json!({})))).unwrap();
|
||||
},
|
||||
);
|
||||
result?;
|
||||
let mut expected = threads;
|
||||
expected.remove(&parent);
|
||||
assert_eq!(app.agents_overview.threads, expected);
|
||||
assert_eq!(
|
||||
(
|
||||
app.primary_thread_id,
|
||||
app.current_displayed_thread_id(),
|
||||
app.chat_widget.thread_id()
|
||||
),
|
||||
(Some(child), Some(child), Some(child))
|
||||
);
|
||||
assert_eq!(app.chat_widget.capture_thread_input_state(), draft);
|
||||
assert_eq!(app.voice_owner_thread_id(), Some(child));
|
||||
if let Some(refresh) = app.agents_overview.refresh_task.take() {
|
||||
refresh.abort();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn archive_confirmation_number_keys_act_immediately() {
|
||||
for key in ['1', '2'] {
|
||||
|
||||
@@ -158,7 +158,9 @@ pub enum SessionPickerAction {
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum SessionPickerLaunchContext {
|
||||
Startup,
|
||||
ExistingSession { current_thread_id: Option<ThreadId> },
|
||||
ExistingSession {
|
||||
current_thread_id: Option<(BackendId, ThreadId)>,
|
||||
},
|
||||
}
|
||||
|
||||
impl SessionPickerAction {
|
||||
@@ -365,6 +367,7 @@ enum BackgroundEvent {
|
||||
page: std::io::Result<PickerPage>,
|
||||
},
|
||||
Preview {
|
||||
backend_id: BackendId,
|
||||
thread_id: ThreadId,
|
||||
preview: std::io::Result<Vec<TranscriptPreviewLine>>,
|
||||
},
|
||||
@@ -374,6 +377,7 @@ enum BackgroundEvent {
|
||||
transcript: std::io::Result<LoadedTranscript>,
|
||||
},
|
||||
Archive {
|
||||
backend_id: BackendId,
|
||||
thread_id: ThreadId,
|
||||
result: std::io::Result<()>,
|
||||
},
|
||||
@@ -580,7 +584,9 @@ pub(crate) async fn run_resume_picker_from_existing_session_with_app_servers(
|
||||
show_all,
|
||||
backends,
|
||||
displayed_owner,
|
||||
SessionPickerLaunchContext::ExistingSession { current_thread_id },
|
||||
SessionPickerLaunchContext::ExistingSession {
|
||||
current_thread_id: current_thread_id.map(|id| (displayed_owner, id)),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -1003,7 +1009,11 @@ fn spawn_app_server_source(backend: AppServerPickerBackend) -> sources::Source {
|
||||
preview = load_transcript_preview(&mut app_server, thread_id, config.as_ref()) => preview,
|
||||
_ = bg_tx.closed() => continue,
|
||||
};
|
||||
let _ = bg_tx.send(BackgroundEvent::Preview { thread_id, preview });
|
||||
let _ = bg_tx.send(BackgroundEvent::Preview {
|
||||
backend_id,
|
||||
thread_id,
|
||||
preview,
|
||||
});
|
||||
}
|
||||
PickerLoadRequest::Transcript {
|
||||
thread_id,
|
||||
@@ -1042,7 +1052,11 @@ fn spawn_app_server_source(backend: AppServerPickerBackend) -> sources::Source {
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(std::io::Error::other);
|
||||
let _ = bg_tx.send(BackgroundEvent::Archive { thread_id, result });
|
||||
let _ = bg_tx.send(BackgroundEvent::Archive {
|
||||
backend_id,
|
||||
thread_id,
|
||||
result,
|
||||
});
|
||||
}
|
||||
PickerLoadRequest::Unarchive { thread_id, .. } => {
|
||||
let result = archive_request_handle
|
||||
@@ -1152,8 +1166,8 @@ struct PickerState {
|
||||
sort_key: ThreadSortKey,
|
||||
inline_error: Option<String>,
|
||||
archive_state: archive::ArchiveState,
|
||||
expanded_thread_id: Option<ThreadId>,
|
||||
transcript_previews: HashMap<ThreadId, TranscriptPreviewState>,
|
||||
expanded_thread_id: Option<(BackendId, ThreadId)>,
|
||||
transcript_previews: HashMap<(BackendId, ThreadId), TranscriptPreviewState>,
|
||||
transcript_cells: HashMap<(BackendId, ThreadId), SessionTranscriptState>,
|
||||
pending_transcript_open: Option<(BackendId, ThreadId)>,
|
||||
unread: HashMap<(BackendId, ThreadId), String>,
|
||||
@@ -1903,9 +1917,20 @@ impl PickerState {
|
||||
let completed_token = self.search_state.active_token();
|
||||
self.continue_search_if_token_matches(completed_token);
|
||||
}
|
||||
BackgroundEvent::Preview { thread_id, preview } => {
|
||||
BackgroundEvent::Preview {
|
||||
backend_id,
|
||||
thread_id,
|
||||
preview,
|
||||
} => {
|
||||
// Archiving removes the owned cache entry; ignore its late preview.
|
||||
if !matches!(
|
||||
self.transcript_previews.get(&(backend_id, thread_id)),
|
||||
Some(TranscriptPreviewState::Loading)
|
||||
) {
|
||||
return Ok(None);
|
||||
}
|
||||
self.transcript_previews.insert(
|
||||
thread_id,
|
||||
(backend_id, thread_id),
|
||||
match preview {
|
||||
Ok(lines) => TranscriptPreviewState::Loaded(lines),
|
||||
Err(_) => TranscriptPreviewState::Failed,
|
||||
@@ -1954,8 +1979,12 @@ impl PickerState {
|
||||
}
|
||||
self.request_frame();
|
||||
}
|
||||
BackgroundEvent::Archive { thread_id, result } => {
|
||||
self.handle_archive_result(thread_id, result);
|
||||
BackgroundEvent::Archive {
|
||||
backend_id,
|
||||
thread_id,
|
||||
result,
|
||||
} => {
|
||||
self.handle_archive_result(backend_id, thread_id, result);
|
||||
}
|
||||
BackgroundEvent::Unarchive { thread_id, result } => {
|
||||
return Ok(self.handle_unarchive_result(thread_id, result));
|
||||
@@ -2378,14 +2407,14 @@ impl PickerState {
|
||||
return;
|
||||
};
|
||||
let backend_id = row.backend_id.unwrap_or(self.backend_id);
|
||||
if self.expanded_thread_id == Some(thread_id) {
|
||||
if self.expanded_thread_id == Some((backend_id, thread_id)) {
|
||||
self.expanded_thread_id = None;
|
||||
self.request_frame();
|
||||
return;
|
||||
}
|
||||
self.expanded_thread_id = Some(thread_id);
|
||||
self.expanded_thread_id = Some((backend_id, thread_id));
|
||||
if let std::collections::hash_map::Entry::Vacant(e) =
|
||||
self.transcript_previews.entry(thread_id)
|
||||
self.transcript_previews.entry((backend_id, thread_id))
|
||||
{
|
||||
e.insert(TranscriptPreviewState::Loading);
|
||||
(self.picker_loader)(PickerLoadRequest::Preview {
|
||||
@@ -2407,7 +2436,10 @@ impl PickerState {
|
||||
let is_selected = row_idx == self.selected;
|
||||
let is_expanded = is_selected
|
||||
&& row.thread_id.is_some()
|
||||
&& self.expanded_thread_id == row.thread_id;
|
||||
&& self.expanded_thread_id
|
||||
== row
|
||||
.thread_id
|
||||
.map(|id| (row.backend_id.unwrap_or(self.backend_id), id));
|
||||
render_session_lines(
|
||||
row,
|
||||
self,
|
||||
@@ -2438,8 +2470,12 @@ impl PickerState {
|
||||
for (offset, row) in self.filtered_rows[self.scroll_top..].iter().enumerate() {
|
||||
let row_idx = self.scroll_top + offset;
|
||||
let is_selected = row_idx == self.selected;
|
||||
let is_expanded =
|
||||
is_selected && row.thread_id.is_some() && self.expanded_thread_id == row.thread_id;
|
||||
let is_expanded = is_selected
|
||||
&& row.thread_id.is_some()
|
||||
&& self.expanded_thread_id
|
||||
== row
|
||||
.thread_id
|
||||
.map(|id| (row.backend_id.unwrap_or(self.backend_id), id));
|
||||
let row_height = render_session_lines(
|
||||
row,
|
||||
self,
|
||||
@@ -3216,8 +3252,12 @@ fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &Pi
|
||||
}
|
||||
let row_idx = start + idx;
|
||||
let is_selected = row_idx == state.selected;
|
||||
let is_expanded =
|
||||
is_selected && row.thread_id.is_some() && state.expanded_thread_id == row.thread_id;
|
||||
let is_expanded = is_selected
|
||||
&& row.thread_id.is_some()
|
||||
&& state.expanded_thread_id
|
||||
== row
|
||||
.thread_id
|
||||
.map(|id| (row.backend_id.unwrap_or(state.backend_id), id));
|
||||
let is_zebra = row_idx.is_multiple_of(2);
|
||||
for line in render_session_lines(row, state, is_selected, is_expanded, is_zebra, area.width)
|
||||
{
|
||||
@@ -3746,7 +3786,10 @@ fn render_transcript_preview_lines(
|
||||
let Some(thread_id) = row.thread_id else {
|
||||
return details;
|
||||
};
|
||||
let preview_lines = match state.transcript_previews.get(&thread_id) {
|
||||
let preview_lines = match state
|
||||
.transcript_previews
|
||||
.get(&(row.backend_id.unwrap_or(state.backend_id), thread_id))
|
||||
{
|
||||
Some(TranscriptPreviewState::Loading) => {
|
||||
vec![
|
||||
vec![
|
||||
@@ -6140,7 +6183,10 @@ session_picker_view = "dense"
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(state.expanded_thread_id, Some(thread_id));
|
||||
assert_eq!(
|
||||
state.expanded_thread_id,
|
||||
Some((state.backend_id, thread_id))
|
||||
);
|
||||
assert_eq!(*recorded_requests.lock().unwrap(), vec![thread_id]);
|
||||
|
||||
state
|
||||
@@ -6182,7 +6228,10 @@ session_picker_view = "dense"
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(state.expanded_thread_id, Some(thread_id));
|
||||
assert_eq!(
|
||||
state.expanded_thread_id,
|
||||
Some((state.backend_id, thread_id))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -6508,9 +6557,9 @@ session_picker_view = "dense"
|
||||
state.filtered_rows = vec![row];
|
||||
state.relative_time_reference =
|
||||
Some(parse_timestamp_str("2026-04-28T18:00:00Z").expect("timestamp"));
|
||||
state.expanded_thread_id = Some(thread_id);
|
||||
state.expanded_thread_id = Some((state.backend_id, thread_id));
|
||||
state.transcript_previews.insert(
|
||||
thread_id,
|
||||
(state.backend_id, thread_id),
|
||||
TranscriptPreviewState::Loaded(vec![
|
||||
TranscriptPreviewLine {
|
||||
speaker: TranscriptPreviewSpeaker::User,
|
||||
|
||||
@@ -21,6 +21,7 @@ pub(super) enum ArchiveState {
|
||||
#[default]
|
||||
Idle,
|
||||
Pending {
|
||||
backend_id: BackendId,
|
||||
thread_id: ThreadId,
|
||||
},
|
||||
Restoring {
|
||||
@@ -58,11 +59,13 @@ impl PickerState {
|
||||
return;
|
||||
};
|
||||
|
||||
let backend_id = row.backend_id.unwrap_or(self.backend_id);
|
||||
let key = (backend_id, thread_id);
|
||||
if matches!(
|
||||
self.launch_context,
|
||||
SessionPickerLaunchContext::ExistingSession {
|
||||
current_thread_id: Some(current_thread_id)
|
||||
} if current_thread_id == thread_id
|
||||
} if current_thread_id == key
|
||||
) {
|
||||
self.inline_error = Some(String::from(
|
||||
"Use /archive to archive the current session and exit.",
|
||||
@@ -71,8 +74,10 @@ impl PickerState {
|
||||
return;
|
||||
}
|
||||
|
||||
let backend_id = row.backend_id.unwrap_or(self.backend_id);
|
||||
self.archive_state = ArchiveState::Pending { thread_id };
|
||||
self.archive_state = ArchiveState::Pending {
|
||||
backend_id,
|
||||
thread_id,
|
||||
};
|
||||
self.request_frame();
|
||||
(self.picker_loader)(PickerLoadRequest::Archive {
|
||||
backend_id,
|
||||
@@ -82,12 +87,24 @@ impl PickerState {
|
||||
|
||||
pub(super) fn handle_archive_result(
|
||||
&mut self,
|
||||
backend_id: BackendId,
|
||||
thread_id: ThreadId,
|
||||
result: std::io::Result<()>,
|
||||
) {
|
||||
if self.archive_state != (ArchiveState::Pending { thread_id }) {
|
||||
if self.archive_state
|
||||
!= (ArchiveState::Pending {
|
||||
backend_id,
|
||||
thread_id,
|
||||
})
|
||||
{
|
||||
return;
|
||||
}
|
||||
let key = (backend_id, thread_id);
|
||||
let default_backend = self.backend_id;
|
||||
let is_archived = |row: &Row| {
|
||||
(row.backend_id.unwrap_or(default_backend), row.thread_id)
|
||||
== (backend_id, Some(thread_id))
|
||||
};
|
||||
self.chord_matcher.cancel();
|
||||
self.archive_state = ArchiveState::Idle;
|
||||
|
||||
@@ -100,21 +117,18 @@ impl PickerState {
|
||||
let selected_key = self
|
||||
.filtered_rows
|
||||
.get(self.selected)
|
||||
.filter(|row| row.thread_id != Some(thread_id))
|
||||
.and_then(Row::seen_key);
|
||||
.filter(|row| !is_archived(row))
|
||||
.map(|row| (row.backend_id.unwrap_or(default_backend), row.seen_key()));
|
||||
|
||||
self.all_rows.retain(|row| row.thread_id != Some(thread_id));
|
||||
self.all_rows.retain(|row| !is_archived(row));
|
||||
// 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.retain(|(_, id), _| *id != thread_id);
|
||||
if self.expanded_thread_id == Some(thread_id) {
|
||||
self.transcript_previews.remove(&key);
|
||||
if self.expanded_thread_id == Some(key) {
|
||||
self.expanded_thread_id = None;
|
||||
}
|
||||
if self
|
||||
.pending_transcript_open
|
||||
.is_some_and(|(_, id)| id == thread_id)
|
||||
{
|
||||
self.transcript_cells.remove(&key);
|
||||
if self.pending_transcript_open == Some(key) {
|
||||
self.pending_transcript_open = None;
|
||||
if let Some(cancellation) = self.pending_transcript_cancellation.take() {
|
||||
let _ = cancellation.send(());
|
||||
@@ -125,10 +139,9 @@ impl PickerState {
|
||||
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))
|
||||
&& let Some(selected) = self.filtered_rows.iter().position(|row| {
|
||||
(row.backend_id.unwrap_or(default_backend), row.seen_key()) == selected_key
|
||||
})
|
||||
{
|
||||
self.selected = selected;
|
||||
self.ensure_selected_visible();
|
||||
|
||||
@@ -72,12 +72,19 @@ async fn archive_shortcut_archives_selected_session_once() {
|
||||
let backend_id = crate::backend_id::BackendId::new();
|
||||
let thread_id = ThreadId::new();
|
||||
set_selected_session(&mut state, thread_id);
|
||||
state.filtered_rows[0].backend_id = Some(backend_id);
|
||||
state.all_rows[0].backend_id = Some(backend_id);
|
||||
state.apply_filter();
|
||||
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!(
|
||||
state.archive_state,
|
||||
ArchiveState::Pending {
|
||||
backend_id,
|
||||
thread_id
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
matches!(actions.recv().await, Some(PickerLoadRequest::Archive { backend_id: owner, thread_id: archived }) if (owner, archived) == (backend_id, thread_id))
|
||||
);
|
||||
@@ -89,14 +96,15 @@ async fn archive_shortcut_archives_selected_session_once() {
|
||||
.is_none()
|
||||
);
|
||||
|
||||
state.handle_archive_result(thread_id, Ok(()));
|
||||
state.handle_archive_result(backend_id, thread_id, Ok(()));
|
||||
|
||||
assert_eq!(state.archive_state, ArchiveState::Idle);
|
||||
assert!(state.filtered_rows.is_empty());
|
||||
|
||||
state.status = SessionStatus::Archived;
|
||||
set_selected_session(&mut state, thread_id);
|
||||
state.filtered_rows[0].backend_id = Some(backend_id);
|
||||
state.all_rows[0].backend_id = Some(backend_id);
|
||||
state.apply_filter();
|
||||
state
|
||||
.handle_key(KeyEvent::from(KeyCode::Enter))
|
||||
.await
|
||||
@@ -111,7 +119,7 @@ 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),
|
||||
current_thread_id: Some((state.backend_id, thread_id)),
|
||||
};
|
||||
set_selected_session(&mut state, thread_id);
|
||||
|
||||
@@ -140,7 +148,11 @@ fn archive_failure_preserves_session_and_reports_server_error() {
|
||||
},
|
||||
};
|
||||
|
||||
state.handle_archive_result(thread_id, Err(std::io::Error::other(error)));
|
||||
state.handle_archive_result(
|
||||
state.backend_id,
|
||||
thread_id,
|
||||
Err(std::io::Error::other(error)),
|
||||
);
|
||||
|
||||
assert_eq!(state.archive_state, ArchiveState::Idle);
|
||||
assert_eq!(
|
||||
@@ -321,3 +333,120 @@ fn archived_session_restore_failure_preserves_session_and_allows_retry() {
|
||||
|
||||
assert_eq!(*requests.lock().unwrap(), vec![thread_id, thread_id]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn archive_cloud_preserves_same_id_current_local_row_and_transcript() {
|
||||
let (mut state, _requests) = archive_picker_state();
|
||||
let (requests, mut actions) = tokio::sync::mpsc::unbounded_channel();
|
||||
state.picker_loader = Arc::new(move |request| requests.send(request).unwrap());
|
||||
let thread_id = ThreadId::new();
|
||||
let local = state.backend_id;
|
||||
let cloud = crate::backend_id::BackendId::new();
|
||||
state.launch_context = SessionPickerLaunchContext::ExistingSession {
|
||||
current_thread_id: Some((local, thread_id)),
|
||||
};
|
||||
set_selected_session(&mut state, thread_id);
|
||||
let local_row = state.all_rows[0].clone();
|
||||
let mut cloud_row = local_row.clone();
|
||||
cloud_row.backend_id = Some(cloud);
|
||||
state.all_rows.push(cloud_row);
|
||||
state.apply_filter();
|
||||
state.selected = state
|
||||
.filtered_rows
|
||||
.iter()
|
||||
.position(|row| row.backend_id == Some(cloud))
|
||||
.unwrap();
|
||||
state.transcript_cells.insert(
|
||||
(local, thread_id),
|
||||
super::super::SessionTranscriptState::Failed,
|
||||
);
|
||||
state.transcript_cells.insert(
|
||||
(cloud, thread_id),
|
||||
super::super::SessionTranscriptState::Failed,
|
||||
);
|
||||
for (backend_id, text) in [(local, "Local preview"), (cloud, "Cloud preview")] {
|
||||
state.selected = state
|
||||
.filtered_rows
|
||||
.iter()
|
||||
.position(|row| row.backend_id.unwrap_or(local) == backend_id)
|
||||
.unwrap();
|
||||
state.toggle_selected_expansion();
|
||||
assert!(
|
||||
matches!(actions.try_recv().unwrap(), PickerLoadRequest::Preview { backend_id: owner, thread_id: id } if (owner, id) == (backend_id, thread_id))
|
||||
);
|
||||
state
|
||||
.handle_background_event(super::super::BackgroundEvent::Preview {
|
||||
backend_id,
|
||||
thread_id,
|
||||
preview: Ok(vec![super::super::TranscriptPreviewLine {
|
||||
speaker: super::super::TranscriptPreviewSpeaker::User,
|
||||
text: text.into(),
|
||||
}]),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(state.expanded_thread_id, Some((cloud, thread_id)));
|
||||
state.pending_transcript_open = Some((local, thread_id));
|
||||
|
||||
state.request_archive_for_selected_session();
|
||||
assert_eq!(
|
||||
state.archive_state,
|
||||
ArchiveState::Pending {
|
||||
backend_id: cloud,
|
||||
thread_id
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
matches!(actions.try_recv().unwrap(), PickerLoadRequest::Archive {
|
||||
backend_id, thread_id: id
|
||||
} if (backend_id, id) == (cloud, thread_id))
|
||||
);
|
||||
assert!(actions.try_recv().is_err());
|
||||
state.handle_archive_result(local, thread_id, Ok(()));
|
||||
assert_eq!(state.all_rows.len(), 2);
|
||||
assert_eq!(
|
||||
state.archive_state,
|
||||
ArchiveState::Pending {
|
||||
backend_id: cloud,
|
||||
thread_id
|
||||
}
|
||||
);
|
||||
state.handle_archive_result(cloud, thread_id, Ok(()));
|
||||
|
||||
assert_eq!(state.all_rows, vec![local_row.clone()]);
|
||||
assert_eq!(state.filtered_rows, vec![local_row]);
|
||||
assert_eq!(
|
||||
state.transcript_cells.keys().copied().collect::<Vec<_>>(),
|
||||
vec![(local, thread_id)]
|
||||
);
|
||||
state
|
||||
.handle_background_event(super::super::BackgroundEvent::Preview {
|
||||
backend_id: cloud,
|
||||
thread_id,
|
||||
preview: Ok(vec![super::super::TranscriptPreviewLine {
|
||||
speaker: super::super::TranscriptPreviewSpeaker::User,
|
||||
text: "Late Cloud preview".into(),
|
||||
}]),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!state.transcript_previews.contains_key(&(cloud, thread_id)));
|
||||
assert_eq!(state.expanded_thread_id, None);
|
||||
state.toggle_selected_expansion();
|
||||
assert_eq!(state.expanded_thread_id, Some((local, thread_id)));
|
||||
let lines = super::super::render_transcript_preview_lines(
|
||||
&state.filtered_rows[0],
|
||||
&state,
|
||||
/*width*/ 80,
|
||||
);
|
||||
let rendered = lines
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(rendered.contains("Local preview"));
|
||||
assert!(!rendered.contains("Cloud preview"));
|
||||
assert_eq!(state.pending_transcript_open, Some((local, thread_id)));
|
||||
assert_eq!(state.archive_state, ArchiveState::Idle);
|
||||
}
|
||||
|
||||
@@ -121,10 +121,10 @@ pub(super) fn spawn(
|
||||
} else {
|
||||
let error = std::io::Error::other("The session's backend is unavailable.");
|
||||
let event = match request {
|
||||
PickerLoadRequest::Preview { thread_id, .. } => BackgroundEvent::Preview { thread_id, preview: Err(error) },
|
||||
PickerLoadRequest::Preview { thread_id, .. } => BackgroundEvent::Preview { backend_id: backend, thread_id, preview: Err(error) },
|
||||
PickerLoadRequest::Acknowledge { .. } => continue,
|
||||
PickerLoadRequest::Transcript { thread_id, .. } => BackgroundEvent::Transcript { backend_id: backend, thread_id, transcript: Err(error) },
|
||||
PickerLoadRequest::Archive { thread_id, .. } => BackgroundEvent::Archive { thread_id, result: Err(error) },
|
||||
PickerLoadRequest::Archive { thread_id, .. } => BackgroundEvent::Archive { backend_id: backend, thread_id, result: Err(error) },
|
||||
PickerLoadRequest::Unarchive { thread_id, .. } => BackgroundEvent::Unarchive { thread_id, result: Err(error) },
|
||||
PickerLoadRequest::Page(_) => unreachable!(),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user