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