diff --git a/codex-rs/tui/src/app/agent_navigation.rs b/codex-rs/tui/src/app/agent_navigation.rs index 91964c688b..f8862e908e 100644 --- a/codex-rs/tui/src/app/agent_navigation.rs +++ b/codex-rs/tui/src/app/agent_navigation.rs @@ -49,8 +49,9 @@ pub(crate) struct AgentNavigationState { stopped_threads: HashSet, /// Spawned child threads whose instructions are owned by their parent agent. parent_owned_threads: HashSet, + picker_excluded_threads: HashSet, /// Coalesces root refreshes while rejecting replies from a previous session. - pub(super) picker_refresh: Option<(ThreadId, Uuid)>, + pub(super) picker_refresh: Option, } /// Direction of keyboard traversal through the stable picker order. @@ -62,24 +63,65 @@ pub(crate) enum AgentNavigationDirection { Next, } +#[derive(Clone, Copy)] +pub(super) enum AgentPickerThreadVisibility { + Visible, + Hidden, +} + +#[derive(Debug)] +pub(super) struct AgentPickerRefreshState { + root: ThreadId, + request_id: Uuid, + pub(super) visibility_changed_threads: HashSet, + pub(super) follow_up_requested: bool, +} + impl AgentNavigationState { pub(crate) fn begin_picker_refresh(&mut self, thread_id: ThreadId) -> Option { - if self.picker_refresh.is_some() { + if self + .picker_refresh + .as_ref() + .is_some_and(|refresh| refresh.root == thread_id) + { return None; } let request_id = Uuid::new_v4(); - self.picker_refresh = Some((thread_id, request_id)); + self.picker_refresh = Some(AgentPickerRefreshState { + root: thread_id, + request_id, + visibility_changed_threads: HashSet::new(), + follow_up_requested: false, + }); Some(request_id) } - pub(crate) fn finish_picker_refresh(&mut self, thread_id: ThreadId, request_id: Uuid) -> bool { - if self.picker_refresh != Some((thread_id, request_id)) { + pub(crate) fn queue_picker_refresh(&mut self, thread_id: ThreadId) -> bool { + let Some(refresh) = self.picker_refresh.as_mut() else { + return false; + }; + if refresh.root != thread_id { return false; } - self.picker_refresh = None; + refresh.follow_up_requested = true; true } + pub(crate) fn finish_picker_refresh( + &mut self, + thread_id: ThreadId, + request_id: Uuid, + ) -> Option { + if !self + .picker_refresh + .as_ref() + .is_some_and(|refresh| refresh.root == thread_id && refresh.request_id == request_id) + { + return None; + } + self.picker_refresh.take() + } + /// Returns the cached picker entry for a specific thread id. /// /// Callers use this when they already know which thread they care about and need the last @@ -99,12 +141,14 @@ impl AgentNavigationState { self.parent_owned_threads.insert(thread_id); } - /// Returns whether the picker cache currently knows about any threads. + /// Returns whether the picker currently has any visible threads. /// /// This is the cheapest way for `App` to decide whether opening the picker should show "No /// agents available yet." rather than constructing picker rows from an empty state. pub(crate) fn is_empty(&self) -> bool { - self.threads.is_empty() + self.threads + .keys() + .all(|thread_id| self.picker_excluded_threads.contains(thread_id)) } /// Inserts or updates a picker entry while preserving first-seen traversal order. @@ -223,6 +267,7 @@ impl AgentNavigationState { self.order.clear(); self.stopped_threads.clear(); self.parent_owned_threads.clear(); + self.picker_excluded_threads.clear(); self.picker_refresh = None; } @@ -236,6 +281,7 @@ impl AgentNavigationState { self.order.retain(|candidate| *candidate != thread_id); self.stopped_threads.remove(&thread_id); self.parent_owned_threads.remove(&thread_id); + self.picker_excluded_threads.remove(&thread_id); } /// Returns whether there is at least one tracked thread other than the primary one. @@ -244,9 +290,10 @@ impl AgentNavigationState { /// feature flag is currently disabled, because already-existing sub-agent threads should remain /// inspectable. pub(crate) fn has_non_primary_thread(&self, primary_thread_id: Option) -> bool { - self.threads - .keys() - .any(|thread_id| Some(*thread_id) != primary_thread_id) + self.threads.keys().any(|thread_id| { + Some(*thread_id) != primary_thread_id + && !self.picker_excluded_threads.contains(thread_id) + }) } /// Returns live picker rows in the same order users cycle through them. @@ -261,11 +308,40 @@ impl AgentNavigationState { .collect() } + pub(crate) fn visible_threads(&self) -> Vec<(ThreadId, &AgentPickerThreadEntry)> { + self.ordered_threads() + .into_iter() + .filter(|(thread_id, _)| !self.picker_excluded_threads.contains(thread_id)) + .collect() + } + + pub(crate) fn set_picker_thread_visibility( + &mut self, + thread_id: ThreadId, + visibility: AgentPickerThreadVisibility, + ) { + let track_hidden = self.threads.contains_key(&thread_id) || self.picker_refresh.is_some(); + match visibility { + AgentPickerThreadVisibility::Visible => self.picker_excluded_threads.remove(&thread_id), + AgentPickerThreadVisibility::Hidden => { + track_hidden && self.picker_excluded_threads.insert(thread_id) + } + }; + if let Some(refresh) = self.picker_refresh.as_mut() { + refresh.visibility_changed_threads.insert(thread_id); + } + } + + pub(crate) fn prune_untracked_picker_exclusions(&mut self) { + self.picker_excluded_threads + .retain(|thread_id| self.threads.contains_key(thread_id)); + } + pub(crate) fn ordered_path_backed_subagent_threads( &self, primary_thread_id: Option, ) -> Vec<(ThreadId, &AgentPickerThreadEntry)> { - self.ordered_threads() + self.visible_threads() .into_iter() .filter(|(thread_id, entry)| { Some(*thread_id) != primary_thread_id @@ -296,26 +372,23 @@ impl AgentNavigationState { current_displayed_thread_id: Option, direction: AgentNavigationDirection, ) -> Option { - let ordered_threads = self.ordered_threads(); - if ordered_threads.len() < 2 { - return None; - } - let current_thread_id = current_displayed_thread_id?; - let current_idx = ordered_threads + let current_idx = self + .order .iter() - .position(|(thread_id, _)| *thread_id == current_thread_id)?; - let next_idx = match direction { - AgentNavigationDirection::Next => (current_idx + 1) % ordered_threads.len(), - AgentNavigationDirection::Previous => { - if current_idx == 0 { - ordered_threads.len() - 1 - } else { - current_idx - 1 + .position(|thread_id| *thread_id == current_thread_id)?; + (1..self.order.len()).find_map(|offset| { + let idx = match direction { + AgentNavigationDirection::Next => (current_idx + offset) % self.order.len(), + AgentNavigationDirection::Previous => { + (current_idx + self.order.len() - offset) % self.order.len() } - } - }; - Some(ordered_threads[next_idx].0) + }; + let thread_id = self.order[idx]; + (self.threads.contains_key(&thread_id) + && !self.picker_excluded_threads.contains(&thread_id)) + .then_some(thread_id) + }) } /// Derives the contextual footer label for the currently displayed thread. @@ -468,8 +541,18 @@ mod tests { .begin_picker_refresh(thread_id) .expect("refresh after session reset"); - assert!(!state.finish_picker_refresh(thread_id, stale_request)); - assert!(state.finish_picker_refresh(thread_id, current_request)); + assert!( + state + .finish_picker_refresh(thread_id, stale_request) + .is_none() + ); + assert!( + state + .finish_picker_refresh(thread_id, current_request) + .is_some() + ); + state.set_picker_thread_visibility(ThreadId::new(), AgentPickerThreadVisibility::Hidden); + assert!(state.picker_excluded_threads.is_empty()); } #[test] @@ -488,6 +571,15 @@ mod tests { state.adjacent_thread_id(Some(main_thread_id), AgentNavigationDirection::Previous), Some(second_agent_id) ); + + let mut state = state; + state.set_agent_path(first_agent_id, Some("/root/first".to_string())); + state.set_picker_thread_visibility(first_agent_id, AgentPickerThreadVisibility::Hidden); + assert!( + state + .ordered_path_backed_subagent_threads(Some(main_thread_id)) + .is_empty() + ); } #[test] diff --git a/codex-rs/tui/src/app/agent_picker.rs b/codex-rs/tui/src/app/agent_picker.rs index d9efcf140d..4f515392d6 100644 --- a/codex-rs/tui/src/app/agent_picker.rs +++ b/codex-rs/tui/src/app/agent_picker.rs @@ -1,9 +1,12 @@ //! Root-scoped background refresh for the agent picker. +use super::agent_navigation::AgentPickerThreadVisibility; +use super::app_server_event_targets::ServerNotificationThreadTarget; +use super::app_server_event_targets::server_notification_thread_target; use super::*; +use crate::app_event::AgentPickerThreadRefresh; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SortDirection; -use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadSourceKind; @@ -27,54 +30,65 @@ impl App { let app_event_tx = self.app_event_tx.clone(); tokio::spawn(async move { let result = async { - let mut threads = Vec::new(); - let mut cursor = None; - let mut seen_cursors = HashSet::new(); - while threads.len() < AGENT_PICKER_MAX_THREADS - && seen_cursors.insert(cursor.clone()) - { - let page = match request_handle - .request_typed::(ClientRequest::ThreadList { - request_id: RequestId::String(Uuid::new_v4().to_string()), - params: ThreadListParams { - originators: None, - cursor, - limit: Some(AGENT_PICKER_PAGE_SIZE), - sort_key: None, - sort_direction: Some(SortDirection::Desc), - model_providers: Some(vec![]), - source_kinds: Some(vec![ThreadSourceKind::SubAgentThreadSpawn]), - archived: None, - section_id: None, - project_id: None, - cwd: None, - use_state_db_only: true, - search_term: None, - parent_thread_id: None, - ancestor_thread_id: Some(root.to_string()), - }, - }) - .await - { - Ok(page) => page, - Err(err) if threads.is_empty() => return Err(err.to_string()), - Err(err) => { - tracing::warn!(%err, "failed to refresh remaining agent picker descendants"); - break; - } - }; - threads.extend( - page.data + let mut refresh = AgentPickerThreadRefresh::default(); + for archived in [false, true] { + let mut cursor = None; + let mut seen_cursors = HashSet::new(); + let mut listed = 0; + while listed < AGENT_PICKER_MAX_THREADS && seen_cursors.insert(cursor.clone()) { + let page = match request_handle + .request_typed::(ClientRequest::ThreadList { + request_id: RequestId::String(Uuid::new_v4().to_string()), + params: ThreadListParams { + originators: None, + cursor, + limit: Some(AGENT_PICKER_PAGE_SIZE), + sort_key: None, + sort_direction: Some(SortDirection::Desc), + model_providers: Some(vec![]), + source_kinds: Some(vec![ThreadSourceKind::SubAgentThreadSpawn]), + archived: Some(archived), + section_id: None, + project_id: None, + cwd: None, + use_state_db_only: true, + search_term: None, + parent_thread_id: None, + ancestor_thread_id: Some(root.to_string()), + }, + }) + .await + { + Ok(page) => page, + Err(err) if !archived && listed == 0 => return Err(err.to_string()), + Err(err) => { + tracing::warn!(%err, archived, "incomplete agent picker refresh"); + break; + } + }; + let page_threads: Vec<_> = page + .data .into_iter() - .take(AGENT_PICKER_MAX_THREADS - threads.len()), - ); - let Some(next_cursor) = page.next_cursor else { - break; - }; - cursor = Some(next_cursor); + .take(AGENT_PICKER_MAX_THREADS - listed) + .collect(); + listed += page_threads.len(); + if archived { + refresh.archived_thread_ids.extend( + page_threads + .into_iter() + .filter_map(|thread| ThreadId::from_string(&thread.id).ok()), + ); + } else { + refresh.threads.extend(page_threads); + } + let Some(next_cursor) = page.next_cursor else { + break; + }; + cursor = Some(next_cursor); + } } - threads.reverse(); - Ok(threads) + refresh.threads.reverse(); + Ok(refresh) } .await; @@ -88,31 +102,42 @@ impl App { pub(super) fn apply_agent_picker_thread_refresh( &mut self, + app_server: &AppServerSession, root: ThreadId, request_id: Uuid, - result: Result, String>, + result: Result, ) { - if !self + let Some(completion) = self .agent_navigation .finish_picker_refresh(root, request_id) - || self.primary_thread_id != Some(root) - { + else { + return; + }; + if self.primary_thread_id != Some(root) { return; } - let threads = match result { - Ok(threads) => threads, + let refresh = match result { + Ok(refresh) => refresh, Err(err) => { tracing::warn!(%err, "failed to refresh agent picker descendants"); + self.agent_navigation.prune_untracked_picker_exclusions(); + if completion.follow_up_requested { + self.refresh_agent_picker_threads(app_server, root); + } return; } }; - let selected = self - .chat_widget - .selected_index_for_present_view(AGENT_PICKER_VIEW_ID); - for thread in threads { + let selected_thread_id = self.selected_agent_picker_thread_id(); + for thread in refresh.threads { let Ok(thread_id) = ThreadId::from_string(&thread.id) else { continue; }; + if !completion.visibility_changed_threads.contains(&thread_id) { + self.update_agent_picker_thread_visibility( + thread_id, + AgentPickerThreadVisibility::Visible, + ); + } let live = self .thread_event_channels .get(&thread_id) @@ -140,7 +165,92 @@ impl App { self.agent_navigation.set_running(thread_id, is_running); } } + for thread_id in refresh.archived_thread_ids { + if !completion.visibility_changed_threads.contains(&thread_id) { + self.update_agent_picker_thread_visibility( + thread_id, + AgentPickerThreadVisibility::Hidden, + ); + } + } + self.agent_navigation.prune_untracked_picker_exclusions(); + self.replace_agent_picker_view(selected_thread_id); + if completion.follow_up_requested { + self.refresh_agent_picker_threads(app_server, root); + } + } + + pub(super) fn set_agent_picker_thread_visibility( + &mut self, + thread_id: ThreadId, + visibility: AgentPickerThreadVisibility, + ) { + let selected_thread_id = self.selected_agent_picker_thread_id(); + self.update_agent_picker_thread_visibility(thread_id, visibility); + self.replace_agent_picker_view(selected_thread_id); + } + + fn update_agent_picker_thread_visibility( + &mut self, + thread_id: ThreadId, + visibility: AgentPickerThreadVisibility, + ) { + if matches!(visibility, AgentPickerThreadVisibility::Hidden) { + self.agent_navigation.mark_stopped(thread_id); + if let Some(channel) = self.thread_event_channels.get_mut(&thread_id) { + channel.mark_replay_only(); + } + } + self.agent_navigation + .set_picker_thread_visibility(thread_id, visibility); + } + + pub(super) fn handle_agent_picker_visibility_notification( + &mut self, + app_server: &AppServerSession, + notification: &ServerNotification, + ) { + let visibility = match notification { + ServerNotification::ThreadArchived(_) => AgentPickerThreadVisibility::Hidden, + ServerNotification::ThreadUnarchived(_) => AgentPickerThreadVisibility::Visible, + _ => return, + }; + let ServerNotificationThreadTarget::Thread(thread_id) = + server_notification_thread_target(notification) + else { + return; + }; + let refresh_uncached_thread = matches!(visibility, AgentPickerThreadVisibility::Visible) + && self.agent_navigation.get(&thread_id).is_none(); + self.set_agent_picker_thread_visibility(thread_id, visibility); + if refresh_uncached_thread + && let Some(primary_thread_id) = self.primary_thread_id + && !self + .agent_navigation + .queue_picker_refresh(primary_thread_id) + { + self.refresh_agent_picker_threads(app_server, primary_thread_id); + } + } + + fn selected_agent_picker_thread_id(&self) -> Option { + let selected = self + .chat_widget + .selected_index_for_present_view(AGENT_PICKER_VIEW_ID)?; + self.agent_navigation + .visible_threads() + .get(selected) + .map(|(thread_id, _)| *thread_id) + } + + fn replace_agent_picker_view(&mut self, selected_thread_id: Option) { + let selected = selected_thread_id.and_then(|selected_thread_id| { + self.agent_navigation + .visible_threads() + .iter() + .position(|(thread_id, _)| *thread_id == selected_thread_id) + }); let params = self.agent_picker_selection_view_params(selected); self.chat_widget .replace_selection_view_if_present(AGENT_PICKER_VIEW_ID, params); diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index 75a02aff48..5a0e798a60 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -88,6 +88,13 @@ impl App { self.agents_overview.usage_disabled = false; self.repaint_agents_overview(); self.refresh_agents_overview_threads(app_server_client); + if let Some(primary_thread_id) = self.primary_thread_id + && !self + .agent_navigation + .queue_picker_refresh(primary_thread_id) + { + self.refresh_agent_picker_threads(app_server_client, primary_thread_id); + } } AppServerEvent::ServerNotification(notification) => { let request_resolved = matches!( @@ -205,6 +212,7 @@ impl App { return; } + self.handle_agent_picker_visibility_notification(app_server_client, ¬ification); if let ServerNotification::ThreadStarted(started) = ¬ification && let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 4c4491f871..f59717f3de 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -2926,7 +2926,12 @@ impl App { request_id, result, } => { - self.apply_agent_picker_thread_refresh(primary_thread_id, request_id, result); + self.apply_agent_picker_thread_refresh( + app_server, + primary_thread_id, + request_id, + result, + ); } AppEvent::SelectAgentThread(thread_id) => { self.select_agent_thread_and_discard_side(tui, app_server, thread_id) diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index e6f6c39cee..e8cc5b4afa 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -174,7 +174,7 @@ impl App { let mut initial_selected_idx = selected; let items: Vec = self .agent_navigation - .ordered_threads() + .visible_threads() .into_iter() .enumerate() .map(|(idx, (thread_id, entry))| { diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 26b3cc9c80..2c900b72fd 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -105,6 +105,7 @@ mod user_verification_routes; #[path = "tests/worktree_background_terminals_tests.rs"] mod worktree_background_terminals_tests; +use super::agent_navigation::AgentPickerThreadVisibility; use super::*; use crate::app_backtrack::BacktrackSelection; use crate::app_backtrack::BacktrackState; @@ -180,6 +181,7 @@ use codex_app_server_protocol::ThreadSettingsUpdatedNotification; use codex_app_server_protocol::ThreadStartedNotification; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::ThreadUnarchivedNotification; use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::ToolRequestUserInputParams; use codex_app_server_protocol::Turn; @@ -2128,15 +2130,54 @@ async fn archived_untracked_threads_do_not_appear_in_agent_picker() -> Result<() .contains_key(&attachment_thread_id) ); + app.upsert_agent_picker_thread( + archived_thread_id, + Some("Missed archive".to_string()), + Some("worker".to_string()), + /*is_closed*/ false, + ); + app.thread_event_channels + .insert(archived_thread_id, ThreadEventChannel::new(/*capacity*/ 1)); + let request_id = app + .agent_navigation + .begin_picker_refresh(primary_thread_id) + .expect("picker refresh after missed archive"); + app.apply_agent_picker_thread_refresh( + &app_server, + primary_thread_id, + request_id, + Ok(crate::app_event::AgentPickerThreadRefresh { + threads: Vec::new(), + archived_thread_ids: std::collections::HashSet::from([archived_thread_id]), + }), + ); + assert_eq!( + app.thread_event_channels[&archived_thread_id].attachment(), + ThreadEventAttachment::ReplayOnly + ); Box::pin(app.open_agent_picker(&mut app_server)).await; + let archived_picker = render_bottom_popup(&app.chat_widget, /*width*/ 80); + app.handle_app_server_event( + &app_server, + codex_app_server_client::AppServerEvent::ServerNotification(Box::new( + ServerNotification::ThreadUnarchived(ThreadUnarchivedNotification { + thread_id: archived_thread_id.to_string(), + }), + )), + ) + .await; assert_app_snapshot!( "untracked_thread_notifications_agent_picker", - render_bottom_popup(&app.chat_widget, /*width*/ 80) + format!( + "Archived:\n{archived_picker}\nUnarchived:\n{}", + render_bottom_popup(&app.chat_widget, /*width*/ 80) + ) + .replace(&archived_thread_id.to_string(), "[archived]") ); assert_eq!( app.agent_navigation.ordered_thread_ids(), - vec![primary_thread_id] + vec![primary_thread_id, archived_thread_id] ); assert_eq!(app.active_thread_id, Some(primary_thread_id)); Ok(()) @@ -2993,17 +3034,40 @@ async fn select_uncached_agent_thread_still_refreshes_liveness() -> Result<()> { #[tokio::test] async fn open_agent_picker_prompts_when_subagents_disabled() -> Result<()> { - let (mut app, mut app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await; + let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( app.chat_widget.config_ref(), )) .await .expect("embedded app server"); let _ = app.config.features.disable(Feature::Collab); + 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.upsert_agent_picker_thread( + archived_thread_id, + Some("Archived".to_string()), + Some("worker".to_string()), + /*is_closed*/ false, + ); + app.set_agent_picker_thread_visibility(archived_thread_id, AgentPickerThreadVisibility::Hidden); Box::pin(app.open_agent_picker(&mut app_server)).await; - assert!(app.chat_widget.has_active_view()); - assert!(app_event_rx.try_recv().is_err()); + assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 80), @r###" + Enable subagents? + Subagents are disabled in this TUI session. + + + › 1. Yes, enable Save on the server for new threads without changing this + thread + 2. Not now Keep subagents disabled + + enter select · esc back + "###); Ok(()) } diff --git a/codex-rs/tui/src/app/tests/active_reconnect_tests.rs b/codex-rs/tui/src/app/tests/active_reconnect_tests.rs index e1505bfa6d..8894121c94 100644 --- a/codex-rs/tui/src/app/tests/active_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/active_reconnect_tests.rs @@ -354,8 +354,9 @@ async fn reconnect_restores_history_permissions_and_resumes_unsent_input() -> Re ); assert!(app.agent_navigation.begin_picker_refresh(id).is_some()); assert!( - !app.agent_navigation + app.agent_navigation .finish_picker_refresh(id, stale_picker_refresh) + .is_none() ); // Let the rebound timer become due without depending on machine uptime. tokio::time::pause(); 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 c9f919f105..b57bbdcd6b 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -4739,11 +4739,12 @@ fn session_lifecycle_avoids_redundant_subagent_metadata_reads() -> Result<()> { }) .await?; if let AppEvent::AgentPickerThreadsLoaded { - result: Ok(threads), + result: Ok(refresh), .. } = &mut completion { - let child = threads + let child = refresh + .threads .iter_mut() .find(|thread| thread.id == child_thread_id.to_string()) .expect("root-scoped response includes the cached child"); @@ -4753,7 +4754,7 @@ fn session_lifecycle_avoids_redundant_subagent_metadata_reads() -> Result<()> { child.status = ThreadStatus::Active { active_flags: Vec::new(), }; - threads.push(discovered); + refresh.threads.push(discovered); } Box::pin(app.handle_event(&mut tui, &mut app_server, completion)).await?; assert_eq!( diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 480cb4cc69..88a8a74514 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -276,6 +276,12 @@ pub(crate) struct AgentsOverviewThreadRefresh { pub(crate) discovery: Option, } +#[derive(Debug, Default)] +pub(crate) struct AgentPickerThreadRefresh { + pub(crate) threads: Vec, + pub(crate) archived_thread_ids: std::collections::HashSet, +} + #[allow(clippy::large_enum_variant)] #[derive(Debug, IntoStaticStr)] pub(crate) enum AppEvent { @@ -382,7 +388,7 @@ pub(crate) enum AppEvent { AgentPickerThreadsLoaded { primary_thread_id: ThreadId, request_id: Uuid, - result: Result, String>, + result: Result, }, /// Switch the active thread to the selected agent. SelectAgentThread(ThreadId), diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__untracked_thread_notifications_agent_picker.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__untracked_thread_notifications_agent_picker.snap index 8a986b13e0..f2cd3c5b66 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__untracked_thread_notifications_agent_picker.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__untracked_thread_notifications_agent_picker.snap @@ -1,11 +1,21 @@ --- source: tui/src/app/tests.rs -expression: "render_bottom_popup(&app.chat_widget, 80)" +expression: "format!(\"Archived:\\n{archived_picker}\\nUnarchived:\\n{}\",\nrender_bottom_popup(&app.chat_widget,\n80)).replace(&archived_thread_id.to_string(), \"[archived]\")" --- +Archived: Subagents Select an agent to watch. ⌥← previous, ⌥→ next. › 1. • Main [default] (current) 00000000-0000-0000-0000-000000000123 + enter select · esc back +Unarchived: + Subagents + Select an agent to watch. ⌥← previous, ⌥→ next. + + +› 1. • Main [default] (current) 00000000-0000-0000-0000-000000000123 + 2. • Missed archive [worker] [archived] + enter select · esc back