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, 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}