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:
Eric Traut
2026-10-02 15:56:32 +00:00
committed by copyberry
parent 4cedd0caac
commit 84d5437b6e
10 changed files with 398 additions and 101 deletions
+123 -31
View File
@@ -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]
+167 -57
View File
@@ -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, &notification);
if let ServerNotification::ThreadStarted(started) = &notification
&& let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
+6 -1
View File
@@ -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)
+1 -1
View File
@@ -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))| {
+69 -5
View File
@@ -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!(
+7 -1
View File
@@ -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),
@@ -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