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