diff --git a/assets/icons/circle.svg b/assets/icons/circle.svg
index 1d80edac09e..c33c37f5f9d 100644
--- a/assets/icons/circle.svg
+++ b/assets/icons/circle.svg
@@ -1,3 +1,3 @@
diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs
index 199c453d092..78c941aa399 100644
--- a/crates/sidebar/src/sidebar.rs
+++ b/crates/sidebar/src/sidebar.rs
@@ -303,6 +303,7 @@ enum ListEntry {
highlight_positions: Vec,
has_running_threads: bool,
waiting_thread_count: usize,
+ has_notifications: bool,
is_active: bool,
has_threads: bool,
},
@@ -654,6 +655,10 @@ pub struct Sidebar {
thread_switcher: Option>,
_thread_switcher_subscriptions: Vec,
pending_thread_activation: Option,
+ /// 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,
view: SidebarView,
restoring_tasks: HashMap>,
recent_projects_popover_handle: PopoverMenuHandle,
@@ -762,6 +767,7 @@ impl Sidebar {
thread_switcher: None,
_thread_switcher_subscriptions: Vec::new(),
pending_thread_activation: None,
+ live_thread_statuses: HashMap::new(),
view: SidebarView::default(),
restoring_tasks: HashMap::new(),
recent_projects_popover_handle: PopoverMenuHandle::default(),
@@ -1205,21 +1211,13 @@ impl Sidebar {
let previous = mem::take(&mut self.contents);
- let old_statuses: HashMap = previous
- .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 old_statuses = &self.live_thread_statuses;
let mut entries = Vec::new();
let mut notified_threads = previous.notified_threads;
let mut notified_terminals: HashSet = HashSet::new();
+ let mut new_live_statuses: HashMap =
+ HashMap::new();
let mut current_session_ids: HashSet = HashSet::new();
let mut current_thread_ids: HashSet = HashSet::new();
let mut current_terminal_ids: HashSet = HashSet::new();
@@ -1566,9 +1564,12 @@ impl Sidebar {
// Merge live info into threads and update notification state
// in a single pass.
for thread in &mut threads {
- if let Some(session_id) = &thread.metadata.session_id {
- if let Some(info) = live_info_by_session.get(session_id) {
+ if let Some(session_id) = thread.metadata.session_id.clone() {
+ 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);
+ new_live_statuses.insert(session_id, (status, thread_id));
}
}
@@ -1582,8 +1583,10 @@ impl Sidebar {
if thread.status == AgentThreadStatus::Completed
&& !is_active_thread
- && session_id.as_ref().and_then(|sid| old_statuses.get(sid))
- == Some(&AgentThreadStatus::Running)
+ && session_id
+ .as_ref()
+ .and_then(|sid| old_statuses.get(sid))
+ .is_some_and(|(s, _)| *s == AgentThreadStatus::Running)
{
notified_threads.insert(thread.metadata.thread_id);
}
@@ -1599,13 +1602,35 @@ impl Sidebar {
b_time.cmp(&a_time)
});
} else {
- for info in live_infos {
+ for info in &live_infos {
if info.status == AgentThreadStatus::Running {
has_running_threads = true;
}
if info.status == AgentThreadStatus::WaitingForConfirmation {
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;
}
+ // 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());
entries.push(ListEntry::ProjectHeader {
key: group_key.clone(),
@@ -1705,6 +1738,7 @@ impl Sidebar {
highlight_positions: workspace_highlight_positions,
has_running_threads,
waiting_thread_count,
+ has_notifications: has_thread_notifications || has_terminal_notifications,
is_active,
has_threads,
});
@@ -1717,6 +1751,32 @@ impl Sidebar {
&mut current_thread_ids,
);
} 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::>();
+ 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());
entries.push(ListEntry::ProjectHeader {
key: group_key.clone(),
@@ -1724,6 +1784,7 @@ impl Sidebar {
highlight_positions: Vec::new(),
has_running_threads,
waiting_thread_count,
+ has_notifications: has_thread_notifications || has_terminal_notifications,
is_active,
has_threads,
});
@@ -1749,6 +1810,8 @@ impl Sidebar {
self.terminal_last_accessed
.retain(|id, _| current_terminal_ids.contains(id));
+ self.live_thread_statuses = new_live_statuses;
+
self.contents = SidebarContents {
entries,
notified_threads,
@@ -1862,6 +1925,7 @@ impl Sidebar {
highlight_positions,
has_running_threads,
waiting_thread_count,
+ has_notifications,
is_active: is_active_group,
has_threads,
} => {
@@ -1885,6 +1949,7 @@ impl Sidebar {
highlight_positions,
*has_running_threads,
*waiting_thread_count,
+ *has_notifications,
*is_active_group,
is_selected,
*has_threads,
@@ -1943,6 +2008,7 @@ impl Sidebar {
highlight_positions: &[usize],
has_running_threads: bool,
waiting_thread_count: usize,
+ has_notifications: bool,
is_active: bool,
is_focused: bool,
has_threads: bool,
@@ -2054,6 +2120,16 @@ impl Sidebar {
.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(
div()
@@ -2509,6 +2585,7 @@ impl Sidebar {
highlight_positions,
has_running_threads,
waiting_thread_count,
+ has_notifications,
is_active,
has_threads,
} = self.contents.entries.get(header_idx)?
@@ -2538,6 +2615,7 @@ impl Sidebar {
&highlight_positions,
*has_running_threads,
*waiting_thread_count,
+ *has_notifications,
*is_active,
is_selected,
*has_threads,
diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs
index c65eda086ab..a7a7263daa8 100644
--- a/crates/sidebar/src/sidebar_tests.rs
+++ b/crates/sidebar/src/sidebar_tests.rs
@@ -938,6 +938,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
highlight_positions: Vec::new(),
has_running_threads: false,
waiting_thread_count: 0,
+ has_notifications: false,
is_active: true,
has_threads: true,
},
@@ -1084,6 +1085,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
highlight_positions: Vec::new(),
has_running_threads: false,
waiting_thread_count: 0,
+ has_notifications: false,
is_active: false,
has_threads: false,
},