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