@@ -65,8 +65,19 @@ impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo {
}
}
-#[derive(Clone, Debug)]
-#[allow(dead_code)]
+#[derive(Clone)]
+struct ThreadEntry {
+ session_info: acp_thread::AgentSessionInfo,
+ icon: IconName,
+ icon_from_external_svg: Option<SharedString>,
+ status: AgentThreadStatus,
+ workspace: Entity<Workspace>,
+ is_live: bool,
+ is_background: bool,
+ highlight_positions: Vec<usize>,
+}
+
+#[derive(Clone)]
enum ListEntry {
ProjectHeader {
path_list: PathList,
@@ -75,17 +86,7 @@ enum ListEntry {
highlight_positions: Vec<usize>,
has_threads: bool,
},
- Thread {
- session_info: acp_thread::AgentSessionInfo,
- icon: IconName,
- icon_from_external_svg: Option<SharedString>,
- status: AgentThreadStatus,
- diff_stats: Option<(usize, usize)>,
- workspace: Entity<Workspace>,
- is_live: bool,
- is_background: bool,
- highlight_positions: Vec<usize>,
- },
+ Thread(ThreadEntry),
ViewMore {
path_list: PathList,
remaining_count: usize,
@@ -97,6 +98,12 @@ enum ListEntry {
},
}
+impl From<ThreadEntry> for ListEntry {
+ fn from(thread: ThreadEntry) -> Self {
+ ListEntry::Thread(thread)
+ }
+}
+
#[derive(Default)]
struct SidebarContents {
entries: Vec<ListEntry>,
@@ -227,7 +234,7 @@ impl Sidebar {
.contents
.entries
.iter()
- .position(|entry| matches!(entry, ListEntry::Thread { .. }))
+ .position(|entry| matches!(entry, ListEntry::Thread(_)))
.or_else(|| {
if this.contents.entries.is_empty() {
None
@@ -416,18 +423,20 @@ impl Sidebar {
.entries
.iter()
.filter_map(|entry| match entry {
- ListEntry::Thread {
- session_info,
- status,
- is_live: true,
- ..
- } => Some((session_info.session_id.clone(), *status)),
+ ListEntry::Thread(thread) if thread.is_live => {
+ Some((thread.session_info.session_id.clone(), thread.status))
+ }
_ => None,
})
.collect();
let mut entries = Vec::new();
let mut notified_threads = previous.notified_threads;
+ // Track all session IDs we add to entries so we can prune stale
+ // notifications without a separate pass at the end.
+ let mut current_session_ids: HashSet<acp::SessionId> = HashSet::new();
+ // Compute active_entry_index inline during the build pass.
+ let mut active_entry_index: Option<usize> = None;
for workspace in workspaces.iter() {
let (path_list, label) = workspace_path_list_and_label(workspace, cx);
@@ -435,17 +444,16 @@ impl Sidebar {
let is_collapsed = self.collapsed_groups.contains(&path_list);
let should_load_threads = !is_collapsed || !query.is_empty();
- let mut threads: Vec<ListEntry> = Vec::new();
+ let mut threads: Vec<ThreadEntry> = Vec::new();
if should_load_threads {
if let Some(ref thread_store) = thread_store {
for meta in thread_store.read(cx).threads_for_paths(&path_list) {
- threads.push(ListEntry::Thread {
+ threads.push(ThreadEntry {
session_info: meta.into(),
icon: IconName::ZedAgent,
icon_from_external_svg: None,
status: AgentThreadStatus::default(),
- diff_stats: None,
workspace: workspace.clone(),
is_live: false,
is_background: false,
@@ -456,76 +464,50 @@ impl Sidebar {
let live_infos = Self::all_thread_infos_for_workspace(workspace, cx);
- for info in &live_infos {
- let Some(existing) = threads.iter_mut().find(|t| {
- matches!(t, ListEntry::Thread { session_info, .. } if session_info.session_id == info.session_id)
- }) else {
- continue;
- };
-
- if let ListEntry::Thread {
- session_info,
- status,
- icon,
- icon_from_external_svg,
- workspace: _,
- is_live,
- is_background,
- ..
- } = existing
- {
- session_info.title = Some(info.title.clone());
- *status = info.status;
- *icon = info.icon;
- *icon_from_external_svg = info.icon_from_external_svg.clone();
- *is_live = true;
- *is_background = info.is_background;
+ if !live_infos.is_empty() {
+ let thread_index_by_session: HashMap<acp::SessionId, usize> = threads
+ .iter()
+ .enumerate()
+ .map(|(i, t)| (t.session_info.session_id.clone(), i))
+ .collect();
+
+ for info in &live_infos {
+ let Some(&idx) = thread_index_by_session.get(&info.session_id) else {
+ continue;
+ };
+
+ let thread = &mut threads[idx];
+ thread.session_info.title = Some(info.title.clone());
+ thread.status = info.status;
+ thread.icon = info.icon;
+ thread.icon_from_external_svg = info.icon_from_external_svg.clone();
+ thread.is_live = true;
+ thread.is_background = info.is_background;
}
}
- // Update notification state for live threads.
+ // Update notification state for live threads in the same pass.
+ let is_active_workspace = active_workspace
+ .as_ref()
+ .is_some_and(|active| active == workspace);
+
for thread in &threads {
- if let ListEntry::Thread {
- workspace: thread_workspace,
- session_info,
- status,
- is_background,
- ..
- } = thread
+ let session_id = &thread.session_info.session_id;
+ if thread.is_background && thread.status == AgentThreadStatus::Completed {
+ notified_threads.insert(session_id.clone());
+ } else if thread.status == AgentThreadStatus::Completed
+ && !is_active_workspace
+ && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running)
{
- let session_id = &session_info.session_id;
- if *is_background && *status == AgentThreadStatus::Completed {
- notified_threads.insert(session_id.clone());
- } else if *status == AgentThreadStatus::Completed
- && active_workspace
- .as_ref()
- .is_none_or(|active| active != thread_workspace)
- && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running)
- {
- notified_threads.insert(session_id.clone());
- }
+ notified_threads.insert(session_id.clone());
+ }
- if active_workspace
- .as_ref()
- .is_some_and(|active| active == thread_workspace)
- && !*is_background
- {
- notified_threads.remove(session_id);
- }
+ if is_active_workspace && !thread.is_background {
+ notified_threads.remove(session_id);
}
}
- threads.sort_by(|a, b| {
- let a_time = match a {
- ListEntry::Thread { session_info, .. } => session_info.updated_at,
- _ => unreachable!(),
- };
- let b_time = match b {
- ListEntry::Thread { session_info, .. } => session_info.updated_at,
- _ => unreachable!(),
- };
- b_time.cmp(&a_time)
- });
+ threads.sort_by(|a, b| b.session_info.updated_at.cmp(&a.session_info.updated_at));
}
if !query.is_empty() {
@@ -535,25 +517,19 @@ impl Sidebar {
fuzzy_match_positions(&query, &label).unwrap_or_default();
let workspace_matched = !workspace_highlight_positions.is_empty();
- let mut matched_threads = Vec::new();
+ let mut matched_threads: Vec<ThreadEntry> = Vec::new();
for mut thread in threads {
- if let ListEntry::Thread {
- session_info,
- highlight_positions,
- ..
- } = &mut thread
- {
- let title = session_info
- .title
- .as_ref()
- .map(|s| s.as_ref())
- .unwrap_or("");
- if let Some(positions) = fuzzy_match_positions(&query, title) {
- *highlight_positions = positions;
- }
- if workspace_matched || !highlight_positions.is_empty() {
- matched_threads.push(thread);
- }
+ let title = thread
+ .session_info
+ .title
+ .as_ref()
+ .map(|s| s.as_ref())
+ .unwrap_or("");
+ if let Some(positions) = fuzzy_match_positions(&query, title) {
+ thread.highlight_positions = positions;
+ }
+ if workspace_matched || !thread.highlight_positions.is_empty() {
+ matched_threads.push(thread);
}
}
@@ -561,6 +537,15 @@ impl Sidebar {
continue;
}
+ if active_entry_index.is_none()
+ && self.focused_thread.is_none()
+ && active_workspace
+ .as_ref()
+ .is_some_and(|active| active == workspace)
+ {
+ active_entry_index = Some(entries.len());
+ }
+
entries.push(ListEntry::ProjectHeader {
path_list: path_list.clone(),
label,
@@ -568,9 +553,33 @@ impl Sidebar {
highlight_positions: workspace_highlight_positions,
has_threads,
});
- entries.extend(matched_threads);
+
+ // Track session IDs and compute active_entry_index as we add
+ // thread entries.
+ for thread in matched_threads {
+ current_session_ids.insert(thread.session_info.session_id.clone());
+ if active_entry_index.is_none() {
+ if let Some(focused) = &self.focused_thread {
+ if &thread.session_info.session_id == focused {
+ active_entry_index = Some(entries.len());
+ }
+ }
+ }
+ entries.push(thread.into());
+ }
} else {
let has_threads = !threads.is_empty();
+
+ // Check if this header is the active entry before pushing it.
+ if active_entry_index.is_none()
+ && self.focused_thread.is_none()
+ && active_workspace
+ .as_ref()
+ .is_some_and(|active| active == workspace)
+ {
+ active_entry_index = Some(entries.len());
+ }
+
entries.push(ListEntry::ProjectHeader {
path_list: path_list.clone(),
label,
@@ -591,7 +600,19 @@ impl Sidebar {
let count = threads_to_show.min(total);
let is_fully_expanded = count >= total;
- entries.extend(threads.into_iter().take(count));
+ // Track session IDs and compute active_entry_index as we add
+ // thread entries.
+ for thread in threads.into_iter().take(count) {
+ current_session_ids.insert(thread.session_info.session_id.clone());
+ if active_entry_index.is_none() {
+ if let Some(focused) = &self.focused_thread {
+ if &thread.session_info.session_id == focused {
+ active_entry_index = Some(entries.len());
+ }
+ }
+ }
+ entries.push(thread.into());
+ }
if total > DEFAULT_THREADS_SHOWN {
entries.push(ListEntry::ViewMore {
@@ -610,16 +631,11 @@ impl Sidebar {
}
}
- // Prune stale entries from notified_threads.
- let current_session_ids: HashSet<&acp::SessionId> = entries
- .iter()
- .filter_map(|e| match e {
- ListEntry::Thread { session_info, .. } => Some(&session_info.session_id),
- _ => None,
- })
- .collect();
+ // Prune stale notifications using the session IDs we collected during
+ // the build pass (no extra scan needed).
notified_threads.retain(|id| current_session_ids.contains(id));
+ self.active_entry_index = active_entry_index;
self.contents = SidebarContents {
entries,
notified_threads,
@@ -639,7 +655,6 @@ impl Sidebar {
let scroll_position = self.list_state.logical_scroll_top();
self.rebuild_contents(cx);
- self.recompute_active_entry_index(cx);
self.list_state.reset(self.contents.entries.len());
self.list_state.scroll_to(scroll_position);
@@ -653,24 +668,6 @@ impl Sidebar {
cx.notify();
}
- fn recompute_active_entry_index(&mut self, cx: &App) {
- self.active_entry_index = if let Some(session_id) = &self.focused_thread {
- self.contents.entries.iter().position(|entry| {
- matches!(entry, ListEntry::Thread { session_info, .. } if &session_info.session_id == session_id)
- })
- } else {
- let active_workspace = self
- .multi_workspace
- .upgrade()
- .map(|mw| mw.read(cx).workspace().clone());
- active_workspace.and_then(|active| {
- self.contents.entries.iter().position(|entry| {
- matches!(entry, ListEntry::ProjectHeader { workspace, .. } if workspace == &active)
- })
- })
- };
- }
-
fn render_list_entry(
&mut self,
ix: usize,
@@ -705,25 +702,7 @@ impl Sidebar {
is_selected,
cx,
),
- ListEntry::Thread {
- session_info,
- icon,
- icon_from_external_svg,
- status,
- workspace,
- highlight_positions,
- ..
- } => self.render_thread(
- ix,
- session_info,
- *icon,
- icon_from_external_svg.clone(),
- *status,
- workspace,
- highlight_positions,
- is_selected,
- cx,
- ),
+ ListEntry::Thread(thread) => self.render_thread(ix, thread, is_selected, cx),
ListEntry::ViewMore {
path_list,
remaining_count,
@@ -975,8 +954,8 @@ impl Sidebar {
})
}
- fn filter_query(&self, cx: &App) -> String {
- self.filter_editor.read(cx).text(cx)
+ fn has_filter_query(&self, cx: &App) -> bool {
+ self.filter_editor.read(cx).buffer().read(cx).is_empty()
}
fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
@@ -1041,13 +1020,9 @@ impl Sidebar {
let workspace = workspace.clone();
self.activate_workspace(&workspace, window, cx);
}
- ListEntry::Thread {
- session_info,
- workspace,
- ..
- } => {
- let session_info = session_info.clone();
- let workspace = workspace.clone();
+ ListEntry::Thread(thread) => {
+ let session_info = thread.session_info.clone();
+ let workspace = thread.workspace.clone();
self.activate_thread(session_info, &workspace, window, cx);
}
ListEntry::ViewMore {
@@ -1144,7 +1119,7 @@ impl Sidebar {
}
}
Some(
- ListEntry::Thread { .. } | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
+ ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
) => {
for i in (0..ix).rev() {
if let Some(ListEntry::ProjectHeader { path_list, .. }) =
@@ -1165,32 +1140,30 @@ impl Sidebar {
fn render_thread(
&self,
ix: usize,
- session_info: &acp_thread::AgentSessionInfo,
- icon: IconName,
- icon_from_external_svg: Option<SharedString>,
- status: AgentThreadStatus,
- workspace: &Entity<Workspace>,
- highlight_positions: &[usize],
+ thread: &ThreadEntry,
is_selected: bool,
cx: &mut Context<Self>,
) -> AnyElement {
- let has_notification = self.contents.is_thread_notified(&session_info.session_id);
+ let has_notification = self
+ .contents
+ .is_thread_notified(&thread.session_info.session_id);
- let title: SharedString = session_info
+ let title: SharedString = thread
+ .session_info
.title
.clone()
.unwrap_or_else(|| "Untitled".into());
- let session_info = session_info.clone();
- let workspace = workspace.clone();
+ let session_info = thread.session_info.clone();
+ let workspace = thread.workspace.clone();
let id = SharedString::from(format!("thread-entry-{}", ix));
ThreadItem::new(id, title)
- .icon(icon)
- .when_some(icon_from_external_svg, |this, svg| {
+ .icon(thread.icon)
+ .when_some(thread.icon_from_external_svg.clone(), |this, svg| {
this.custom_icon_from_external_svg(svg)
})
- .highlight_positions(highlight_positions.to_vec())
- .status(status)
+ .highlight_positions(thread.highlight_positions.to_vec())
+ .status(thread.status)
.notified(has_notification)
.selected(self.focused_thread.as_ref() == Some(&session_info.session_id))
.focused(is_selected)
@@ -1356,7 +1329,7 @@ impl Render for Sidebar {
let ui_font = theme::setup_ui_font(window, cx);
let is_focused = self.focus_handle.is_focused(window)
|| self.filter_editor.focus_handle(cx).is_focused(window);
- let has_query = !self.filter_query(cx).is_empty();
+ let has_query = self.has_filter_query(cx);
let focus_tooltip_label = if is_focused {
"Focus Workspace"
@@ -1666,19 +1639,15 @@ mod tests {
};
format!("{} [{}]{}", icon, label, selected)
}
- ListEntry::Thread {
- session_info,
- status,
- is_live,
- ..
- } => {
- let title = session_info
+ ListEntry::Thread(thread) => {
+ let title = thread
+ .session_info
.title
.as_ref()
.map(|s| s.as_ref())
.unwrap_or("Untitled");
- let active = if *is_live { " *" } else { "" };
- let status_str = match status {
+ let active = if thread.is_live { " *" } else { "" };
+ let status_str = match thread.status {
AgentThreadStatus::Running => " (running)",
AgentThreadStatus::Error => " (error)",
AgentThreadStatus::WaitingForConfirmation => " (waiting)",
@@ -1686,7 +1655,7 @@ mod tests {
};
let notified = if sidebar
.contents
- .is_thread_notified(&session_info.session_id)
+ .is_thread_notified(&thread.session_info.session_id)
{
" (!)"
} else {
@@ -2007,7 +1976,7 @@ mod tests {
has_threads: true,
},
// Thread with default (Completed) status, not active
- ListEntry::Thread {
+ ListEntry::Thread(ThreadEntry {
session_info: acp_thread::AgentSessionInfo {
session_id: acp::SessionId::new(Arc::from("t-1")),
cwd: None,
@@ -2018,14 +1987,13 @@ mod tests {
icon: IconName::ZedAgent,
icon_from_external_svg: None,
status: AgentThreadStatus::Completed,
- diff_stats: None,
workspace: workspace.clone(),
is_live: false,
is_background: false,
highlight_positions: Vec::new(),
- },
+ }),
// Active thread with Running status
- ListEntry::Thread {
+ ListEntry::Thread(ThreadEntry {
session_info: acp_thread::AgentSessionInfo {
session_id: acp::SessionId::new(Arc::from("t-2")),
cwd: None,
@@ -2036,14 +2004,13 @@ mod tests {
icon: IconName::ZedAgent,
icon_from_external_svg: None,
status: AgentThreadStatus::Running,
- diff_stats: None,
workspace: workspace.clone(),
is_live: true,
is_background: false,
highlight_positions: Vec::new(),
- },
+ }),
// Active thread with Error status
- ListEntry::Thread {
+ ListEntry::Thread(ThreadEntry {
session_info: acp_thread::AgentSessionInfo {
session_id: acp::SessionId::new(Arc::from("t-3")),
cwd: None,
@@ -2054,14 +2021,13 @@ mod tests {
icon: IconName::ZedAgent,
icon_from_external_svg: None,
status: AgentThreadStatus::Error,
- diff_stats: None,
workspace: workspace.clone(),
is_live: true,
is_background: false,
highlight_positions: Vec::new(),
- },
+ }),
// Thread with WaitingForConfirmation status, not active
- ListEntry::Thread {
+ ListEntry::Thread(ThreadEntry {
session_info: acp_thread::AgentSessionInfo {
session_id: acp::SessionId::new(Arc::from("t-4")),
cwd: None,
@@ -2072,14 +2038,13 @@ mod tests {
icon: IconName::ZedAgent,
icon_from_external_svg: None,
status: AgentThreadStatus::WaitingForConfirmation,
- diff_stats: None,
workspace: workspace.clone(),
is_live: false,
is_background: false,
highlight_positions: Vec::new(),
- },
+ }),
// Background thread that completed (should show notification)
- ListEntry::Thread {
+ ListEntry::Thread(ThreadEntry {
session_info: acp_thread::AgentSessionInfo {
session_id: acp::SessionId::new(Arc::from("t-5")),
cwd: None,
@@ -2090,12 +2055,11 @@ mod tests {
icon: IconName::ZedAgent,
icon_from_external_svg: None,
status: AgentThreadStatus::Completed,
- diff_stats: None,
workspace: workspace.clone(),
is_live: true,
is_background: true,
highlight_positions: Vec::new(),
- },
+ }),
// View More entry
ListEntry::ViewMore {
path_list: expanded_path.clone(),
@@ -3475,7 +3439,7 @@ mod tests {
let active_entry = sidebar.active_entry_index
.and_then(|ix| sidebar.contents.entries.get(ix));
assert!(
- matches!(active_entry, Some(ListEntry::Thread { session_info, .. }) if session_info.session_id == session_id_a),
+ matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_a),
"Active entry should be the clicked thread"
);
});
@@ -3531,7 +3495,7 @@ mod tests {
.active_entry_index
.and_then(|ix| sidebar.contents.entries.get(ix));
assert!(
- matches!(active_entry, Some(ListEntry::Thread { session_info, .. }) if session_info.session_id == session_id_b),
+ matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b),
"Active entry should be the cross-workspace thread"
);
});
@@ -3626,7 +3590,7 @@ mod tests {
.active_entry_index
.and_then(|ix| sidebar.contents.entries.get(ix));
assert!(
- matches!(active_entry, Some(ListEntry::Thread { session_info, .. }) if session_info.session_id == session_id_b2),
+ matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b2),
"Active entry should be the focused thread"
);
});