sidebar: Show pending/unread state in project header when collapsed (#57322)

Closes AI-285

Similar to how we display whether there are running threads or a thread
waiting for permission in the collapsed version of the project's header
in the sidebar, I was missing the "unread" state from being shown. I had
to change the approach here as to how we extract this information
because the previous method was relying on observing the state of the
list entries within the sidebar at every `rebuild_contents` run, and
given there aren't any list entires when the project is collapsed, it
wouldn't work.
Release Notes:

- Agent: Added the notification indicator on collapsed project headers
in the sidebar when a thread completes.
This commit is contained in:
Danilo Leal 2026-05-21 16:35:43 -03:00 committed by GitHub
parent 7afcc87927
commit 7ec36d3661
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 97 additions and 17 deletions

View file

@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 11C9.65685 11 11 9.65685 11 8C11 6.34315 9.65685 5 8 5C6.34315 5 5 6.34315 5 8C5 9.65685 6.34315 11 8 11Z" fill="black"/> <path d="M8 12C10.2091 12 12 10.2091 12 8C12 5.79087 10.2091 4 8 4C5.79087 4 4 5.79087 4 8C4 10.2091 5.79087 12 8 12Z" fill="#C6CAD0"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 237 B

After

Width:  |  Height:  |  Size: 239 B

View file

@ -303,6 +303,7 @@ enum ListEntry {
highlight_positions: Vec<usize>, highlight_positions: Vec<usize>,
has_running_threads: bool, has_running_threads: bool,
waiting_thread_count: usize, waiting_thread_count: usize,
has_notifications: bool,
is_active: bool, is_active: bool,
has_threads: bool, has_threads: bool,
}, },
@ -654,6 +655,10 @@ pub struct Sidebar {
thread_switcher: Option<Entity<ThreadSwitcher>>, thread_switcher: Option<Entity<ThreadSwitcher>>,
_thread_switcher_subscriptions: Vec<gpui::Subscription>, _thread_switcher_subscriptions: Vec<gpui::Subscription>,
pending_thread_activation: Option<agent_ui::ThreadId>, pending_thread_activation: Option<agent_ui::ThreadId>,
/// Persists live thread statuses across rebuilds so that Running→Completed
/// transitions can be detected even when the group is collapsed (and
/// thread entries are not present in the list).
live_thread_statuses: HashMap<acp::SessionId, (AgentThreadStatus, ThreadId)>,
view: SidebarView, view: SidebarView,
restoring_tasks: HashMap<agent_ui::ThreadId, Task<()>>, restoring_tasks: HashMap<agent_ui::ThreadId, Task<()>>,
recent_projects_popover_handle: PopoverMenuHandle<SidebarRecentProjects>, recent_projects_popover_handle: PopoverMenuHandle<SidebarRecentProjects>,
@ -762,6 +767,7 @@ impl Sidebar {
thread_switcher: None, thread_switcher: None,
_thread_switcher_subscriptions: Vec::new(), _thread_switcher_subscriptions: Vec::new(),
pending_thread_activation: None, pending_thread_activation: None,
live_thread_statuses: HashMap::new(),
view: SidebarView::default(), view: SidebarView::default(),
restoring_tasks: HashMap::new(), restoring_tasks: HashMap::new(),
recent_projects_popover_handle: PopoverMenuHandle::default(), recent_projects_popover_handle: PopoverMenuHandle::default(),
@ -1205,21 +1211,13 @@ impl Sidebar {
let previous = mem::take(&mut self.contents); let previous = mem::take(&mut self.contents);
let old_statuses: HashMap<acp::SessionId, AgentThreadStatus> = previous let old_statuses = &self.live_thread_statuses;
.entries
.iter()
.filter_map(|entry| match entry {
ListEntry::Thread(thread) if thread.is_live => {
let sid = thread.metadata.session_id.clone()?;
Some((sid, thread.status))
}
_ => None,
})
.collect();
let mut entries = Vec::new(); let mut entries = Vec::new();
let mut notified_threads = previous.notified_threads; let mut notified_threads = previous.notified_threads;
let mut notified_terminals: HashSet<TerminalId> = HashSet::new(); let mut notified_terminals: HashSet<TerminalId> = HashSet::new();
let mut new_live_statuses: HashMap<acp::SessionId, (AgentThreadStatus, ThreadId)> =
HashMap::new();
let mut current_session_ids: HashSet<acp::SessionId> = HashSet::new(); let mut current_session_ids: HashSet<acp::SessionId> = HashSet::new();
let mut current_thread_ids: HashSet<agent_ui::ThreadId> = HashSet::new(); let mut current_thread_ids: HashSet<agent_ui::ThreadId> = HashSet::new();
let mut current_terminal_ids: HashSet<TerminalId> = HashSet::new(); let mut current_terminal_ids: HashSet<TerminalId> = HashSet::new();
@ -1566,9 +1564,12 @@ impl Sidebar {
// Merge live info into threads and update notification state // Merge live info into threads and update notification state
// in a single pass. // in a single pass.
for thread in &mut threads { for thread in &mut threads {
if let Some(session_id) = &thread.metadata.session_id { if let Some(session_id) = thread.metadata.session_id.clone() {
if let Some(info) = live_info_by_session.get(session_id) { if let Some(&info) = live_info_by_session.get(&session_id) {
let status = info.status;
let thread_id = thread.metadata.thread_id;
thread.apply_active_info(info); thread.apply_active_info(info);
new_live_statuses.insert(session_id, (status, thread_id));
} }
} }
@ -1582,8 +1583,10 @@ impl Sidebar {
if thread.status == AgentThreadStatus::Completed if thread.status == AgentThreadStatus::Completed
&& !is_active_thread && !is_active_thread
&& session_id.as_ref().and_then(|sid| old_statuses.get(sid)) && session_id
== Some(&AgentThreadStatus::Running) .as_ref()
.and_then(|sid| old_statuses.get(sid))
.is_some_and(|(s, _)| *s == AgentThreadStatus::Running)
{ {
notified_threads.insert(thread.metadata.thread_id); notified_threads.insert(thread.metadata.thread_id);
} }
@ -1599,13 +1602,35 @@ impl Sidebar {
b_time.cmp(&a_time) b_time.cmp(&a_time)
}); });
} else { } else {
for info in live_infos { for info in &live_infos {
if info.status == AgentThreadStatus::Running { if info.status == AgentThreadStatus::Running {
has_running_threads = true; has_running_threads = true;
} }
if info.status == AgentThreadStatus::WaitingForConfirmation { if info.status == AgentThreadStatus::WaitingForConfirmation {
waiting_thread_count += 1; waiting_thread_count += 1;
} }
// Resolve the thread_id for this session so we can
// track its status and detect transitions even while
// the group is collapsed.
let thread_id = old_statuses
.get(&info.session_id)
.map(|(_, tid)| *tid)
.or_else(|| {
ThreadMetadataStore::global(cx)
.read(cx)
.entry_by_session(&info.session_id)
.map(|m| m.thread_id)
});
if let Some(thread_id) = thread_id {
let old_status = old_statuses.get(&info.session_id).map(|(s, _)| *s);
new_live_statuses.insert(info.session_id.clone(), (info.status, thread_id));
if info.status == AgentThreadStatus::Completed
&& old_status == Some(AgentThreadStatus::Running)
{
notified_threads.insert(thread_id);
}
}
} }
} }
@ -1698,6 +1723,14 @@ impl Sidebar {
continue; continue;
} }
// Check for notifications: threads that completed while not active.
let has_thread_notifications = matched_threads
.iter()
.any(|t| notified_threads.contains(&t.metadata.thread_id));
let has_terminal_notifications = matched_terminals
.iter()
.any(|t| notified_terminals.contains(&t.metadata.terminal_id));
project_header_indices.push(entries.len()); project_header_indices.push(entries.len());
entries.push(ListEntry::ProjectHeader { entries.push(ListEntry::ProjectHeader {
key: group_key.clone(), key: group_key.clone(),
@ -1705,6 +1738,7 @@ impl Sidebar {
highlight_positions: workspace_highlight_positions, highlight_positions: workspace_highlight_positions,
has_running_threads, has_running_threads,
waiting_thread_count, waiting_thread_count,
has_notifications: has_thread_notifications || has_terminal_notifications,
is_active, is_active,
has_threads, has_threads,
}); });
@ -1717,6 +1751,32 @@ impl Sidebar {
&mut current_thread_ids, &mut current_thread_ids,
); );
} else { } else {
let has_terminal_notifications = terminals
.iter()
.any(|t| notified_terminals.contains(&t.metadata.terminal_id));
// When collapsed, threads aren't loaded into `threads`, so we
// query the store for thread IDs to check notifications and
// to prevent the retain below from purging them.
let has_thread_notifications = if threads.is_empty() && !notified_threads.is_empty()
{
let thread_store = ThreadMetadataStore::global(cx);
let store = thread_store.read(cx);
let group_thread_ids = store
.entries_for_main_worktree_path(group_key.path_list(), group_host.as_ref())
.chain(store.entries_for_path(group_key.path_list(), group_host.as_ref()))
.map(|m| m.thread_id)
.collect::<HashSet<_>>();
current_thread_ids.extend(group_thread_ids.iter());
group_thread_ids
.iter()
.any(|id| notified_threads.contains(id))
} else {
threads
.iter()
.any(|t| notified_threads.contains(&t.metadata.thread_id))
};
project_header_indices.push(entries.len()); project_header_indices.push(entries.len());
entries.push(ListEntry::ProjectHeader { entries.push(ListEntry::ProjectHeader {
key: group_key.clone(), key: group_key.clone(),
@ -1724,6 +1784,7 @@ impl Sidebar {
highlight_positions: Vec::new(), highlight_positions: Vec::new(),
has_running_threads, has_running_threads,
waiting_thread_count, waiting_thread_count,
has_notifications: has_thread_notifications || has_terminal_notifications,
is_active, is_active,
has_threads, has_threads,
}); });
@ -1749,6 +1810,8 @@ impl Sidebar {
self.terminal_last_accessed self.terminal_last_accessed
.retain(|id, _| current_terminal_ids.contains(id)); .retain(|id, _| current_terminal_ids.contains(id));
self.live_thread_statuses = new_live_statuses;
self.contents = SidebarContents { self.contents = SidebarContents {
entries, entries,
notified_threads, notified_threads,
@ -1862,6 +1925,7 @@ impl Sidebar {
highlight_positions, highlight_positions,
has_running_threads, has_running_threads,
waiting_thread_count, waiting_thread_count,
has_notifications,
is_active: is_active_group, is_active: is_active_group,
has_threads, has_threads,
} => { } => {
@ -1885,6 +1949,7 @@ impl Sidebar {
highlight_positions, highlight_positions,
*has_running_threads, *has_running_threads,
*waiting_thread_count, *waiting_thread_count,
*has_notifications,
*is_active_group, *is_active_group,
is_selected, is_selected,
*has_threads, *has_threads,
@ -1943,6 +2008,7 @@ impl Sidebar {
highlight_positions: &[usize], highlight_positions: &[usize],
has_running_threads: bool, has_running_threads: bool,
waiting_thread_count: usize, waiting_thread_count: usize,
has_notifications: bool,
is_active: bool, is_active: bool,
is_focused: bool, is_focused: bool,
has_threads: bool, has_threads: bool,
@ -2054,6 +2120,16 @@ impl Sidebar {
.tooltip(Tooltip::text(tooltip_text)), .tooltip(Tooltip::text(tooltip_text)),
) )
}) })
.when(
has_notifications && !has_running_threads && waiting_thread_count == 0,
|this| {
this.child(
Icon::new(IconName::Circle)
.size(IconSize::Small)
.color(Color::Accent),
)
},
)
}) })
.child( .child(
div() div()
@ -2509,6 +2585,7 @@ impl Sidebar {
highlight_positions, highlight_positions,
has_running_threads, has_running_threads,
waiting_thread_count, waiting_thread_count,
has_notifications,
is_active, is_active,
has_threads, has_threads,
} = self.contents.entries.get(header_idx)? } = self.contents.entries.get(header_idx)?
@ -2538,6 +2615,7 @@ impl Sidebar {
&highlight_positions, &highlight_positions,
*has_running_threads, *has_running_threads,
*waiting_thread_count, *waiting_thread_count,
*has_notifications,
*is_active, *is_active,
is_selected, is_selected,
*has_threads, *has_threads,

View file

@ -938,6 +938,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
highlight_positions: Vec::new(), highlight_positions: Vec::new(),
has_running_threads: false, has_running_threads: false,
waiting_thread_count: 0, waiting_thread_count: 0,
has_notifications: false,
is_active: true, is_active: true,
has_threads: true, has_threads: true,
}, },
@ -1084,6 +1085,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
highlight_positions: Vec::new(), highlight_positions: Vec::new(),
has_running_threads: false, has_running_threads: false,
waiting_thread_count: 0, waiting_thread_count: 0,
has_notifications: false,
is_active: false, is_active: false,
has_threads: false, has_threads: false,
}, },