From 5c95e4588e229cdda2484d1290ed15c1492ec5c3 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 8 Apr 2026 09:35:54 -0700 Subject: [PATCH] Fix TUI crash when resuming the current thread (#17086) Problem: Resuming the live TUI thread through `/resume` could unsubscribe and reconnect the same app-server thread, leaving the UI crashed or disconnected. Solution: No-op `/resume` only when the selected thread is the currently attached active thread; keep the normal resume path for stale/displayed-only threads so recovery and reattach still work. --- codex-rs/tui/src/app.rs | 66 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index a0a5d66c6..8d96641c5 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1693,6 +1693,21 @@ impl App { self.active_thread_id.or(self.chat_widget.thread_id()) } + fn ignore_same_thread_resume( + &mut self, + target_session: &crate::resume_picker::SessionTarget, + ) -> bool { + if self.active_thread_id != Some(target_session.thread_id) { + return false; + }; + + self.chat_widget.add_info_message( + format!("Already viewing {}.", target_session.display_label()), + /*hint*/ None, + ); + true + } + /// Mirrors the visible thread into the contextual footer row. /// /// The footer sometimes shows ambient context instead of an instructional hint. In multi-agent @@ -4074,6 +4089,10 @@ impl App { .await? { SessionSelection::Resume(target_session) => { + if self.ignore_same_thread_resume(&target_session) { + tui.frame_requester().schedule_frame(); + return Ok(AppRunControl::Continue); + } let current_cwd = self.config.cwd.to_path_buf(); let resume_cwd = if self.remote_app_server_url.is_some() { current_cwd.clone() @@ -6520,6 +6539,53 @@ mod tests { ); } + #[tokio::test] + async fn ignore_same_thread_resume_reports_noop_for_current_thread() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let session = test_thread_session(thread_id, PathBuf::from("/tmp/project")); + app.chat_widget.handle_thread_session(session.clone()); + app.thread_event_channels.insert( + thread_id, + ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + session, + Vec::new(), + ), + ); + app.activate_thread_channel(thread_id).await; + while app_event_rx.try_recv().is_ok() {} + + let ignored = app.ignore_same_thread_resume(&crate::resume_picker::SessionTarget { + path: Some(PathBuf::from("/tmp/project")), + thread_id, + }); + + assert!(ignored); + let cell = match app_event_rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => cell, + other => panic!("expected info message after same-thread resume, saw {other:?}"), + }; + let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); + assert!(rendered.contains("Already viewing /tmp/project.")); + } + + #[tokio::test] + async fn ignore_same_thread_resume_allows_reattaching_displayed_inactive_thread() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + let session = test_thread_session(thread_id, PathBuf::from("/tmp/project")); + app.chat_widget.handle_thread_session(session); + + let ignored = app.ignore_same_thread_resume(&crate::resume_picker::SessionTarget { + path: Some(PathBuf::from("/tmp/project")), + thread_id, + }); + + assert!(!ignored); + assert!(app.transcript_cells.is_empty()); + } + #[tokio::test] async fn enqueue_primary_thread_session_replays_buffered_approval_after_attach() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;