sidebar.rs

   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}