diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index be2cf3e923..2ce46b43f1 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -383,7 +383,7 @@ impl App { } } AppEvent::ArchiveCurrentThread => { - return Ok(self.archive_current_thread(app_server).await); + return self.archive_current_thread(tui, app_server).await; } AppEvent::DeleteCurrentThread => { return Ok(self.delete_current_thread(app_server).await); @@ -3448,29 +3448,62 @@ impl App { pub(super) async fn archive_current_thread( &mut self, + tui: &mut tui::Tui, app_server: &mut AppServerSession, - ) -> AppRunControl { + ) -> Result { let Some(thread_id) = self.active_thread_id.or(self.chat_widget.thread_id()) else { self.chat_widget .add_error_message("A thread must start before it can be archived.".to_string()); - return AppRunControl::Continue; + return Ok(AppRunControl::Continue); }; if self.side_threads.contains_key(&thread_id) { self.chat_widget.add_error_message( "'/archive' is unavailable in side conversations. Press Ctrl+C to return to the main thread first." .to_string(), ); - return AppRunControl::Continue; + return Ok(AppRunControl::Continue); } - match app_server.thread_archive(thread_id).await { - Ok(()) => AppRunControl::Exit(ExitReason::Archived(thread_id)), + if !matches!(self.app_server_target, AppServerTarget::Embedded) { + self.shutdown_side_threads(app_server).await; + if !self.side_threads.is_empty() { + return Ok(AppRunControl::Continue); + } + } + + Ok(match app_server.thread_archive(thread_id).await { + Ok(()) if matches!(self.app_server_target, AppServerTarget::Embedded) => { + AppRunControl::Exit(ExitReason::Archived(thread_id)) + } + Ok(()) => { + self.track_agents_overview_notification(&ServerNotification::ThreadArchived( + codex_app_server_protocol::ThreadArchivedNotification { + thread_id: thread_id.to_string(), + }, + )); + self.discard_thread_local_state(thread_id).await; + self.agents_overview.input_states.remove(&thread_id); + self.agents_overview.dispatched_requests.remove(&thread_id); + self.reset_for_thread_switch(tui)?; + self.pending_thread_switch_resets += 1; + self.app_event_tx + .send(AppEvent::ResetTranscriptForThreadSwitch); + self.reset_thread_event_state(); + let init = self.chatwidget_init_for_forked_or_resumed_thread( + tui, + self.config.clone(), + /*initial_user_message*/ None, + ); + self.replace_chat_widget(ChatWidget::new_with_app_event(init)); + self.open_agents_overview(app_server); + AppRunControl::Continue + } Err(err) => { self.chat_widget .add_error_message(format!("Failed to archive current thread: {err}")); AppRunControl::Continue } - } + }) } pub(super) async fn delete_current_thread( diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index 906fe7724d..f8d053236d 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -585,72 +585,58 @@ async fn make_history_test_app() -> Result<(App, tempfile::TempDir)> { } #[tokio::test] -async fn removing_remote_thread_omits_disconnect_guidance() -> Result<()> { - for event in [ - AppEvent::ArchiveCurrentThread, - AppEvent::DeleteCurrentThread, - ] { - let (mut app, codex_home) = make_history_test_app().await?; - let thread_id = ThreadId::from_string( - &create_fake_rollout( - codex_home.path(), - "2026-01-01T00-00-00", - "2026-01-01T00:00:00Z", - "Saved user message", - Some(app.config.model_provider_id.as_str()), - /*git_info*/ None, - ) - .expect("create rollout"), - )?; - let (mut server, _, proxy) = start_recording_app_server( - &app.config, - /*blocked_thread_list*/ None, - /*failed_thread_name*/ None, +async fn deleting_remote_thread_omits_disconnect_guidance() -> Result<()> { + let (mut app, codex_home) = make_history_test_app().await?; + let thread_id = ThreadId::from_string( + &create_fake_rollout( + codex_home.path(), + "2026-01-01T00-00-00", + "2026-01-01T00:00:00Z", + "Saved user message", + Some(app.config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .expect("create rollout"), + )?; + let (mut server, _, proxy) = start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + let resumed = server + .resume_thread( + &app.local_settings, + app.config.clone(), + thread_id, + crate::app_server_session::ResumeModelSettings::RestoreFromThread, ) .await?; - let resumed = server - .resume_thread( - &app.local_settings, - app.config.clone(), - thread_id, - crate::app_server_session::ResumeModelSettings::RestoreFromThread, - ) - .await?; - app.app_server_target = AppServerTarget::Remote { - endpoint: crate::resolve_remote_addr("ws://127.0.0.1:4500")?, - }; - app.active_thread_id = Some(thread_id); - app.chat_widget.handle_thread_session(resumed.session); - let mut tui = crate::tui::test_support::make_test_tui()?; - let archived = matches!(&event, AppEvent::ArchiveCurrentThread); - // Keep the large dispatcher future off the Windows test thread's stack. - let AppRunControl::Exit(reason) = - Box::pin(app.handle_event(&mut tui, &mut server, event)).await? - else { - panic!("removing the current thread must exit"); - }; - if archived { - assert_matches!(reason, ExitReason::Archived(id) if id == thread_id); - } else { - assert_matches!(reason, ExitReason::ThreadRemoved); - } - let mut exit_info = app.exit_info(reason); - exit_info.token_usage = TokenUsage { - output_tokens: 2, - total_tokens: 2, - ..Default::default() - }; - let mut expected = vec!["Token usage: total=2 input=0 output=2".to_string()]; - if archived { - expected.push(format!("Session archived: {thread_id}")); - } - assert_eq!( - exit_info.format_exit_messages(/*color_enabled*/ false), - expected - ); - server.shutdown().await?; - proxy.await??; - } + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::resolve_remote_addr("ws://127.0.0.1:4500")?, + }; + app.active_thread_id = Some(thread_id); + app.chat_widget.handle_thread_session(resumed.session); + let mut tui = crate::tui::test_support::make_test_tui()?; + // Keep the large dispatcher future off the Windows test thread's stack. + let AppRunControl::Exit(reason) = + Box::pin(app.handle_event(&mut tui, &mut server, AppEvent::DeleteCurrentThread)).await? + else { + panic!("removing the current thread must exit"); + }; + assert_matches!(reason, ExitReason::ThreadRemoved); + let mut exit_info = app.exit_info(reason); + exit_info.token_usage = TokenUsage { + output_tokens: 2, + total_tokens: 2, + ..Default::default() + }; + assert_eq!( + exit_info.format_exit_messages(/*color_enabled*/ false), + vec!["Token usage: total=2 input=0 output=2".to_string()] + ); + server.shutdown().await?; + proxy.await??; Ok(()) } @@ -813,15 +799,17 @@ async fn archive_current_thread_reports_success_only_after_archiving() -> Result )?; let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; app.active_thread_id = Some(ThreadId::new()); assert_matches!( - app.archive_current_thread(&mut app_server).await, + app.archive_current_thread(&mut tui, &mut app_server) + .await?, AppRunControl::Continue ); app.active_thread_id = Some(thread_id); assert_matches!( - app.archive_current_thread(&mut app_server).await, + app.archive_current_thread(&mut tui, &mut app_server).await?, AppRunControl::Exit(ExitReason::Archived(archived_id)) if archived_id == thread_id ); @@ -829,6 +817,103 @@ async fn archive_current_thread_reports_success_only_after_archiving() -> Result Ok(()) } +#[tokio::test] +async fn archive_current_thread_returns_shared_servers_to_agents() -> Result<()> { + let endpoint = crate::resolve_remote_addr("ws://127.0.0.1:4500")?; + for target in [ + AppServerTarget::LocalDaemon { + endpoint: endpoint.clone(), + }, + AppServerTarget::Remote { endpoint }, + ] { + let (mut app, _codex_home) = make_history_test_app().await?; + let thread_id = + create_history_rollout(&app.config, ThreadHistoryMode::Legacy, "archive me")?; + let (mut server, requests, proxy) = start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + let resumed = server + .resume_thread( + &app.local_settings, + app.config.clone(), + thread_id, + crate::app_server_session::ResumeModelSettings::RestoreFromThread, + ) + .await?; + let mut side_config = app.config.clone(); + side_config.ephemeral = true; + let side = server + .fork_side_thread(&app.local_settings, side_config, thread_id) + .await?; + let side_id = side.session.thread_id; + app.side_threads + .insert(side_id, SideThreadState::new(thread_id)); + app.app_server_target = target; + app.enqueue_primary_thread_session(resumed.session.clone(), resumed.turns) + .await?; + app.chat_widget.handle_thread_session(resumed.session); + app.chat_widget.insert_str("Unsent archived draft"); + let mut tui = crate::tui::test_support::make_test_tui()?; + let (tx, mut events) = tokio::sync::mpsc::unbounded_channel(); + app.app_event_tx = AppEventSender::new(tx); + + assert_matches!( + Box::pin(app.handle_event(&mut tui, &mut server, AppEvent::ArchiveCurrentThread)) + .await?, + AppRunControl::Continue + ); + loop { + let event = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), events.recv()) + .await? + .expect("expected command center refresh"); + let refreshed = matches!(&event, AppEvent::AgentsOverviewThreadsLoaded { .. }); + Box::pin(app.handle_event(&mut tui, &mut server, event)).await?; + if refreshed { + break; + } + } + assert!(app.agents_overview.initialized); + assert_eq!( + ( + app.active_thread_id, + app.primary_thread_id, + app.chat_widget.thread_id() + ), + (None, None, None) + ); + assert!(!app.agents_overview.threads.contains_key(&thread_id)); + assert!(app.thread_event_channels.is_empty()); + assert!(app.side_threads.is_empty()); + assert_eq!( + recorded_params(&requests, "thread/unsubscribe"), + vec![serde_json::json!({"threadId": side_id.to_string()})] + ); + assert!(app.chat_widget.composer_is_empty()); + assert_eq!( + recorded_params(&requests, "thread/archive"), + vec![serde_json::json!({"threadId": thread_id.to_string()})] + ); + assert!( + app.chat_widget + .selected_index_for_present_view( + crate::app::agents_overview::AGENTS_OVERVIEW_VIEW_ID + ) + .is_some() + ); + assert_snapshot!( + "agents_command_center_after_archive", + render_bottom_popup(&app.chat_widget, /*width*/ 100) + ); + + server.shutdown().await?; + proxy.await??; + } + Ok(()) +} + #[tokio::test] async fn local_daemon_registers_approval_gated_mcp_tools_for_both_start_paths() -> Result<()> { let (mut app, events, _ops) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__agents_command_center_after_archive.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__agents_command_center_after_archive.snap new file mode 100644 index 0000000000..b3375278fa --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__agents_command_center_after_archive.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/app/tests/session_lifecycle_requests.rs +expression: "render_bottom_popup(&app.chat_widget, 100)" +--- + Agent command center + 0 need input 0 working 0 ready + ──────────────────────────────────────────────────────────────────────────────────────────────── + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + New task + +› Describe a new task + + enter create task ctrl+j newline esc tasks diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_ar.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_ar.snap index 5014c35d28..9714636b39 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_ar.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_ar.snap @@ -6,4 +6,4 @@ expression: terminal.backend() "› /ar " " " " " -" /archive archive this session and exit " +" /archive archive this session " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap index 280a204be2..0cadee0486 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap @@ -16,7 +16,7 @@ expression: commands /review - review my current changes and find issues /rename - rename the current thread /new - start a new chat during a conversation -/archive - archive this session and exit +/archive - archive this session /delete - permanently delete this session and exit /resume - resume a saved chat /fork - fork the current chat diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 1ee962f045..f67ece542e 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -188,8 +188,7 @@ impl ChatWidget { self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Archive this session?".to_string()), subtitle: Some( - "Are you sure? This will archive the current session and exit Codex" - .to_string(), + "Are you sure? This will archive the current session".to_string(), ), footer_hint: Some(standard_popup_hint_line()), items: vec![ @@ -200,7 +199,7 @@ impl ChatWidget { ..Default::default() }, SelectionItem { - name: "Yes, archive and exit".to_string(), + name: "Yes, archive".to_string(), description: Some("Archive this session now".to_string()), actions: vec![Box::new(|tx| { tx.send(AppEvent::ArchiveCurrentThread); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_archive_confirmation_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_archive_confirmation_popup.snap index cdc895d7be..ada6b3b442 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_archive_confirmation_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_archive_confirmation_popup.snap @@ -3,9 +3,9 @@ source: tui/src/chatwidget/tests/slash_commands.rs expression: popup --- Archive this session? - Are you sure? This will archive the current session and exit Codex + Are you sure? This will archive the current session -› 1. No, don't archive Return to the current session - 2. Yes, archive and exit Archive this session now +› 1. No, don't archive Return to the current session + 2. Yes, archive Archive this session now Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index d866c8feea..99fea9eee7 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -95,7 +95,7 @@ impl SlashCommand { SlashCommand::Review => "review my current changes and find issues", SlashCommand::Rename => "rename the current thread", SlashCommand::Resume => "resume a saved chat", - SlashCommand::Archive => "archive this session and exit", + SlashCommand::Archive => "archive this session", SlashCommand::Delete => "permanently delete this session and exit", SlashCommand::Clear => "clear the terminal and start a new chat", SlashCommand::Fork => "fork the current chat",