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