mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
Keep the subagent picker consistent with thread archive state (#50345)
## Why Cached subagents can remain in the TUI picker after being archived, including when archive notifications are missed. ## What changed - Hide archived threads from the picker and keyboard navigation, and restore them when unarchived. - Refresh both active and archived descendants to reconcile cached visibility, including after dropped server events. Queue a follow-up refresh when needed and preserve visibility changes received during an in-flight refresh. - Preserve picker selection by thread ID as visible rows change, and mark archived thread attachments as replay-only. ## Testing Extend regression coverage for missed archives, unarchive notifications, hidden subagent navigation, and the subagent enable prompt when only archived subagents are cached. GitOrigin-RevId: 9e75906dfe6946b1400583a386922453e2b77ea1
This commit is contained in:
@@ -49,8 +49,9 @@ pub(crate) struct AgentNavigationState {
|
||||
stopped_threads: HashSet<ThreadId>,
|
||||
/// Spawned child threads whose instructions are owned by their parent agent.
|
||||
parent_owned_threads: HashSet<ThreadId>,
|
||||
picker_excluded_threads: HashSet<ThreadId>,
|
||||
/// Coalesces root refreshes while rejecting replies from a previous session.
|
||||
pub(super) picker_refresh: Option<(ThreadId, Uuid)>,
|
||||
pub(super) picker_refresh: Option<AgentPickerRefreshState>,
|
||||
}
|
||||
|
||||
/// 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<ThreadId>,
|
||||
pub(super) follow_up_requested: bool,
|
||||
}
|
||||
|
||||
impl AgentNavigationState {
|
||||
pub(crate) fn begin_picker_refresh(&mut self, thread_id: ThreadId) -> Option<Uuid> {
|
||||
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<AgentPickerRefreshState> {
|
||||
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<ThreadId>) -> 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<ThreadId>,
|
||||
) -> 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<ThreadId>,
|
||||
direction: AgentNavigationDirection,
|
||||
) -> Option<ThreadId> {
|
||||
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]
|
||||
|
||||
@@ -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::<ThreadListResponse>(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::<ThreadListResponse>(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<Vec<Thread>, String>,
|
||||
result: Result<AgentPickerThreadRefresh, String>,
|
||||
) {
|
||||
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<ThreadId> {
|
||||
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<ThreadId>) {
|
||||
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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -174,7 +174,7 @@ impl App {
|
||||
let mut initial_selected_idx = selected;
|
||||
let items: Vec<SelectionItem> = self
|
||||
.agent_navigation
|
||||
.ordered_threads()
|
||||
.visible_threads()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(idx, (thread_id, entry))| {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -276,6 +276,12 @@ pub(crate) struct AgentsOverviewThreadRefresh {
|
||||
pub(crate) discovery: Option<crate::app::agents_overview_discovery::AgentsOverviewDiscovery>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct AgentPickerThreadRefresh {
|
||||
pub(crate) threads: Vec<Thread>,
|
||||
pub(crate) archived_thread_ids: std::collections::HashSet<ThreadId>,
|
||||
}
|
||||
|
||||
#[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<Vec<Thread>, String>,
|
||||
result: Result<AgentPickerThreadRefresh, String>,
|
||||
},
|
||||
/// Switch the active thread to the selected agent.
|
||||
SelectAgentThread(ThreadId),
|
||||
|
||||
+11
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user