1use acp_thread::ThreadStatus;
2use agent_ui::{AgentPanel, AgentPanelEvent};
3use chrono::{Datelike, Local, NaiveDate, TimeDelta};
4use db::kvp::KEY_VALUE_STORE;
5use fs::Fs;
6use fuzzy::StringMatchCandidate;
7use gpui::{
8 App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, SharedString,
9 Subscription, Task, Window, px,
10};
11use picker::{Picker, PickerDelegate};
12use project::Event as ProjectEvent;
13use recent_projects::{RecentProjectEntry, get_recent_projects};
14use std::fmt::Display;
15
16use std::collections::{HashMap, HashSet};
17
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20use theme::ActiveTheme;
21use ui::utils::TRAFFIC_LIGHT_PADDING;
22use ui::{Divider, DividerColor, KeyBinding, ListSubHeader, Tab, ThreadItem, Tooltip, prelude::*};
23use ui_input::ErasedEditor;
24use util::ResultExt as _;
25use workspace::{
26 FocusWorkspaceSidebar, MultiWorkspace, NewWorkspaceInWindow, Sidebar as WorkspaceSidebar,
27 SidebarEvent, ToggleWorkspaceSidebar, Workspace,
28};
29
30#[derive(Clone, Debug, PartialEq, Eq)]
31pub enum AgentThreadStatus {
32 Running,
33 Completed,
34}
35
36#[derive(Clone, Debug)]
37struct AgentThreadInfo {
38 title: SharedString,
39 status: AgentThreadStatus,
40 icon: IconName,
41}
42
43const LAST_THREAD_TITLES_KEY: &str = "sidebar-last-thread-titles";
44
45const DEFAULT_WIDTH: Pixels = px(320.0);
46const MIN_WIDTH: Pixels = px(200.0);
47const MAX_WIDTH: Pixels = px(800.0);
48const MAX_MATCHES: usize = 100;
49
50#[derive(Clone)]
51struct WorkspaceThreadEntry {
52 index: usize,
53 worktree_label: SharedString,
54 full_path: SharedString,
55 thread_info: Option<AgentThreadInfo>,
56}
57
58impl WorkspaceThreadEntry {
59 fn new(
60 index: usize,
61 workspace: &Entity<Workspace>,
62 persisted_titles: &HashMap<String, String>,
63 cx: &App,
64 ) -> Self {
65 let workspace_ref = workspace.read(cx);
66
67 let worktrees: Vec<_> = workspace_ref
68 .worktrees(cx)
69 .filter(|worktree| worktree.read(cx).is_visible())
70 .map(|worktree| worktree.read(cx).abs_path())
71 .collect();
72
73 let worktree_names: Vec<String> = worktrees
74 .iter()
75 .filter_map(|path| {
76 path.file_name()
77 .map(|name| name.to_string_lossy().to_string())
78 })
79 .collect();
80
81 let worktree_label: SharedString = if worktree_names.is_empty() {
82 format!("Workspace {}", index + 1).into()
83 } else {
84 worktree_names.join(", ").into()
85 };
86
87 let full_path: SharedString = worktrees
88 .iter()
89 .map(|path| path.to_string_lossy().to_string())
90 .collect::<Vec<_>>()
91 .join("\n")
92 .into();
93
94 let thread_info = Self::thread_info(workspace, cx).or_else(|| {
95 if worktrees.is_empty() {
96 return None;
97 }
98 let path_key = sorted_paths_key(&worktrees);
99 let title = persisted_titles.get(&path_key)?;
100 Some(AgentThreadInfo {
101 title: SharedString::from(title.clone()),
102 status: AgentThreadStatus::Completed,
103 icon: IconName::ZedAgent,
104 })
105 });
106
107 Self {
108 index,
109 worktree_label,
110 full_path,
111 thread_info,
112 }
113 }
114
115 fn thread_info(workspace: &Entity<Workspace>, cx: &App) -> Option<AgentThreadInfo> {
116 let agent_panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
117 let agent_panel_ref = agent_panel.read(cx);
118
119 let thread_view = agent_panel_ref.as_active_thread_view(cx)?.read(cx);
120 let thread = thread_view.thread.read(cx);
121
122 let icon = thread_view.agent_icon;
123 let title = thread.title();
124
125 let status = match thread.status() {
126 ThreadStatus::Generating => AgentThreadStatus::Running,
127 ThreadStatus::Idle => AgentThreadStatus::Completed,
128 };
129 Some(AgentThreadInfo {
130 title,
131 status,
132 icon,
133 })
134 }
135}
136
137#[derive(Clone)]
138enum SidebarEntry {
139 Separator(SharedString),
140 WorkspaceThread(WorkspaceThreadEntry),
141 RecentProject(RecentProjectEntry),
142}
143
144impl SidebarEntry {
145 fn searchable_text(&self) -> &str {
146 match self {
147 SidebarEntry::Separator(_) => "",
148 SidebarEntry::WorkspaceThread(entry) => entry.worktree_label.as_ref(),
149 SidebarEntry::RecentProject(entry) => entry.name.as_ref(),
150 }
151 }
152}
153
154#[derive(Clone)]
155struct SidebarMatch {
156 entry: SidebarEntry,
157 positions: Vec<usize>,
158}
159
160struct WorkspacePickerDelegate {
161 multi_workspace: Entity<MultiWorkspace>,
162 entries: Vec<SidebarEntry>,
163 active_workspace_index: usize,
164 workspace_thread_count: usize,
165 /// All recent projects including what's filtered out of entries
166 /// used to add unopened projects to entries on rebuild
167 recent_projects: Vec<RecentProjectEntry>,
168 recent_project_thread_titles: HashMap<SharedString, SharedString>,
169 matches: Vec<SidebarMatch>,
170 selected_index: usize,
171 query: String,
172 hovered_thread_item: Option<usize>,
173 notified_workspaces: HashSet<usize>,
174}
175
176impl WorkspacePickerDelegate {
177 fn new(multi_workspace: Entity<MultiWorkspace>) -> Self {
178 Self {
179 multi_workspace,
180 entries: Vec::new(),
181 active_workspace_index: 0,
182 workspace_thread_count: 0,
183 recent_projects: Vec::new(),
184 recent_project_thread_titles: HashMap::new(),
185 matches: Vec::new(),
186 selected_index: 0,
187 query: String::new(),
188 hovered_thread_item: None,
189 notified_workspaces: HashSet::new(),
190 }
191 }
192
193 fn set_entries(
194 &mut self,
195 workspace_threads: Vec<WorkspaceThreadEntry>,
196 active_workspace_index: usize,
197 cx: &App,
198 ) {
199 if let Some(hovered_index) = self.hovered_thread_item {
200 let still_exists = workspace_threads
201 .iter()
202 .any(|thread| thread.index == hovered_index);
203 if !still_exists {
204 self.hovered_thread_item = None;
205 }
206 }
207
208 let old_statuses: HashMap<usize, AgentThreadStatus> = self
209 .entries
210 .iter()
211 .filter_map(|entry| match entry {
212 SidebarEntry::WorkspaceThread(thread) => thread
213 .thread_info
214 .as_ref()
215 .map(|info| (thread.index, info.status.clone())),
216 _ => None,
217 })
218 .collect();
219
220 for thread in &workspace_threads {
221 if let Some(info) = &thread.thread_info {
222 if info.status == AgentThreadStatus::Completed
223 && thread.index != active_workspace_index
224 {
225 if old_statuses.get(&thread.index) == Some(&AgentThreadStatus::Running) {
226 self.notified_workspaces.insert(thread.index);
227 }
228 }
229 }
230 }
231
232 if self.active_workspace_index != active_workspace_index {
233 self.notified_workspaces.remove(&active_workspace_index);
234 }
235 self.active_workspace_index = active_workspace_index;
236 self.workspace_thread_count = workspace_threads.len();
237 self.rebuild_entries(workspace_threads, cx);
238 }
239
240 fn set_recent_projects(&mut self, recent_projects: Vec<RecentProjectEntry>, cx: &App) {
241 self.recent_project_thread_titles.clear();
242 if let Some(map) = read_thread_title_map() {
243 for entry in &recent_projects {
244 let path_key = sorted_paths_key(&entry.paths);
245 if let Some(title) = map.get(&path_key) {
246 self.recent_project_thread_titles
247 .insert(entry.full_path.clone(), title.clone().into());
248 }
249 }
250 }
251
252 self.recent_projects = recent_projects;
253
254 let workspace_threads: Vec<WorkspaceThreadEntry> = self
255 .entries
256 .iter()
257 .filter_map(|entry| match entry {
258 SidebarEntry::WorkspaceThread(thread) => Some(thread.clone()),
259 _ => None,
260 })
261 .collect();
262 self.rebuild_entries(workspace_threads, cx);
263 }
264
265 fn open_workspace_path_sets(&self, cx: &App) -> Vec<Vec<Arc<Path>>> {
266 self.multi_workspace
267 .read(cx)
268 .workspaces()
269 .iter()
270 .map(|workspace| {
271 let mut paths = workspace.read(cx).root_paths(cx);
272 paths.sort();
273 paths
274 })
275 .collect()
276 }
277
278 fn rebuild_entries(&mut self, workspace_threads: Vec<WorkspaceThreadEntry>, cx: &App) {
279 let open_path_sets = self.open_workspace_path_sets(cx);
280
281 self.entries.clear();
282
283 if !workspace_threads.is_empty() {
284 self.entries
285 .push(SidebarEntry::Separator("Active Workspaces".into()));
286 for thread in workspace_threads {
287 self.entries.push(SidebarEntry::WorkspaceThread(thread));
288 }
289 }
290
291 let recent: Vec<_> = self
292 .recent_projects
293 .iter()
294 .filter(|project| {
295 let mut project_paths: Vec<&Path> =
296 project.paths.iter().map(|p| p.as_path()).collect();
297 project_paths.sort();
298 !open_path_sets.iter().any(|open_paths| {
299 open_paths.len() == project_paths.len()
300 && open_paths
301 .iter()
302 .zip(&project_paths)
303 .all(|(a, b)| a.as_ref() == *b)
304 })
305 })
306 .cloned()
307 .collect();
308
309 if !recent.is_empty() {
310 let today = Local::now().naive_local().date();
311 let mut current_bucket: Option<TimeBucket> = None;
312
313 for project in recent {
314 let entry_date = project.timestamp.with_timezone(&Local).naive_local().date();
315 let bucket = TimeBucket::from_dates(today, entry_date);
316
317 if current_bucket != Some(bucket) {
318 current_bucket = Some(bucket);
319 self.entries
320 .push(SidebarEntry::Separator(bucket.to_string().into()));
321 }
322
323 self.entries.push(SidebarEntry::RecentProject(project));
324 }
325 }
326 }
327}
328
329#[derive(Clone, Copy, Debug, PartialEq, Eq)]
330enum TimeBucket {
331 Today,
332 Yesterday,
333 ThisWeek,
334 PastWeek,
335 All,
336}
337
338impl TimeBucket {
339 fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
340 if date == reference {
341 return TimeBucket::Today;
342 }
343
344 if date == reference - TimeDelta::days(1) {
345 return TimeBucket::Yesterday;
346 }
347
348 let week = date.iso_week();
349
350 if reference.iso_week() == week {
351 return TimeBucket::ThisWeek;
352 }
353
354 let last_week = (reference - TimeDelta::days(7)).iso_week();
355
356 if week == last_week {
357 return TimeBucket::PastWeek;
358 }
359
360 TimeBucket::All
361 }
362}
363
364impl Display for TimeBucket {
365 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
366 match self {
367 TimeBucket::Today => write!(f, "Today"),
368 TimeBucket::Yesterday => write!(f, "Yesterday"),
369 TimeBucket::ThisWeek => write!(f, "This Week"),
370 TimeBucket::PastWeek => write!(f, "Past Week"),
371 TimeBucket::All => write!(f, "All"),
372 }
373 }
374}
375
376fn open_recent_project(paths: Vec<PathBuf>, window: &mut Window, cx: &mut App) {
377 let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() else {
378 return;
379 };
380
381 cx.defer(move |cx| {
382 if let Some(task) = handle
383 .update(cx, |multi_workspace, window, cx| {
384 multi_workspace.open_project(paths, window, cx)
385 })
386 .log_err()
387 {
388 task.detach_and_log_err(cx);
389 }
390 });
391}
392
393impl PickerDelegate for WorkspacePickerDelegate {
394 type ListItem = AnyElement;
395
396 fn match_count(&self) -> usize {
397 self.matches.len()
398 }
399
400 fn selected_index(&self) -> usize {
401 self.selected_index
402 }
403
404 fn set_selected_index(
405 &mut self,
406 ix: usize,
407 _window: &mut Window,
408 _cx: &mut Context<Picker<Self>>,
409 ) {
410 self.selected_index = ix;
411 }
412
413 fn can_select(
414 &mut self,
415 ix: usize,
416 _window: &mut Window,
417 _cx: &mut Context<Picker<Self>>,
418 ) -> bool {
419 match self.matches.get(ix) {
420 Some(SidebarMatch {
421 entry: SidebarEntry::Separator(_),
422 ..
423 }) => false,
424 _ => true,
425 }
426 }
427
428 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
429 "Search…".into()
430 }
431
432 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
433 if self.query.is_empty() {
434 None
435 } else {
436 Some("No threads match your search.".into())
437 }
438 }
439
440 fn update_matches(
441 &mut self,
442 query: String,
443 window: &mut Window,
444 cx: &mut Context<Picker<Self>>,
445 ) -> Task<()> {
446 let query_changed = self.query != query;
447 self.query = query.clone();
448 if query_changed {
449 self.hovered_thread_item = None;
450 }
451 let entries = self.entries.clone();
452
453 if query.is_empty() {
454 self.matches = entries
455 .into_iter()
456 .map(|entry| SidebarMatch {
457 entry,
458 positions: Vec::new(),
459 })
460 .collect();
461
462 let separator_offset = if self.workspace_thread_count > 0 {
463 1
464 } else {
465 0
466 };
467 self.selected_index = (self.active_workspace_index + separator_offset)
468 .min(self.matches.len().saturating_sub(1));
469 return Task::ready(());
470 }
471
472 let executor = cx.background_executor().clone();
473 cx.spawn_in(window, async move |picker, cx| {
474 let matches = cx
475 .background_spawn(async move {
476 let data_entries: Vec<(usize, &SidebarEntry)> = entries
477 .iter()
478 .enumerate()
479 .filter(|(_, entry)| !matches!(entry, SidebarEntry::Separator(_)))
480 .collect();
481
482 let candidates: Vec<StringMatchCandidate> = data_entries
483 .iter()
484 .enumerate()
485 .map(|(candidate_index, (_, entry))| {
486 StringMatchCandidate::new(candidate_index, entry.searchable_text())
487 })
488 .collect();
489
490 let search_matches = fuzzy::match_strings(
491 &candidates,
492 &query,
493 false,
494 true,
495 MAX_MATCHES,
496 &Default::default(),
497 executor,
498 )
499 .await;
500
501 let mut workspace_matches = Vec::new();
502 let mut project_matches = Vec::new();
503
504 for search_match in search_matches {
505 let (original_index, _) = data_entries[search_match.candidate_id];
506 let entry = entries[original_index].clone();
507 let sidebar_match = SidebarMatch {
508 positions: search_match.positions,
509 entry: entry.clone(),
510 };
511 match entry {
512 SidebarEntry::WorkspaceThread(_) => {
513 workspace_matches.push(sidebar_match)
514 }
515 SidebarEntry::RecentProject(_) => project_matches.push(sidebar_match),
516 SidebarEntry::Separator(_) => {}
517 }
518 }
519
520 let mut result = Vec::new();
521 if !workspace_matches.is_empty() {
522 result.push(SidebarMatch {
523 entry: SidebarEntry::Separator("Active Workspaces".into()),
524 positions: Vec::new(),
525 });
526 result.extend(workspace_matches);
527 }
528 if !project_matches.is_empty() {
529 result.push(SidebarMatch {
530 entry: SidebarEntry::Separator("Recent Projects".into()),
531 positions: Vec::new(),
532 });
533 result.extend(project_matches);
534 }
535 result
536 })
537 .await;
538
539 picker
540 .update_in(cx, |picker, _window, _cx| {
541 picker.delegate.matches = matches;
542 if picker.delegate.matches.is_empty() {
543 picker.delegate.selected_index = 0;
544 } else {
545 let first_selectable = picker
546 .delegate
547 .matches
548 .iter()
549 .position(|m| !matches!(m.entry, SidebarEntry::Separator(_)))
550 .unwrap_or(0);
551 picker.delegate.selected_index = first_selectable;
552 }
553 })
554 .log_err();
555 })
556 }
557
558 fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
559 let Some(selected_match) = self.matches.get(self.selected_index) else {
560 return;
561 };
562
563 match &selected_match.entry {
564 SidebarEntry::Separator(_) => {}
565 SidebarEntry::WorkspaceThread(thread_entry) => {
566 let target_index = thread_entry.index;
567 self.multi_workspace.update(cx, |multi_workspace, cx| {
568 multi_workspace.activate_index(target_index, window, cx);
569 });
570 }
571 SidebarEntry::RecentProject(project_entry) => {
572 let paths = project_entry.paths.clone();
573 open_recent_project(paths, window, cx);
574 }
575 }
576 }
577
578 fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
579
580 fn render_match(
581 &self,
582 index: usize,
583 selected: bool,
584 _window: &mut Window,
585 cx: &mut Context<Picker<Self>>,
586 ) -> Option<Self::ListItem> {
587 let match_entry = self.matches.get(index)?;
588 let SidebarMatch { entry, positions } = match_entry;
589
590 match entry {
591 SidebarEntry::Separator(title) => Some(
592 v_flex()
593 .when(index > 0, |this| {
594 this.mt_1()
595 .gap_2()
596 .child(Divider::horizontal().color(DividerColor::BorderFaded))
597 })
598 .child(ListSubHeader::new(title.clone()).inset(true))
599 .into_any_element(),
600 ),
601 SidebarEntry::WorkspaceThread(thread_entry) => {
602 let worktree_label = thread_entry.worktree_label.clone();
603 let full_path = thread_entry.full_path.clone();
604 let thread_info = thread_entry.thread_info.clone();
605 let workspace_index = thread_entry.index;
606 let multi_workspace = self.multi_workspace.clone();
607 let workspace_count = self.multi_workspace.read(cx).workspaces().len();
608 let is_hovered = self.hovered_thread_item == Some(workspace_index);
609
610 let remove_btn = IconButton::new(
611 format!("remove-workspace-{}", workspace_index),
612 IconName::Close,
613 )
614 .icon_size(IconSize::Small)
615 .icon_color(Color::Muted)
616 .tooltip(Tooltip::text("Remove Workspace"))
617 .on_click({
618 let multi_workspace = multi_workspace;
619 move |_, window, cx| {
620 multi_workspace.update(cx, |mw, cx| {
621 mw.remove_workspace(workspace_index, window, cx);
622 });
623 }
624 });
625
626 let has_notification = self.notified_workspaces.contains(&workspace_index);
627 let thread_subtitle = thread_info.as_ref().map(|info| info.title.clone());
628 let running = matches!(
629 thread_info,
630 Some(AgentThreadInfo {
631 status: AgentThreadStatus::Running,
632 ..
633 })
634 );
635
636 Some(
637 ThreadItem::new(
638 ("workspace-item", thread_entry.index),
639 thread_subtitle.unwrap_or("New Thread".into()),
640 )
641 .icon(
642 thread_info
643 .as_ref()
644 .map_or(IconName::ZedAgent, |info| info.icon),
645 )
646 .running(running)
647 .generation_done(has_notification)
648 .selected(selected)
649 .worktree(worktree_label.clone())
650 .worktree_highlight_positions(positions.clone())
651 .when(workspace_count > 1, |item| item.action_slot(remove_btn))
652 .hovered(is_hovered)
653 .on_hover(cx.listener(move |picker, is_hovered, _window, cx| {
654 let mut changed = false;
655 if *is_hovered {
656 if picker.delegate.hovered_thread_item != Some(workspace_index) {
657 picker.delegate.hovered_thread_item = Some(workspace_index);
658 changed = true;
659 }
660 } else if picker.delegate.hovered_thread_item == Some(workspace_index) {
661 picker.delegate.hovered_thread_item = None;
662 changed = true;
663 }
664 if changed {
665 cx.notify();
666 }
667 }))
668 .when(!full_path.is_empty(), |this| {
669 this.tooltip(move |_, cx| {
670 Tooltip::with_meta(worktree_label.clone(), None, full_path.clone(), cx)
671 })
672 })
673 .into_any_element(),
674 )
675 }
676 SidebarEntry::RecentProject(project_entry) => {
677 let name = project_entry.name.clone();
678 let full_path = project_entry.full_path.clone();
679 let item_id: SharedString =
680 format!("recent-project-{:?}", project_entry.workspace_id).into();
681
682 Some(
683 ThreadItem::new(item_id, name.clone())
684 .icon(IconName::Folder)
685 .selected(selected)
686 .highlight_positions(positions.clone())
687 .tooltip(move |_, cx| {
688 Tooltip::with_meta(name.clone(), None, full_path.clone(), cx)
689 })
690 .into_any_element(),
691 )
692 }
693 }
694 }
695
696 fn render_editor(
697 &self,
698 editor: &Arc<dyn ErasedEditor>,
699 window: &mut Window,
700 cx: &mut Context<Picker<Self>>,
701 ) -> Div {
702 h_flex()
703 .h(Tab::container_height(cx))
704 .w_full()
705 .px_2()
706 .gap_2()
707 .justify_between()
708 .border_b_1()
709 .border_color(cx.theme().colors().border)
710 .child(
711 Icon::new(IconName::MagnifyingGlass)
712 .color(Color::Muted)
713 .size(IconSize::Small),
714 )
715 .child(editor.render(window, cx))
716 }
717}
718
719pub struct Sidebar {
720 multi_workspace: Entity<MultiWorkspace>,
721 width: Pixels,
722 picker: Entity<Picker<WorkspacePickerDelegate>>,
723 _subscription: Subscription,
724 _project_subscriptions: Vec<Subscription>,
725 _agent_panel_subscriptions: Vec<Subscription>,
726 _thread_subscriptions: Vec<Subscription>,
727 #[cfg(any(test, feature = "test-support"))]
728 test_thread_infos: HashMap<usize, AgentThreadInfo>,
729 #[cfg(any(test, feature = "test-support"))]
730 test_recent_project_thread_titles: HashMap<SharedString, SharedString>,
731 _fetch_recent_projects: Task<()>,
732}
733
734impl EventEmitter<SidebarEvent> for Sidebar {}
735
736impl Sidebar {
737 pub fn new(
738 multi_workspace: Entity<MultiWorkspace>,
739 window: &mut Window,
740 cx: &mut Context<Self>,
741 ) -> Self {
742 let delegate = WorkspacePickerDelegate::new(multi_workspace.clone());
743 let picker = cx.new(|cx| {
744 Picker::list(delegate, window, cx)
745 .max_height(None)
746 .show_scrollbar(true)
747 .modal(false)
748 });
749
750 let subscription = cx.observe_in(
751 &multi_workspace,
752 window,
753 |this, multi_workspace, window, cx| {
754 this.queue_refresh(multi_workspace, window, cx);
755 },
756 );
757
758 let fetch_recent_projects = {
759 let picker = picker.downgrade();
760 let fs = <dyn Fs>::global(cx);
761 cx.spawn_in(window, async move |_this, cx| {
762 let projects = get_recent_projects(None, None, fs).await;
763
764 cx.update(|window, cx| {
765 if let Some(picker) = picker.upgrade() {
766 picker.update(cx, |picker, cx| {
767 picker.delegate.set_recent_projects(projects, cx);
768 let query = picker.query(cx);
769 picker.update_matches(query, window, cx);
770 });
771 }
772 })
773 .log_err();
774 })
775 };
776
777 let mut this = Self {
778 multi_workspace,
779 width: DEFAULT_WIDTH,
780 picker,
781 _subscription: subscription,
782 _project_subscriptions: Vec::new(),
783 _agent_panel_subscriptions: Vec::new(),
784 _thread_subscriptions: Vec::new(),
785 #[cfg(any(test, feature = "test-support"))]
786 test_thread_infos: HashMap::new(),
787 #[cfg(any(test, feature = "test-support"))]
788 test_recent_project_thread_titles: HashMap::new(),
789 _fetch_recent_projects: fetch_recent_projects,
790 };
791 this.queue_refresh(this.multi_workspace.clone(), window, cx);
792 this
793 }
794
795 fn subscribe_to_projects(
796 &mut self,
797 window: &mut Window,
798 cx: &mut Context<Self>,
799 ) -> Vec<Subscription> {
800 let projects: Vec<_> = self
801 .multi_workspace
802 .read(cx)
803 .workspaces()
804 .iter()
805 .map(|w| w.read(cx).project().clone())
806 .collect();
807
808 projects
809 .iter()
810 .map(|project| {
811 cx.subscribe_in(
812 project,
813 window,
814 |this, _project, event, window, cx| match event {
815 ProjectEvent::WorktreeAdded(_)
816 | ProjectEvent::WorktreeRemoved(_)
817 | ProjectEvent::WorktreeOrderChanged => {
818 this.queue_refresh(this.multi_workspace.clone(), window, cx);
819 }
820 _ => {}
821 },
822 )
823 })
824 .collect()
825 }
826
827 fn build_workspace_thread_entries(
828 &self,
829 multi_workspace: &MultiWorkspace,
830 cx: &App,
831 ) -> (Vec<WorkspaceThreadEntry>, usize) {
832 let persisted_titles = read_thread_title_map().unwrap_or_default();
833
834 #[allow(unused_mut)]
835 let mut entries: Vec<WorkspaceThreadEntry> = multi_workspace
836 .workspaces()
837 .iter()
838 .enumerate()
839 .map(|(index, workspace)| {
840 WorkspaceThreadEntry::new(index, workspace, &persisted_titles, cx)
841 })
842 .collect();
843
844 #[cfg(any(test, feature = "test-support"))]
845 for (index, info) in &self.test_thread_infos {
846 if let Some(entry) = entries.get_mut(*index) {
847 entry.thread_info = Some(info.clone());
848 }
849 }
850
851 (entries, multi_workspace.active_workspace_index())
852 }
853
854 #[cfg(any(test, feature = "test-support"))]
855 pub fn set_test_recent_projects(
856 &self,
857 projects: Vec<RecentProjectEntry>,
858 cx: &mut Context<Self>,
859 ) {
860 self.picker.update(cx, |picker, _cx| {
861 picker.delegate.recent_projects = projects;
862 });
863 }
864
865 #[cfg(any(test, feature = "test-support"))]
866 pub fn set_test_thread_info(
867 &mut self,
868 index: usize,
869 title: SharedString,
870 status: AgentThreadStatus,
871 ) {
872 self.test_thread_infos.insert(
873 index,
874 AgentThreadInfo {
875 title,
876 status,
877 icon: IconName::ZedAgent,
878 },
879 );
880 }
881
882 #[cfg(any(test, feature = "test-support"))]
883 pub fn set_test_recent_project_thread_title(
884 &mut self,
885 full_path: SharedString,
886 title: SharedString,
887 cx: &mut Context<Self>,
888 ) {
889 self.test_recent_project_thread_titles
890 .insert(full_path.clone(), title.clone());
891 self.picker.update(cx, |picker, _cx| {
892 picker
893 .delegate
894 .recent_project_thread_titles
895 .insert(full_path, title);
896 });
897 }
898
899 fn subscribe_to_agent_panels(
900 &mut self,
901 window: &mut Window,
902 cx: &mut Context<Self>,
903 ) -> Vec<Subscription> {
904 let workspaces: Vec<_> = self.multi_workspace.read(cx).workspaces().to_vec();
905
906 workspaces
907 .iter()
908 .map(|workspace| {
909 if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
910 cx.subscribe_in(
911 &agent_panel,
912 window,
913 |this, _, _event: &AgentPanelEvent, window, cx| {
914 this.queue_refresh(this.multi_workspace.clone(), window, cx);
915 },
916 )
917 } else {
918 // Panel hasn't loaded yet — observe the workspace so we
919 // re-subscribe once the panel appears on its dock.
920 cx.observe_in(workspace, window, |this, _, window, cx| {
921 this.queue_refresh(this.multi_workspace.clone(), window, cx);
922 })
923 }
924 })
925 .collect()
926 }
927
928 fn subscribe_to_threads(
929 &mut self,
930 window: &mut Window,
931 cx: &mut Context<Self>,
932 ) -> Vec<Subscription> {
933 let workspaces: Vec<_> = self.multi_workspace.read(cx).workspaces().to_vec();
934
935 workspaces
936 .iter()
937 .filter_map(|workspace| {
938 let agent_panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
939 let thread = agent_panel.read(cx).active_agent_thread(cx)?;
940 Some(cx.observe_in(&thread, window, |this, _, window, cx| {
941 this.queue_refresh(this.multi_workspace.clone(), window, cx);
942 }))
943 })
944 .collect()
945 }
946
947 fn persist_thread_titles(
948 &self,
949 entries: &[WorkspaceThreadEntry],
950 multi_workspace: &Entity<MultiWorkspace>,
951 cx: &mut Context<Self>,
952 ) {
953 let mut map = read_thread_title_map().unwrap_or_default();
954 let workspaces = multi_workspace.read(cx).workspaces().to_vec();
955 let mut changed = false;
956
957 for (workspace, entry) in workspaces.iter().zip(entries.iter()) {
958 if let Some(ref info) = entry.thread_info {
959 let paths: Vec<_> = workspace
960 .read(cx)
961 .worktrees(cx)
962 .map(|wt| wt.read(cx).abs_path())
963 .collect();
964 if paths.is_empty() {
965 continue;
966 }
967 let path_key = sorted_paths_key(&paths);
968 let title = info.title.to_string();
969 if map.get(&path_key) != Some(&title) {
970 map.insert(path_key, title);
971 changed = true;
972 }
973 }
974 }
975
976 if changed {
977 if let Some(json) = serde_json::to_string(&map).log_err() {
978 cx.background_spawn(async move {
979 KEY_VALUE_STORE
980 .write_kvp(LAST_THREAD_TITLES_KEY.into(), json)
981 .await
982 .log_err();
983 })
984 .detach();
985 }
986 }
987 }
988
989 fn queue_refresh(
990 &mut self,
991 multi_workspace: Entity<MultiWorkspace>,
992 window: &mut Window,
993 cx: &mut Context<Self>,
994 ) {
995 cx.defer_in(window, move |this, window, cx| {
996 this._project_subscriptions = this.subscribe_to_projects(window, cx);
997 this._agent_panel_subscriptions = this.subscribe_to_agent_panels(window, cx);
998 this._thread_subscriptions = this.subscribe_to_threads(window, cx);
999 let (entries, active_index) = multi_workspace.read_with(cx, |multi_workspace, cx| {
1000 this.build_workspace_thread_entries(multi_workspace, cx)
1001 });
1002
1003 this.persist_thread_titles(&entries, &multi_workspace, cx);
1004
1005 let had_notifications = !this.picker.read(cx).delegate.notified_workspaces.is_empty();
1006 this.picker.update(cx, |picker, cx| {
1007 picker.delegate.set_entries(entries, active_index, cx);
1008 let query = picker.query(cx);
1009 picker.update_matches(query, window, cx);
1010 });
1011 let has_notifications = !this.picker.read(cx).delegate.notified_workspaces.is_empty();
1012 if had_notifications != has_notifications {
1013 multi_workspace.update(cx, |_, cx| cx.notify());
1014 }
1015 });
1016 }
1017}
1018
1019impl WorkspaceSidebar for Sidebar {
1020 fn width(&self, _cx: &App) -> Pixels {
1021 self.width
1022 }
1023
1024 fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
1025 self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
1026 cx.notify();
1027 }
1028
1029 fn has_notifications(&self, cx: &App) -> bool {
1030 !self.picker.read(cx).delegate.notified_workspaces.is_empty()
1031 }
1032}
1033
1034impl Focusable for Sidebar {
1035 fn focus_handle(&self, cx: &App) -> FocusHandle {
1036 self.picker.read(cx).focus_handle(cx)
1037 }
1038}
1039
1040fn sorted_paths_key<P: AsRef<Path>>(paths: &[P]) -> String {
1041 let mut sorted: Vec<String> = paths
1042 .iter()
1043 .map(|p| p.as_ref().to_string_lossy().to_string())
1044 .collect();
1045 sorted.sort();
1046 sorted.join("\n")
1047}
1048
1049fn read_thread_title_map() -> Option<HashMap<String, String>> {
1050 let json = KEY_VALUE_STORE
1051 .read_kvp(LAST_THREAD_TITLES_KEY)
1052 .log_err()
1053 .flatten()?;
1054 serde_json::from_str(&json).log_err()
1055}
1056
1057impl Render for Sidebar {
1058 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1059 let titlebar_height = ui::utils::platform_title_bar_height(window);
1060 let ui_font = theme::setup_ui_font(window, cx);
1061 let is_focused = self.focus_handle(cx).is_focused(window);
1062
1063 let focus_tooltip_label = if is_focused {
1064 "Focus Workspace"
1065 } else {
1066 "Focus Sidebar"
1067 };
1068
1069 v_flex()
1070 .id("workspace-sidebar")
1071 .key_context("WorkspaceSidebar")
1072 .font(ui_font)
1073 .h_full()
1074 .w(self.width)
1075 .bg(cx.theme().colors().surface_background)
1076 .border_r_1()
1077 .border_color(cx.theme().colors().border)
1078 .child(
1079 h_flex()
1080 .flex_none()
1081 .h(titlebar_height)
1082 .w_full()
1083 .mt_px()
1084 .pb_px()
1085 .pr_1()
1086 .when(cfg!(target_os = "macos"), |this| {
1087 this.pl(px(TRAFFIC_LIGHT_PADDING))
1088 })
1089 .when(cfg!(not(target_os = "macos")), |this| this.pl_2())
1090 .justify_between()
1091 .border_b_1()
1092 .border_color(cx.theme().colors().border)
1093 .child({
1094 let focus_handle = cx.focus_handle();
1095 IconButton::new("close-sidebar", IconName::WorkspaceNavOpen)
1096 .icon_size(IconSize::Small)
1097 .tooltip(Tooltip::element(move |_, cx| {
1098 v_flex()
1099 .gap_1()
1100 .child(
1101 h_flex()
1102 .gap_2()
1103 .justify_between()
1104 .child(Label::new("Close Sidebar"))
1105 .child(KeyBinding::for_action_in(
1106 &ToggleWorkspaceSidebar,
1107 &focus_handle,
1108 cx,
1109 )),
1110 )
1111 .child(
1112 h_flex()
1113 .pt_1()
1114 .gap_2()
1115 .border_t_1()
1116 .border_color(cx.theme().colors().border_variant)
1117 .justify_between()
1118 .child(Label::new(focus_tooltip_label))
1119 .child(KeyBinding::for_action_in(
1120 &FocusWorkspaceSidebar,
1121 &focus_handle,
1122 cx,
1123 )),
1124 )
1125 .into_any_element()
1126 }))
1127 .on_click(cx.listener(|_this, _, _window, cx| {
1128 cx.emit(SidebarEvent::Close);
1129 }))
1130 })
1131 .child(
1132 IconButton::new("new-workspace", IconName::Plus)
1133 .icon_size(IconSize::Small)
1134 .tooltip(|_window, cx| {
1135 Tooltip::for_action("New Workspace", &NewWorkspaceInWindow, cx)
1136 })
1137 .on_click(cx.listener(|this, _, window, cx| {
1138 this.multi_workspace.update(cx, |multi_workspace, cx| {
1139 multi_workspace.create_workspace(window, cx);
1140 });
1141 })),
1142 ),
1143 )
1144 .child(self.picker.clone())
1145 }
1146}
1147
1148#[cfg(test)]
1149mod tests {
1150 use super::*;
1151 use feature_flags::FeatureFlagAppExt as _;
1152 use fs::FakeFs;
1153 use gpui::TestAppContext;
1154 use settings::SettingsStore;
1155
1156 fn init_test(cx: &mut TestAppContext) {
1157 cx.update(|cx| {
1158 let settings_store = SettingsStore::test(cx);
1159 cx.set_global(settings_store);
1160 theme::init(theme::LoadThemes::JustBase, cx);
1161 editor::init(cx);
1162 cx.update_flags(false, vec!["agent-v2".into()]);
1163 });
1164 }
1165
1166 fn set_thread_info_and_refresh(
1167 sidebar: &Entity<Sidebar>,
1168 multi_workspace: &Entity<MultiWorkspace>,
1169 index: usize,
1170 title: &str,
1171 status: AgentThreadStatus,
1172 cx: &mut gpui::VisualTestContext,
1173 ) {
1174 sidebar.update_in(cx, |s, _window, _cx| {
1175 s.set_test_thread_info(index, SharedString::from(title.to_string()), status.clone());
1176 });
1177 multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1178 cx.run_until_parked();
1179 }
1180
1181 fn has_notifications(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) -> bool {
1182 sidebar.read_with(cx, |s, cx| s.has_notifications(cx))
1183 }
1184
1185 #[gpui::test]
1186 async fn test_notification_on_running_to_completed_transition(cx: &mut TestAppContext) {
1187 init_test(cx);
1188 let fs = FakeFs::new(cx.executor());
1189 cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
1190 let project = project::Project::test(fs, [], cx).await;
1191
1192 let (multi_workspace, cx) =
1193 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1194
1195 let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| {
1196 let mw_handle = cx.entity();
1197 cx.new(|cx| Sidebar::new(mw_handle, window, cx))
1198 });
1199 multi_workspace.update_in(cx, |mw, window, cx| {
1200 mw.register_sidebar(sidebar.clone(), window, cx);
1201 });
1202 cx.run_until_parked();
1203
1204 // Create a second workspace and switch to it so workspace 0 is background.
1205 multi_workspace.update_in(cx, |mw, window, cx| {
1206 mw.create_workspace(window, cx);
1207 });
1208 cx.run_until_parked();
1209 multi_workspace.update_in(cx, |mw, window, cx| {
1210 mw.activate_index(1, window, cx);
1211 });
1212 cx.run_until_parked();
1213
1214 assert!(
1215 !has_notifications(&sidebar, cx),
1216 "should have no notifications initially"
1217 );
1218
1219 set_thread_info_and_refresh(
1220 &sidebar,
1221 &multi_workspace,
1222 0,
1223 "Test Thread",
1224 AgentThreadStatus::Running,
1225 cx,
1226 );
1227
1228 assert!(
1229 !has_notifications(&sidebar, cx),
1230 "Running status alone should not create a notification"
1231 );
1232
1233 set_thread_info_and_refresh(
1234 &sidebar,
1235 &multi_workspace,
1236 0,
1237 "Test Thread",
1238 AgentThreadStatus::Completed,
1239 cx,
1240 );
1241
1242 assert!(
1243 has_notifications(&sidebar, cx),
1244 "Running → Completed transition should create a notification"
1245 );
1246 }
1247
1248 #[gpui::test]
1249 async fn test_no_notification_for_active_workspace(cx: &mut TestAppContext) {
1250 init_test(cx);
1251 let fs = FakeFs::new(cx.executor());
1252 cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
1253 let project = project::Project::test(fs, [], cx).await;
1254
1255 let (multi_workspace, cx) =
1256 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1257
1258 let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| {
1259 let mw_handle = cx.entity();
1260 cx.new(|cx| Sidebar::new(mw_handle, window, cx))
1261 });
1262 multi_workspace.update_in(cx, |mw, window, cx| {
1263 mw.register_sidebar(sidebar.clone(), window, cx);
1264 });
1265 cx.run_until_parked();
1266
1267 // Workspace 0 is the active workspace — thread completes while
1268 // the user is already looking at it.
1269 set_thread_info_and_refresh(
1270 &sidebar,
1271 &multi_workspace,
1272 0,
1273 "Test Thread",
1274 AgentThreadStatus::Running,
1275 cx,
1276 );
1277 set_thread_info_and_refresh(
1278 &sidebar,
1279 &multi_workspace,
1280 0,
1281 "Test Thread",
1282 AgentThreadStatus::Completed,
1283 cx,
1284 );
1285
1286 assert!(
1287 !has_notifications(&sidebar, cx),
1288 "should not notify for the workspace the user is already looking at"
1289 );
1290 }
1291
1292 #[gpui::test]
1293 async fn test_notification_cleared_on_workspace_activation(cx: &mut TestAppContext) {
1294 init_test(cx);
1295 let fs = FakeFs::new(cx.executor());
1296 cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
1297 let project = project::Project::test(fs, [], cx).await;
1298
1299 let (multi_workspace, cx) =
1300 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1301
1302 let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| {
1303 let mw_handle = cx.entity();
1304 cx.new(|cx| Sidebar::new(mw_handle, window, cx))
1305 });
1306 multi_workspace.update_in(cx, |mw, window, cx| {
1307 mw.register_sidebar(sidebar.clone(), window, cx);
1308 });
1309 cx.run_until_parked();
1310
1311 // Create a second workspace so we can switch away and back.
1312 multi_workspace.update_in(cx, |mw, window, cx| {
1313 mw.create_workspace(window, cx);
1314 });
1315 cx.run_until_parked();
1316
1317 // Switch to workspace 1 so workspace 0 becomes a background workspace.
1318 multi_workspace.update_in(cx, |mw, window, cx| {
1319 mw.activate_index(1, window, cx);
1320 });
1321 cx.run_until_parked();
1322
1323 // Thread on workspace 0 transitions Running → Completed while
1324 // the user is looking at workspace 1.
1325 set_thread_info_and_refresh(
1326 &sidebar,
1327 &multi_workspace,
1328 0,
1329 "Test Thread",
1330 AgentThreadStatus::Running,
1331 cx,
1332 );
1333 set_thread_info_and_refresh(
1334 &sidebar,
1335 &multi_workspace,
1336 0,
1337 "Test Thread",
1338 AgentThreadStatus::Completed,
1339 cx,
1340 );
1341
1342 assert!(
1343 has_notifications(&sidebar, cx),
1344 "background workspace completion should create a notification"
1345 );
1346
1347 // Switching back to workspace 0 should clear the notification.
1348 multi_workspace.update_in(cx, |mw, window, cx| {
1349 mw.activate_index(0, window, cx);
1350 });
1351 cx.run_until_parked();
1352
1353 assert!(
1354 !has_notifications(&sidebar, cx),
1355 "notification should be cleared when workspace becomes active"
1356 );
1357 }
1358}