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