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