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