diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index 25c1cf04f4..5e50341f2e 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -163,7 +163,9 @@ impl App { session.model = notification.thread_settings.model.clone(); session.model_provider_id = notification.thread_settings.model_provider.clone(); session.service_tier = notification.thread_settings.service_tier.clone(); - session.cwd = notification.thread_settings.cwd.clone(); + session.set_cwd_retargeting_implicit_runtime_workspace_root( + notification.thread_settings.cwd.clone(), + ); session.approval_policy = notification.thread_settings.approval_policy; session.approvals_reviewer = notification.thread_settings.approvals_reviewer.to_core(); session.permission_profile = notification @@ -178,6 +180,7 @@ impl App { .map(codex_protocol::models::ActivePermissionProfile::from); session.reasoning_effort = notification.thread_settings.effort; }; + let server_notification = ServerNotification::ThreadSettingsUpdated(notification.clone()); if self.primary_thread_id == Some(thread_id) && let Some(session) = self.primary_session_configured.as_mut() @@ -189,16 +192,15 @@ impl App { if let Some(session) = store.session.as_mut() { update_session(session); } + store.push_notification(server_notification.clone()); } if self.chat_widget.thread_id() != Some(thread_id) { return; } - self.chat_widget.handle_server_notification( - ServerNotification::ThreadSettingsUpdated(notification), - /*replay_kind*/ None, - ); + self.chat_widget + .handle_server_notification(server_notification, /*replay_kind*/ None); } async fn handle_server_request_event( diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 2c4322d56a..7eb608fbb2 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -234,29 +234,33 @@ async fn thread_settings_updated_notification_reaches_active_thread_ui() -> Resu } #[tokio::test] -async fn thread_settings_updated_notification_for_hidden_thread_updates_cache_only() -> Result<()> { +async fn hidden_thread_settings_update_buffers_for_replay() -> Result<()> { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; let active_thread_id = ThreadId::new(); let hidden_thread_id = ThreadId::new(); let active_cwd = test_path_buf("/tmp/active").abs(); let hidden_cwd = test_path_buf("/tmp/hidden").abs(); + let hidden_next_cwd = test_path_buf("/tmp/hidden-next").abs(); + let shared_root = test_path_buf("/tmp/shared").abs(); app.enqueue_primary_thread_session( test_thread_session(active_thread_id, active_cwd.to_path_buf()), Vec::new(), ) .await?; + let mut hidden_session = test_thread_session(hidden_thread_id, hidden_cwd.to_path_buf()); + hidden_session.runtime_workspace_roots = vec![hidden_cwd.clone(), shared_root.clone()]; app.thread_event_channels.insert( hidden_thread_id, ThreadEventChannel::new_with_session( THREAD_EVENT_CHANNEL_CAPACITY, - test_thread_session(hidden_thread_id, hidden_cwd.to_path_buf()), + hidden_session, Vec::new(), ), ); app.handle_thread_settings_updated_notification(ThreadSettingsUpdatedNotification { thread_id: hidden_thread_id.to_string(), - thread_settings: test_thread_settings("gpt-hidden", hidden_cwd), + thread_settings: test_thread_settings("gpt-hidden", hidden_next_cwd.clone()), }) .await; @@ -273,6 +277,21 @@ async fn thread_settings_updated_notification_for_hidden_thread_updates_cache_on .map(|session| session.model.as_str()), Some("gpt-hidden") ); + let hidden_session = hidden_store.session.as_ref().expect("hidden session"); + assert_eq!(hidden_session.cwd, hidden_next_cwd); + assert_eq!( + hidden_session.runtime_workspace_roots, + vec![hidden_next_cwd.clone(), shared_root] + ); + assert!( + hidden_store.buffer.iter().any(|event| matches!( + event, + ThreadBufferedEvent::Notification(ServerNotification::ThreadSettingsUpdated( + notification + )) if notification.thread_settings.model == "gpt-hidden" + )), + "hidden thread settings update should be buffered for replay" + ); Ok(()) } diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 30f68dc640..389a9978c3 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -50,6 +50,7 @@ impl ThreadEventStore { ThreadBufferedEvent::Request(_) | ThreadBufferedEvent::Notification(ServerNotification::HookStarted(_)) | ThreadBufferedEvent::Notification(ServerNotification::HookCompleted(_)) + | ThreadBufferedEvent::Notification(ServerNotification::ThreadSettingsUpdated(_)) | ThreadBufferedEvent::FeedbackSubmission(_) ) } diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index 7ccb2bc342..969ebcb0be 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -220,7 +220,6 @@ impl ChatWidget { | ServerNotification::AccountRateLimitsUpdated(_) | ServerNotification::ThreadStarted(_) | ServerNotification::ThreadStatusChanged(_) - | ServerNotification::ThreadSettingsUpdated(_) | ServerNotification::ThreadArchived(_) | ServerNotification::ThreadUnarchived(_) | ServerNotification::RawResponseItemCompleted(_) @@ -249,8 +248,17 @@ impl ChatWidget { &mut self, thread_settings: codex_app_server_protocol::ThreadSettings, ) { + let previous_cwd = std::mem::replace(&mut self.config.cwd, thread_settings.cwd.clone()); self.current_cwd = Some(thread_settings.cwd.to_path_buf()); - self.config.cwd = thread_settings.cwd; + if crate::session_state::retarget_implicit_workspace_root( + &mut self.config.workspace_roots, + previous_cwd, + thread_settings.cwd.clone(), + ) { + self.config + .permissions + .set_workspace_roots(self.config.workspace_roots.clone()); + } self.config.model_reasoning_summary = thread_settings.summary; self.config.personality = thread_settings.personality; self.effective_service_tier = thread_settings.service_tier.clone(); diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 28ff9a6859..3995fb6f2d 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -4,7 +4,14 @@ use pretty_assertions::assert_eq; #[tokio::test] async fn thread_settings_updated_notification_refreshes_active_ui_state_without_history() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + let previous_cwd = test_path_buf("/tmp/thread-settings-previous").abs(); let cwd = test_path_buf("/tmp/thread-settings").abs(); + let extra_root = test_path_buf("/tmp/thread-settings-extra-root").abs(); + chat.config.cwd = previous_cwd.clone(); + chat.config.workspace_roots = vec![previous_cwd, extra_root.clone()]; + chat.config + .permissions + .set_workspace_roots(chat.config.workspace_roots.clone()); let permission_profile = PermissionProfile::workspace_write(); let collaboration_mode = CollaborationMode { mode: ModeKind::Plan, @@ -63,6 +70,12 @@ async fn thread_settings_updated_notification_refreshes_active_ui_state_without_ ); assert_eq!(chat.config_ref().personality, Some(Personality::Pragmatic)); assert_eq!(chat.current_collaboration_mode(), &collaboration_mode); + let expected_workspace_roots = vec![cwd, extra_root]; + assert_eq!(chat.config_ref().workspace_roots, expected_workspace_roots); + assert_eq!( + chat.config_ref().permissions.user_visible_workspace_roots(), + expected_workspace_roots.as_slice() + ); assert!(drain_insert_history(&mut rx).is_empty()); } diff --git a/codex-rs/tui/src/session_state.rs b/codex-rs/tui/src/session_state.rs index c9d964d620..3fedca0242 100644 --- a/codex-rs/tui/src/session_state.rs +++ b/codex-rs/tui/src/session_state.rs @@ -58,16 +58,25 @@ impl ThreadSessionState { cwd: AbsolutePathBuf, ) { let previous_cwd = std::mem::replace(&mut self.cwd, cwd.clone()); - if !self.runtime_workspace_roots.contains(&previous_cwd) { - return; - } - - let previous_roots = std::mem::take(&mut self.runtime_workspace_roots); - self.runtime_workspace_roots.push(cwd); - for root in previous_roots { - if root != previous_cwd && !self.runtime_workspace_roots.contains(&root) { - self.runtime_workspace_roots.push(root); - } - } + retarget_implicit_workspace_root(&mut self.runtime_workspace_roots, previous_cwd, cwd); } } + +pub(crate) fn retarget_implicit_workspace_root( + workspace_roots: &mut Vec, + previous_cwd: AbsolutePathBuf, + cwd: AbsolutePathBuf, +) -> bool { + if !workspace_roots.contains(&previous_cwd) { + return false; + } + + let previous_roots = std::mem::take(workspace_roots); + workspace_roots.push(cwd); + for root in previous_roots { + if root != previous_cwd && !workspace_roots.contains(&root) { + workspace_roots.push(root); + } + } + true +}