sidebar.rs

   1use acp_thread::ThreadStatus;
   2use action_log::DiffStats;
   3use agent_client_protocol::{self as acp};
   4use agent_settings::AgentSettings;
   5use agent_ui::thread_metadata_store::{SidebarThreadMetadataStore, ThreadMetadata};
   6use agent_ui::threads_archive_view::{
   7    ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp,
   8};
   9use agent_ui::{
  10    Agent, AgentPanel, AgentPanelEvent, DEFAULT_THREAD_TITLE, NewThread, RemoveSelectedThread,
  11};
  12use chrono::Utc;
  13use editor::Editor;
  14use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _};
  15use gpui::{
  16    Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, KeyContext, ListState,
  17    Pixels, Render, SharedString, WeakEntity, Window, WindowHandle, list, prelude::*, px,
  18};
  19use menu::{
  20    Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious,
  21};
  22use project::{Event as ProjectEvent, linked_worktree_short_name};
  23use recent_projects::sidebar_recent_projects::SidebarRecentProjects;
  24use ui::utils::platform_title_bar_height;
  25
  26use settings::Settings as _;
  27use std::collections::{HashMap, HashSet};
  28use std::mem;
  29use std::rc::Rc;
  30use theme::ActiveTheme;
  31use ui::{
  32    AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, HighlightedLabel, KeyBinding,
  33    PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, ThreadItemWorktreeInfo, TintColor, Tooltip,
  34    WithScrollbar, prelude::*,
  35};
  36use util::ResultExt as _;
  37use util::path_list::PathList;
  38use workspace::{
  39    AddFolderToProject, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, Open,
  40    Sidebar as WorkspaceSidebar, SidebarSide, ToggleWorkspaceSidebar, Workspace, WorkspaceId,
  41    sidebar_side_context_menu,
  42};
  43
  44use zed_actions::OpenRecent;
  45use zed_actions::editor::{MoveDown, MoveUp};
  46
  47use zed_actions::agents_sidebar::FocusSidebarFilter;
  48
  49use crate::project_group_builder::ProjectGroupBuilder;
  50
  51mod project_group_builder;
  52
  53#[cfg(test)]
  54mod sidebar_tests;
  55
  56gpui::actions!(
  57    agents_sidebar,
  58    [
  59        /// Creates a new thread in the currently selected or active project group.
  60        NewThreadInGroup,
  61        /// Toggles between the thread list and the archive view.
  62        ToggleArchive,
  63    ]
  64);
  65
  66const DEFAULT_WIDTH: Pixels = px(300.0);
  67const MIN_WIDTH: Pixels = px(200.0);
  68const MAX_WIDTH: Pixels = px(800.0);
  69const DEFAULT_THREADS_SHOWN: usize = 5;
  70
  71#[derive(Debug, Default)]
  72enum SidebarView {
  73    #[default]
  74    ThreadList,
  75    Archive(Entity<ThreadsArchiveView>),
  76}
  77
  78#[derive(Clone, Debug)]
  79struct ActiveThreadInfo {
  80    session_id: acp::SessionId,
  81    title: SharedString,
  82    status: AgentThreadStatus,
  83    icon: IconName,
  84    icon_from_external_svg: Option<SharedString>,
  85    is_background: bool,
  86    is_title_generating: bool,
  87    diff_stats: DiffStats,
  88}
  89
  90impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo {
  91    fn from(info: &ActiveThreadInfo) -> Self {
  92        Self {
  93            session_id: info.session_id.clone(),
  94            work_dirs: None,
  95            title: Some(info.title.clone()),
  96            updated_at: Some(Utc::now()),
  97            created_at: Some(Utc::now()),
  98            meta: None,
  99        }
 100    }
 101}
 102
 103#[derive(Clone)]
 104enum ThreadEntryWorkspace {
 105    Open(Entity<Workspace>),
 106    Closed(PathList),
 107}
 108
 109#[derive(Clone)]
 110struct WorktreeInfo {
 111    name: SharedString,
 112    full_path: SharedString,
 113    highlight_positions: Vec<usize>,
 114}
 115
 116#[derive(Clone)]
 117struct ThreadEntry {
 118    agent: Agent,
 119    session_info: acp_thread::AgentSessionInfo,
 120    icon: IconName,
 121    icon_from_external_svg: Option<SharedString>,
 122    status: AgentThreadStatus,
 123    workspace: ThreadEntryWorkspace,
 124    is_live: bool,
 125    is_background: bool,
 126    is_title_generating: bool,
 127    highlight_positions: Vec<usize>,
 128    worktrees: Vec<WorktreeInfo>,
 129    diff_stats: DiffStats,
 130}
 131
 132impl ThreadEntry {
 133    /// Updates this thread entry with active thread information.
 134    ///
 135    /// The existing [`ThreadEntry`] was likely deserialized from the database
 136    /// but if we have a correspond thread already loaded we want to apply the
 137    /// live information.
 138    fn apply_active_info(&mut self, info: &ActiveThreadInfo) {
 139        self.session_info.title = Some(info.title.clone());
 140        self.status = info.status;
 141        self.icon = info.icon;
 142        self.icon_from_external_svg = info.icon_from_external_svg.clone();
 143        self.is_live = true;
 144        self.is_background = info.is_background;
 145        self.is_title_generating = info.is_title_generating;
 146        self.diff_stats = info.diff_stats;
 147    }
 148}
 149
 150#[derive(Clone)]
 151enum ListEntry {
 152    ProjectHeader {
 153        path_list: PathList,
 154        label: SharedString,
 155        workspace: Entity<Workspace>,
 156        highlight_positions: Vec<usize>,
 157        has_running_threads: bool,
 158        waiting_thread_count: usize,
 159        is_active: bool,
 160    },
 161    Thread(ThreadEntry),
 162    ViewMore {
 163        path_list: PathList,
 164        is_fully_expanded: bool,
 165    },
 166    NewThread {
 167        path_list: PathList,
 168        workspace: Entity<Workspace>,
 169        is_active_draft: bool,
 170    },
 171}
 172
 173#[cfg(test)]
 174impl ListEntry {
 175    fn workspace(&self) -> Option<Entity<Workspace>> {
 176        match self {
 177            ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
 178            ListEntry::Thread(thread_entry) => match &thread_entry.workspace {
 179                ThreadEntryWorkspace::Open(workspace) => Some(workspace.clone()),
 180                ThreadEntryWorkspace::Closed(_) => None,
 181            },
 182            ListEntry::ViewMore { .. } => None,
 183            ListEntry::NewThread { workspace, .. } => Some(workspace.clone()),
 184        }
 185    }
 186
 187    fn session_id(&self) -> Option<&acp::SessionId> {
 188        match self {
 189            ListEntry::Thread(thread_entry) => Some(&thread_entry.session_info.session_id),
 190            _ => None,
 191        }
 192    }
 193}
 194
 195impl From<ThreadEntry> for ListEntry {
 196    fn from(thread: ThreadEntry) -> Self {
 197        ListEntry::Thread(thread)
 198    }
 199}
 200
 201#[derive(Default)]
 202struct SidebarContents {
 203    entries: Vec<ListEntry>,
 204    notified_threads: HashSet<acp::SessionId>,
 205    project_header_indices: Vec<usize>,
 206    has_open_projects: bool,
 207}
 208
 209impl SidebarContents {
 210    fn is_thread_notified(&self, session_id: &acp::SessionId) -> bool {
 211        self.notified_threads.contains(session_id)
 212    }
 213}
 214
 215fn fuzzy_match_positions(query: &str, candidate: &str) -> Option<Vec<usize>> {
 216    let mut positions = Vec::new();
 217    let mut query_chars = query.chars().peekable();
 218
 219    for (byte_idx, candidate_char) in candidate.char_indices() {
 220        if let Some(&query_char) = query_chars.peek() {
 221            if candidate_char.eq_ignore_ascii_case(&query_char) {
 222                positions.push(byte_idx);
 223                query_chars.next();
 224            }
 225        } else {
 226            break;
 227        }
 228    }
 229
 230    if query_chars.peek().is_none() {
 231        Some(positions)
 232    } else {
 233        None
 234    }
 235}
 236
 237// TODO: The mapping from workspace root paths to git repositories needs a
 238// unified approach across the codebase: this function, `AgentPanel::classify_worktrees`,
 239// thread persistence (which PathList is saved to the database), and thread
 240// querying (which PathList is used to read threads back). All of these need
 241// to agree on how repos are resolved for a given workspace, especially in
 242// multi-root and nested-repo configurations.
 243fn root_repository_snapshots(
 244    workspace: &Entity<Workspace>,
 245    cx: &App,
 246) -> impl Iterator<Item = project::git_store::RepositorySnapshot> {
 247    let path_list = workspace_path_list(workspace, cx);
 248    let project = workspace.read(cx).project().read(cx);
 249    project.repositories(cx).values().filter_map(move |repo| {
 250        let snapshot = repo.read(cx).snapshot();
 251        let is_root = path_list
 252            .paths()
 253            .iter()
 254            .any(|p| p.as_path() == snapshot.work_directory_abs_path.as_ref());
 255        is_root.then_some(snapshot)
 256    })
 257}
 258
 259fn workspace_path_list(workspace: &Entity<Workspace>, cx: &App) -> PathList {
 260    PathList::new(&workspace.read(cx).root_paths(cx))
 261}
 262
 263/// Derives worktree display info from a thread's stored path list.
 264///
 265/// For each path in the thread's `folder_paths` that canonicalizes to a
 266/// different path (i.e. it's a git worktree), produces a [`WorktreeInfo`]
 267/// with the short worktree name and full path.
 268fn worktree_info_from_thread_paths(
 269    folder_paths: &PathList,
 270    project_groups: &ProjectGroupBuilder,
 271) -> Vec<WorktreeInfo> {
 272    folder_paths
 273        .paths()
 274        .iter()
 275        .filter_map(|path| {
 276            let canonical = project_groups.canonicalize_path(path);
 277            if canonical != path.as_path() {
 278                Some(WorktreeInfo {
 279                    name: linked_worktree_short_name(canonical, path).unwrap_or_default(),
 280                    full_path: SharedString::from(path.display().to_string()),
 281                    highlight_positions: Vec::new(),
 282                })
 283            } else {
 284                None
 285            }
 286        })
 287        .collect()
 288}
 289
 290/// The sidebar re-derives its entire entry list from scratch on every
 291/// change via `update_entries` → `rebuild_contents`. Avoid adding
 292/// incremental or inter-event coordination state — if something can
 293/// be computed from the current world state, compute it in the rebuild.
 294pub struct Sidebar {
 295    multi_workspace: WeakEntity<MultiWorkspace>,
 296    width: Pixels,
 297    focus_handle: FocusHandle,
 298    filter_editor: Entity<Editor>,
 299    list_state: ListState,
 300    contents: SidebarContents,
 301    /// The index of the list item that currently has the keyboard focus
 302    ///
 303    /// Note: This is NOT the same as the active item.
 304    selection: Option<usize>,
 305    /// Derived from the active panel's thread in `rebuild_contents`.
 306    /// Only updated when the panel returns `Some` — never cleared by
 307    /// derivation, since the panel may transiently return `None` while
 308    /// loading. User actions may write directly for immediate feedback.
 309    focused_thread: Option<acp::SessionId>,
 310    agent_panel_visible: bool,
 311    active_thread_is_draft: bool,
 312    hovered_thread_index: Option<usize>,
 313    collapsed_groups: HashSet<PathList>,
 314    expanded_groups: HashMap<PathList, usize>,
 315    view: SidebarView,
 316    recent_projects_popover_handle: PopoverMenuHandle<SidebarRecentProjects>,
 317    project_header_menu_ix: Option<usize>,
 318    _subscriptions: Vec<gpui::Subscription>,
 319    _draft_observation: Option<gpui::Subscription>,
 320}
 321
 322impl Sidebar {
 323    pub fn new(
 324        multi_workspace: Entity<MultiWorkspace>,
 325        window: &mut Window,
 326        cx: &mut Context<Self>,
 327    ) -> Self {
 328        let focus_handle = cx.focus_handle();
 329        cx.on_focus_in(&focus_handle, window, Self::focus_in)
 330            .detach();
 331
 332        let filter_editor = cx.new(|cx| {
 333            let mut editor = Editor::single_line(window, cx);
 334            editor.set_use_modal_editing(true);
 335            editor.set_placeholder_text("Search…", window, cx);
 336            editor
 337        });
 338
 339        cx.subscribe_in(
 340            &multi_workspace,
 341            window,
 342            |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
 343                MultiWorkspaceEvent::ActiveWorkspaceChanged => {
 344                    this.observe_draft_editor(cx);
 345                    this.update_entries(cx);
 346                }
 347                MultiWorkspaceEvent::WorkspaceAdded(workspace) => {
 348                    this.subscribe_to_workspace(workspace, window, cx);
 349                    this.update_entries(cx);
 350                }
 351                MultiWorkspaceEvent::WorkspaceRemoved(_) => {
 352                    this.update_entries(cx);
 353                }
 354            },
 355        )
 356        .detach();
 357
 358        cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
 359            if let editor::EditorEvent::BufferEdited = event {
 360                let query = this.filter_editor.read(cx).text(cx);
 361                if !query.is_empty() {
 362                    this.selection.take();
 363                }
 364                this.update_entries(cx);
 365                if !query.is_empty() {
 366                    this.select_first_entry();
 367                }
 368            }
 369        })
 370        .detach();
 371
 372        cx.observe(
 373            &SidebarThreadMetadataStore::global(cx),
 374            |this, _store, cx| {
 375                this.update_entries(cx);
 376            },
 377        )
 378        .detach();
 379
 380        cx.observe_flag::<AgentV2FeatureFlag, _>(window, |_is_enabled, this, _window, cx| {
 381            this.update_entries(cx);
 382        })
 383        .detach();
 384
 385        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
 386        cx.defer_in(window, move |this, window, cx| {
 387            for workspace in &workspaces {
 388                this.subscribe_to_workspace(workspace, window, cx);
 389            }
 390            this.update_entries(cx);
 391        });
 392
 393        Self {
 394            multi_workspace: multi_workspace.downgrade(),
 395            width: DEFAULT_WIDTH,
 396            focus_handle,
 397            filter_editor,
 398            list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
 399            contents: SidebarContents::default(),
 400            selection: None,
 401            focused_thread: None,
 402            agent_panel_visible: false,
 403            active_thread_is_draft: false,
 404            hovered_thread_index: None,
 405            collapsed_groups: HashSet::new(),
 406            expanded_groups: HashMap::new(),
 407            view: SidebarView::default(),
 408            recent_projects_popover_handle: PopoverMenuHandle::default(),
 409            project_header_menu_ix: None,
 410            _subscriptions: Vec::new(),
 411            _draft_observation: None,
 412        }
 413    }
 414
 415    fn is_active_workspace(&self, workspace: &Entity<Workspace>, cx: &App) -> bool {
 416        self.multi_workspace
 417            .upgrade()
 418            .map_or(false, |mw| mw.read(cx).workspace() == workspace)
 419    }
 420
 421    fn subscribe_to_workspace(
 422        &mut self,
 423        workspace: &Entity<Workspace>,
 424        window: &mut Window,
 425        cx: &mut Context<Self>,
 426    ) {
 427        let project = workspace.read(cx).project().clone();
 428        cx.subscribe_in(
 429            &project,
 430            window,
 431            |this, _project, event, _window, cx| match event {
 432                ProjectEvent::WorktreeAdded(_)
 433                | ProjectEvent::WorktreeRemoved(_)
 434                | ProjectEvent::WorktreeOrderChanged => {
 435                    this.update_entries(cx);
 436                }
 437                _ => {}
 438            },
 439        )
 440        .detach();
 441
 442        let git_store = workspace.read(cx).project().read(cx).git_store().clone();
 443        cx.subscribe_in(
 444            &git_store,
 445            window,
 446            |this, _, event: &project::git_store::GitStoreEvent, _window, cx| {
 447                if matches!(
 448                    event,
 449                    project::git_store::GitStoreEvent::RepositoryUpdated(
 450                        _,
 451                        project::git_store::RepositoryEvent::GitWorktreeListChanged,
 452                        _,
 453                    )
 454                ) {
 455                    this.update_entries(cx);
 456                }
 457            },
 458        )
 459        .detach();
 460
 461        cx.subscribe_in(
 462            workspace,
 463            window,
 464            |this, _workspace, event: &workspace::Event, window, cx| {
 465                if let workspace::Event::PanelAdded(view) = event {
 466                    if let Ok(agent_panel) = view.clone().downcast::<AgentPanel>() {
 467                        this.subscribe_to_agent_panel(&agent_panel, window, cx);
 468                    }
 469                }
 470            },
 471        )
 472        .detach();
 473
 474        self.observe_docks(workspace, cx);
 475
 476        if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
 477            self.subscribe_to_agent_panel(&agent_panel, window, cx);
 478            if self.is_active_workspace(workspace, cx) {
 479                self.agent_panel_visible = AgentPanel::is_visible(workspace, cx);
 480            }
 481            self.observe_draft_editor(cx);
 482        }
 483    }
 484
 485    fn subscribe_to_agent_panel(
 486        &mut self,
 487        agent_panel: &Entity<AgentPanel>,
 488        window: &mut Window,
 489        cx: &mut Context<Self>,
 490    ) {
 491        cx.subscribe_in(
 492            agent_panel,
 493            window,
 494            |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event {
 495                AgentPanelEvent::ActiveViewChanged => {
 496                    let is_new_draft = agent_panel
 497                        .read(cx)
 498                        .active_conversation_view()
 499                        .is_some_and(|cv| cv.read(cx).parent_id(cx).is_none());
 500                    if is_new_draft {
 501                        this.focused_thread = None;
 502                    }
 503                    this.observe_draft_editor(cx);
 504                    this.update_entries(cx);
 505                }
 506                AgentPanelEvent::ThreadFocused | AgentPanelEvent::BackgroundThreadChanged => {
 507                    this.update_entries(cx);
 508                }
 509            },
 510        )
 511        .detach();
 512    }
 513
 514    fn observe_docks(&mut self, workspace: &Entity<Workspace>, cx: &mut Context<Self>) {
 515        let docks: Vec<_> = workspace
 516            .read(cx)
 517            .all_docks()
 518            .into_iter()
 519            .cloned()
 520            .collect();
 521        let workspace = workspace.downgrade();
 522        for dock in docks {
 523            let workspace = workspace.clone();
 524            cx.observe(&dock, move |this, _dock, cx| {
 525                let Some(workspace) = workspace.upgrade() else {
 526                    return;
 527                };
 528                if !this.is_active_workspace(&workspace, cx) {
 529                    return;
 530                }
 531
 532                let is_visible = AgentPanel::is_visible(&workspace, cx);
 533
 534                if this.agent_panel_visible != is_visible {
 535                    this.agent_panel_visible = is_visible;
 536                    cx.notify();
 537                }
 538            })
 539            .detach();
 540        }
 541    }
 542
 543    fn observe_draft_editor(&mut self, cx: &mut Context<Self>) {
 544        self._draft_observation = self
 545            .multi_workspace
 546            .upgrade()
 547            .and_then(|mw| {
 548                let ws = mw.read(cx).workspace();
 549                ws.read(cx).panel::<AgentPanel>(cx)
 550            })
 551            .and_then(|panel| {
 552                let cv = panel.read(cx).active_conversation_view()?;
 553                let tv = cv.read(cx).active_thread()?;
 554                Some(tv.read(cx).message_editor.clone())
 555            })
 556            .map(|editor| {
 557                cx.observe(&editor, |_this, _editor, cx| {
 558                    cx.notify();
 559                })
 560            });
 561    }
 562
 563    fn active_draft_text(&self, cx: &App) -> Option<SharedString> {
 564        let mw = self.multi_workspace.upgrade()?;
 565        let workspace = mw.read(cx).workspace();
 566        let panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
 567        let conversation_view = panel.read(cx).active_conversation_view()?;
 568        let thread_view = conversation_view.read(cx).active_thread()?;
 569        let raw = thread_view.read(cx).message_editor.read(cx).text(cx);
 570        let cleaned = Self::clean_mention_links(&raw);
 571        let mut text: String = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
 572        if text.is_empty() {
 573            None
 574        } else {
 575            const MAX_CHARS: usize = 250;
 576            if let Some((truncate_at, _)) = text.char_indices().nth(MAX_CHARS) {
 577                text.truncate(truncate_at);
 578            }
 579            Some(text.into())
 580        }
 581    }
 582
 583    fn clean_mention_links(input: &str) -> String {
 584        let mut result = String::with_capacity(input.len());
 585        let mut remaining = input;
 586
 587        while let Some(start) = remaining.find("[@") {
 588            result.push_str(&remaining[..start]);
 589            let after_bracket = &remaining[start + 1..]; // skip '['
 590            if let Some(close_bracket) = after_bracket.find("](") {
 591                let mention = &after_bracket[..close_bracket]; // "@something"
 592                let after_link_start = &after_bracket[close_bracket + 2..]; // after "]("
 593                if let Some(close_paren) = after_link_start.find(')') {
 594                    result.push_str(mention);
 595                    remaining = &after_link_start[close_paren + 1..];
 596                    continue;
 597                }
 598            }
 599            // Couldn't parse full link syntax — emit the literal "[@" and move on.
 600            result.push_str("[@");
 601            remaining = &remaining[start + 2..];
 602        }
 603        result.push_str(remaining);
 604        result
 605    }
 606
 607    /// Rebuilds the sidebar contents from current workspace and thread state.
 608    ///
 609    /// Uses [`ProjectGroupBuilder`] to group workspaces by their main git
 610    /// repository, then populates thread entries from the metadata store and
 611    /// merges live thread info from active agent panels.
 612    ///
 613    /// Aim for a single forward pass over workspaces and threads plus an
 614    /// O(T log T) sort. Avoid adding extra scans over the data.
 615    ///
 616    /// Properties:
 617    ///
 618    /// - Should always show every workspace in the multiworkspace
 619    ///     - If you have no threads, and two workspaces for the worktree and the main workspace, make sure at least one is shown
 620    /// - Should always show every thread, associated with each workspace in the multiworkspace
 621    /// - After every build_contents, our "active" state should exactly match the current workspace's, current agent panel's current thread.
 622    fn rebuild_contents(&mut self, cx: &App) {
 623        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
 624            return;
 625        };
 626        let mw = multi_workspace.read(cx);
 627        let workspaces = mw.workspaces().to_vec();
 628        let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned();
 629
 630        let agent_server_store = workspaces
 631            .first()
 632            .map(|ws| ws.read(cx).project().read(cx).agent_server_store().clone());
 633
 634        let query = self.filter_editor.read(cx).text(cx);
 635
 636        // Re-derive agent_panel_visible from the active workspace so it stays
 637        // correct after workspace switches.
 638        self.agent_panel_visible = active_workspace
 639            .as_ref()
 640            .map_or(false, |ws| AgentPanel::is_visible(ws, cx));
 641
 642        // Derive active_thread_is_draft BEFORE focused_thread so we can
 643        // use it as a guard below.
 644        self.active_thread_is_draft = active_workspace
 645            .as_ref()
 646            .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
 647            .map_or(false, |panel| panel.read(cx).active_thread_is_draft(cx));
 648
 649        // Derive focused_thread from the active workspace's agent panel.
 650        // Only update when the panel gives us a positive signal — if the
 651        // panel returns None (e.g. still loading after a thread activation),
 652        // keep the previous value so eager writes from user actions survive.
 653        let panel_focused = active_workspace
 654            .as_ref()
 655            .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
 656            .and_then(|panel| {
 657                panel
 658                    .read(cx)
 659                    .active_conversation_view()
 660                    .and_then(|cv| cv.read(cx).parent_id(cx))
 661            });
 662        if panel_focused.is_some() && !self.active_thread_is_draft {
 663            self.focused_thread = panel_focused;
 664        }
 665
 666        let previous = mem::take(&mut self.contents);
 667
 668        let old_statuses: HashMap<acp::SessionId, AgentThreadStatus> = previous
 669            .entries
 670            .iter()
 671            .filter_map(|entry| match entry {
 672                ListEntry::Thread(thread) if thread.is_live => {
 673                    Some((thread.session_info.session_id.clone(), thread.status))
 674                }
 675                _ => None,
 676            })
 677            .collect();
 678
 679        let mut entries = Vec::new();
 680        let mut notified_threads = previous.notified_threads;
 681        let mut current_session_ids: HashSet<acp::SessionId> = HashSet::new();
 682        let mut project_header_indices: Vec<usize> = Vec::new();
 683
 684        // Use ProjectGroupBuilder to canonically group workspaces by their
 685        // main git repository. This replaces the manual absorbed-workspace
 686        // detection that was here before.
 687        let project_groups = ProjectGroupBuilder::from_multiworkspace(mw, cx);
 688
 689        let has_open_projects = workspaces
 690            .iter()
 691            .any(|ws| !workspace_path_list(ws, cx).paths().is_empty());
 692
 693        let resolve_agent = |row: &ThreadMetadata| -> (Agent, IconName, Option<SharedString>) {
 694            match &row.agent_id {
 695                None => (Agent::NativeAgent, IconName::ZedAgent, None),
 696                Some(id) => {
 697                    let custom_icon = agent_server_store
 698                        .as_ref()
 699                        .and_then(|store| store.read(cx).agent_icon(id));
 700                    (
 701                        Agent::Custom { id: id.clone() },
 702                        IconName::Terminal,
 703                        custom_icon,
 704                    )
 705                }
 706            }
 707        };
 708
 709        for (group_name, group) in project_groups.groups() {
 710            let path_list = group_name.path_list().clone();
 711            if path_list.paths().is_empty() {
 712                continue;
 713            }
 714
 715            let label = group_name.display_name();
 716
 717            let is_collapsed = self.collapsed_groups.contains(&path_list);
 718            let should_load_threads = !is_collapsed || !query.is_empty();
 719
 720            let is_active = active_workspace
 721                .as_ref()
 722                .is_some_and(|active| group.workspaces.contains(active));
 723
 724            // Pick a representative workspace for the group: prefer the active
 725            // workspace if it belongs to this group, otherwise use the main
 726            // repo workspace (not a linked worktree).
 727            let representative_workspace = active_workspace
 728                .as_ref()
 729                .filter(|_| is_active)
 730                .unwrap_or_else(|| group.main_workspace(cx));
 731
 732            // Collect live thread infos from all workspaces in this group.
 733            let live_infos: Vec<_> = group
 734                .workspaces
 735                .iter()
 736                .flat_map(|ws| all_thread_infos_for_workspace(ws, cx))
 737                .collect();
 738
 739            let mut threads: Vec<ThreadEntry> = Vec::new();
 740            let mut has_running_threads = false;
 741            let mut waiting_thread_count: usize = 0;
 742
 743            if should_load_threads {
 744                let mut seen_session_ids: HashSet<acp::SessionId> = HashSet::new();
 745                let thread_store = SidebarThreadMetadataStore::global(cx);
 746
 747                // Load threads from each workspace in the group.
 748                for workspace in &group.workspaces {
 749                    let ws_path_list = workspace_path_list(workspace, cx);
 750
 751                    for row in thread_store.read(cx).entries_for_path(&ws_path_list) {
 752                        if !seen_session_ids.insert(row.session_id.clone()) {
 753                            continue;
 754                        }
 755                        let (agent, icon, icon_from_external_svg) = resolve_agent(&row);
 756                        let worktrees =
 757                            worktree_info_from_thread_paths(&row.folder_paths, &project_groups);
 758                        threads.push(ThreadEntry {
 759                            agent,
 760                            session_info: acp_thread::AgentSessionInfo {
 761                                session_id: row.session_id.clone(),
 762                                work_dirs: None,
 763                                title: Some(row.title.clone()),
 764                                updated_at: Some(row.updated_at),
 765                                created_at: row.created_at,
 766                                meta: None,
 767                            },
 768                            icon,
 769                            icon_from_external_svg,
 770                            status: AgentThreadStatus::default(),
 771                            workspace: ThreadEntryWorkspace::Open(workspace.clone()),
 772                            is_live: false,
 773                            is_background: false,
 774                            is_title_generating: false,
 775                            highlight_positions: Vec::new(),
 776                            worktrees,
 777                            diff_stats: DiffStats::default(),
 778                        });
 779                    }
 780                }
 781
 782                // Load threads from linked git worktrees whose
 783                // canonical paths belong to this group.
 784                let linked_worktree_queries = group
 785                    .workspaces
 786                    .iter()
 787                    .flat_map(|ws| root_repository_snapshots(ws, cx))
 788                    .filter(|snapshot| !snapshot.is_linked_worktree())
 789                    .flat_map(|snapshot| {
 790                        snapshot
 791                            .linked_worktrees()
 792                            .iter()
 793                            .filter(|wt| {
 794                                project_groups.group_owns_worktree(group, &path_list, &wt.path)
 795                            })
 796                            .map(|wt| PathList::new(std::slice::from_ref(&wt.path)))
 797                            .collect::<Vec<_>>()
 798                    });
 799
 800                for worktree_path_list in linked_worktree_queries {
 801                    for row in thread_store.read(cx).entries_for_path(&worktree_path_list) {
 802                        if !seen_session_ids.insert(row.session_id.clone()) {
 803                            continue;
 804                        }
 805                        let (agent, icon, icon_from_external_svg) = resolve_agent(&row);
 806                        let worktrees =
 807                            worktree_info_from_thread_paths(&row.folder_paths, &project_groups);
 808                        threads.push(ThreadEntry {
 809                            agent,
 810                            session_info: acp_thread::AgentSessionInfo {
 811                                session_id: row.session_id.clone(),
 812                                work_dirs: None,
 813                                title: Some(row.title.clone()),
 814                                updated_at: Some(row.updated_at),
 815                                created_at: row.created_at,
 816                                meta: None,
 817                            },
 818                            icon,
 819                            icon_from_external_svg,
 820                            status: AgentThreadStatus::default(),
 821                            workspace: ThreadEntryWorkspace::Closed(worktree_path_list.clone()),
 822                            is_live: false,
 823                            is_background: false,
 824                            is_title_generating: false,
 825                            highlight_positions: Vec::new(),
 826                            worktrees,
 827                            diff_stats: DiffStats::default(),
 828                        });
 829                    }
 830                }
 831
 832                // Build a lookup from live_infos and compute running/waiting
 833                // counts in a single pass.
 834                let mut live_info_by_session: HashMap<&acp::SessionId, &ActiveThreadInfo> =
 835                    HashMap::new();
 836                for info in &live_infos {
 837                    live_info_by_session.insert(&info.session_id, info);
 838                    if info.status == AgentThreadStatus::Running {
 839                        has_running_threads = true;
 840                    }
 841                    if info.status == AgentThreadStatus::WaitingForConfirmation {
 842                        waiting_thread_count += 1;
 843                    }
 844                }
 845
 846                // Merge live info into threads and update notification state
 847                // in a single pass.
 848                for thread in &mut threads {
 849                    if let Some(info) = live_info_by_session.get(&thread.session_info.session_id) {
 850                        thread.apply_active_info(info);
 851                    }
 852
 853                    let session_id = &thread.session_info.session_id;
 854
 855                    let is_thread_workspace_active = match &thread.workspace {
 856                        ThreadEntryWorkspace::Open(thread_workspace) => active_workspace
 857                            .as_ref()
 858                            .is_some_and(|active| active == thread_workspace),
 859                        ThreadEntryWorkspace::Closed(_) => false,
 860                    };
 861
 862                    if thread.status == AgentThreadStatus::Completed
 863                        && !is_thread_workspace_active
 864                        && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running)
 865                    {
 866                        notified_threads.insert(session_id.clone());
 867                    }
 868
 869                    if is_thread_workspace_active && !thread.is_background {
 870                        notified_threads.remove(session_id);
 871                    }
 872                }
 873
 874                threads.sort_by(|a, b| {
 875                    let a_time = a.session_info.created_at.or(a.session_info.updated_at);
 876                    let b_time = b.session_info.created_at.or(b.session_info.updated_at);
 877                    b_time.cmp(&a_time)
 878                });
 879            } else {
 880                for info in live_infos {
 881                    if info.status == AgentThreadStatus::Running {
 882                        has_running_threads = true;
 883                    }
 884                    if info.status == AgentThreadStatus::WaitingForConfirmation {
 885                        waiting_thread_count += 1;
 886                    }
 887                }
 888            }
 889
 890            if !query.is_empty() {
 891                let workspace_highlight_positions =
 892                    fuzzy_match_positions(&query, &label).unwrap_or_default();
 893                let workspace_matched = !workspace_highlight_positions.is_empty();
 894
 895                let mut matched_threads: Vec<ThreadEntry> = Vec::new();
 896                for mut thread in threads {
 897                    let title = thread
 898                        .session_info
 899                        .title
 900                        .as_ref()
 901                        .map(|s| s.as_ref())
 902                        .unwrap_or("");
 903                    if let Some(positions) = fuzzy_match_positions(&query, title) {
 904                        thread.highlight_positions = positions;
 905                    }
 906                    let mut worktree_matched = false;
 907                    for worktree in &mut thread.worktrees {
 908                        if let Some(positions) = fuzzy_match_positions(&query, &worktree.name) {
 909                            worktree.highlight_positions = positions;
 910                            worktree_matched = true;
 911                        }
 912                    }
 913                    if workspace_matched
 914                        || !thread.highlight_positions.is_empty()
 915                        || worktree_matched
 916                    {
 917                        matched_threads.push(thread);
 918                    }
 919                }
 920
 921                if matched_threads.is_empty() && !workspace_matched {
 922                    continue;
 923                }
 924
 925                project_header_indices.push(entries.len());
 926                entries.push(ListEntry::ProjectHeader {
 927                    path_list: path_list.clone(),
 928                    label,
 929                    workspace: representative_workspace.clone(),
 930                    highlight_positions: workspace_highlight_positions,
 931                    has_running_threads,
 932                    waiting_thread_count,
 933                    is_active,
 934                });
 935
 936                for thread in matched_threads {
 937                    current_session_ids.insert(thread.session_info.session_id.clone());
 938                    entries.push(thread.into());
 939                }
 940            } else {
 941                let thread_count = threads.len();
 942                let is_draft_for_workspace = self.agent_panel_visible
 943                    && self.active_thread_is_draft
 944                    && self.focused_thread.is_none()
 945                    && is_active;
 946
 947                let show_new_thread_entry = thread_count == 0 || is_draft_for_workspace;
 948
 949                project_header_indices.push(entries.len());
 950                entries.push(ListEntry::ProjectHeader {
 951                    path_list: path_list.clone(),
 952                    label,
 953                    workspace: representative_workspace.clone(),
 954                    highlight_positions: Vec::new(),
 955                    has_running_threads,
 956                    waiting_thread_count,
 957                    is_active,
 958                });
 959
 960                if is_collapsed {
 961                    continue;
 962                }
 963
 964                if show_new_thread_entry {
 965                    entries.push(ListEntry::NewThread {
 966                        path_list: path_list.clone(),
 967                        workspace: representative_workspace.clone(),
 968                        is_active_draft: is_draft_for_workspace,
 969                    });
 970                }
 971
 972                let total = threads.len();
 973
 974                let extra_batches = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
 975                let threads_to_show =
 976                    DEFAULT_THREADS_SHOWN + (extra_batches * DEFAULT_THREADS_SHOWN);
 977                let count = threads_to_show.min(total);
 978
 979                let mut promoted_threads: HashSet<acp::SessionId> = HashSet::new();
 980
 981                // Build visible entries in a single pass. Threads within
 982                // the cutoff are always shown. Threads beyond it are shown
 983                // only if they should be promoted (running, waiting, or
 984                // focused)
 985                for (index, thread) in threads.into_iter().enumerate() {
 986                    let is_hidden = index >= count;
 987
 988                    let session_id = &thread.session_info.session_id;
 989                    if is_hidden {
 990                        let is_promoted = thread.status == AgentThreadStatus::Running
 991                            || thread.status == AgentThreadStatus::WaitingForConfirmation
 992                            || notified_threads.contains(session_id)
 993                            || self
 994                                .focused_thread
 995                                .as_ref()
 996                                .is_some_and(|id| id == session_id);
 997                        if is_promoted {
 998                            promoted_threads.insert(session_id.clone());
 999                        }
1000                        if !promoted_threads.contains(session_id) {
1001                            continue;
1002                        }
1003                    }
1004
1005                    current_session_ids.insert(session_id.clone());
1006                    entries.push(thread.into());
1007                }
1008
1009                let visible = count + promoted_threads.len();
1010                let is_fully_expanded = visible >= total;
1011
1012                if total > DEFAULT_THREADS_SHOWN {
1013                    entries.push(ListEntry::ViewMore {
1014                        path_list: path_list.clone(),
1015                        is_fully_expanded,
1016                    });
1017                }
1018            }
1019        }
1020
1021        // Prune stale notifications using the session IDs we collected during
1022        // the build pass (no extra scan needed).
1023        notified_threads.retain(|id| current_session_ids.contains(id));
1024
1025        self.contents = SidebarContents {
1026            entries,
1027            notified_threads,
1028            project_header_indices,
1029            has_open_projects,
1030        };
1031    }
1032
1033    /// Rebuilds the sidebar's visible entries from already-cached state.
1034    fn update_entries(&mut self, cx: &mut Context<Self>) {
1035        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1036            return;
1037        };
1038        if !multi_workspace.read(cx).multi_workspace_enabled(cx) {
1039            return;
1040        }
1041
1042        let had_notifications = self.has_notifications(cx);
1043        let scroll_position = self.list_state.logical_scroll_top();
1044
1045        self.rebuild_contents(cx);
1046
1047        self.list_state.reset(self.contents.entries.len());
1048        self.list_state.scroll_to(scroll_position);
1049
1050        if had_notifications != self.has_notifications(cx) {
1051            multi_workspace.update(cx, |_, cx| {
1052                cx.notify();
1053            });
1054        }
1055
1056        cx.notify();
1057    }
1058
1059    fn select_first_entry(&mut self) {
1060        self.selection = self
1061            .contents
1062            .entries
1063            .iter()
1064            .position(|entry| matches!(entry, ListEntry::Thread(_)))
1065            .or_else(|| {
1066                if self.contents.entries.is_empty() {
1067                    None
1068                } else {
1069                    Some(0)
1070                }
1071            });
1072    }
1073
1074    fn render_list_entry(
1075        &mut self,
1076        ix: usize,
1077        window: &mut Window,
1078        cx: &mut Context<Self>,
1079    ) -> AnyElement {
1080        let Some(entry) = self.contents.entries.get(ix) else {
1081            return div().into_any_element();
1082        };
1083        let is_focused = self.focus_handle.is_focused(window);
1084        // is_selected means the keyboard selector is here.
1085        let is_selected = is_focused && self.selection == Some(ix);
1086
1087        let is_group_header_after_first =
1088            ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. });
1089
1090        let rendered = match entry {
1091            ListEntry::ProjectHeader {
1092                path_list,
1093                label,
1094                workspace,
1095                highlight_positions,
1096                has_running_threads,
1097                waiting_thread_count,
1098                is_active,
1099            } => self.render_project_header(
1100                ix,
1101                false,
1102                path_list,
1103                label,
1104                workspace,
1105                highlight_positions,
1106                *has_running_threads,
1107                *waiting_thread_count,
1108                *is_active,
1109                is_selected,
1110                cx,
1111            ),
1112            ListEntry::Thread(thread) => self.render_thread(ix, thread, is_selected, cx),
1113            ListEntry::ViewMore {
1114                path_list,
1115                is_fully_expanded,
1116            } => self.render_view_more(ix, path_list, *is_fully_expanded, is_selected, cx),
1117            ListEntry::NewThread {
1118                path_list,
1119                workspace,
1120                is_active_draft,
1121            } => {
1122                self.render_new_thread(ix, path_list, workspace, *is_active_draft, is_selected, cx)
1123            }
1124        };
1125
1126        if is_group_header_after_first {
1127            v_flex()
1128                .w_full()
1129                .border_t_1()
1130                .border_color(cx.theme().colors().border.opacity(0.5))
1131                .child(rendered)
1132                .into_any_element()
1133        } else {
1134            rendered
1135        }
1136    }
1137
1138    fn render_project_header(
1139        &self,
1140        ix: usize,
1141        is_sticky: bool,
1142        path_list: &PathList,
1143        label: &SharedString,
1144        workspace: &Entity<Workspace>,
1145        highlight_positions: &[usize],
1146        has_running_threads: bool,
1147        waiting_thread_count: usize,
1148        is_active: bool,
1149        is_selected: bool,
1150        cx: &mut Context<Self>,
1151    ) -> AnyElement {
1152        let id_prefix = if is_sticky { "sticky-" } else { "" };
1153        let id = SharedString::from(format!("{id_prefix}project-header-{ix}"));
1154        let group_name = SharedString::from(format!("{id_prefix}header-group-{ix}"));
1155
1156        let is_collapsed = self.collapsed_groups.contains(path_list);
1157        let disclosure_icon = if is_collapsed {
1158            IconName::ChevronRight
1159        } else {
1160            IconName::ChevronDown
1161        };
1162
1163        let has_new_thread_entry = self
1164            .contents
1165            .entries
1166            .get(ix + 1)
1167            .is_some_and(|entry| matches!(entry, ListEntry::NewThread { .. }));
1168        let show_new_thread_button = !has_new_thread_entry && !self.has_filter_query(cx);
1169
1170        let workspace_for_remove = workspace.clone();
1171        let workspace_for_menu = workspace.clone();
1172        let workspace_for_open = workspace.clone();
1173
1174        let path_list_for_toggle = path_list.clone();
1175        let path_list_for_collapse = path_list.clone();
1176        let view_more_expanded = self.expanded_groups.contains_key(path_list);
1177
1178        let label = if highlight_positions.is_empty() {
1179            Label::new(label.clone())
1180                .color(Color::Muted)
1181                .into_any_element()
1182        } else {
1183            HighlightedLabel::new(label.clone(), highlight_positions.to_vec())
1184                .color(Color::Muted)
1185                .into_any_element()
1186        };
1187
1188        let color = cx.theme().colors();
1189        let hover_color = color
1190            .element_active
1191            .blend(color.element_background.opacity(0.2));
1192
1193        h_flex()
1194            .id(id)
1195            .group(&group_name)
1196            .h(Tab::content_height(cx))
1197            .w_full()
1198            .pl_1p5()
1199            .pr_1()
1200            .border_1()
1201            .map(|this| {
1202                if is_selected {
1203                    this.border_color(color.border_focused)
1204                } else {
1205                    this.border_color(gpui::transparent_black())
1206                }
1207            })
1208            .justify_between()
1209            .hover(|s| s.bg(hover_color))
1210            .child(
1211                h_flex()
1212                    .relative()
1213                    .min_w_0()
1214                    .w_full()
1215                    .gap_1p5()
1216                    .child(
1217                        h_flex().size_4().flex_none().justify_center().child(
1218                            Icon::new(disclosure_icon)
1219                                .size(IconSize::Small)
1220                                .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.5))),
1221                        ),
1222                    )
1223                    .child(label)
1224                    .when(is_collapsed, |this| {
1225                        this.when(has_running_threads, |this| {
1226                            this.child(
1227                                Icon::new(IconName::LoadCircle)
1228                                    .size(IconSize::XSmall)
1229                                    .color(Color::Muted)
1230                                    .with_rotate_animation(2),
1231                            )
1232                        })
1233                        .when(waiting_thread_count > 0, |this| {
1234                            let tooltip_text = if waiting_thread_count == 1 {
1235                                "1 thread is waiting for confirmation".to_string()
1236                            } else {
1237                                format!(
1238                                    "{waiting_thread_count} threads are waiting for confirmation",
1239                                )
1240                            };
1241                            this.child(
1242                                div()
1243                                    .id(format!("{id_prefix}waiting-indicator-{ix}"))
1244                                    .child(
1245                                        Icon::new(IconName::Warning)
1246                                            .size(IconSize::XSmall)
1247                                            .color(Color::Warning),
1248                                    )
1249                                    .tooltip(Tooltip::text(tooltip_text)),
1250                            )
1251                        })
1252                    }),
1253            )
1254            .child({
1255                let workspace_for_new_thread = workspace.clone();
1256                let path_list_for_new_thread = path_list.clone();
1257
1258                h_flex()
1259                    .when(self.project_header_menu_ix != Some(ix), |this| {
1260                        this.visible_on_hover(group_name)
1261                    })
1262                    .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
1263                        cx.stop_propagation();
1264                    })
1265                    .child(self.render_project_header_menu(
1266                        ix,
1267                        id_prefix,
1268                        &workspace_for_menu,
1269                        &workspace_for_remove,
1270                        cx,
1271                    ))
1272                    .when(view_more_expanded && !is_collapsed, |this| {
1273                        this.child(
1274                            IconButton::new(
1275                                SharedString::from(format!(
1276                                    "{id_prefix}project-header-collapse-{ix}",
1277                                )),
1278                                IconName::ListCollapse,
1279                            )
1280                            .icon_size(IconSize::Small)
1281                            .icon_color(Color::Muted)
1282                            .tooltip(Tooltip::text("Collapse Displayed Threads"))
1283                            .on_click(cx.listener({
1284                                let path_list_for_collapse = path_list_for_collapse.clone();
1285                                move |this, _, _window, cx| {
1286                                    this.selection = None;
1287                                    this.expanded_groups.remove(&path_list_for_collapse);
1288                                    this.update_entries(cx);
1289                                }
1290                            })),
1291                        )
1292                    })
1293                    .when(!is_active, |this| {
1294                        this.child(
1295                            IconButton::new(
1296                                SharedString::from(format!(
1297                                    "{id_prefix}project-header-open-workspace-{ix}",
1298                                )),
1299                                IconName::Focus,
1300                            )
1301                            .icon_size(IconSize::Small)
1302                            .icon_color(Color::Muted)
1303                            .tooltip(Tooltip::text("Activate Workspace"))
1304                            .on_click(cx.listener({
1305                                move |this, _, window, cx| {
1306                                    this.focused_thread = None;
1307                                    if let Some(multi_workspace) = this.multi_workspace.upgrade() {
1308                                        multi_workspace.update(cx, |multi_workspace, cx| {
1309                                            multi_workspace
1310                                                .activate(workspace_for_open.clone(), cx);
1311                                        });
1312                                    }
1313                                    if AgentPanel::is_visible(&workspace_for_open, cx) {
1314                                        workspace_for_open.update(cx, |workspace, cx| {
1315                                            workspace.focus_panel::<AgentPanel>(window, cx);
1316                                        });
1317                                    }
1318                                }
1319                            })),
1320                        )
1321                    })
1322                    .when(show_new_thread_button, |this| {
1323                        this.child(
1324                            IconButton::new(
1325                                SharedString::from(format!(
1326                                    "{id_prefix}project-header-new-thread-{ix}",
1327                                )),
1328                                IconName::Plus,
1329                            )
1330                            .icon_size(IconSize::Small)
1331                            .icon_color(Color::Muted)
1332                            .tooltip(Tooltip::text("New Thread"))
1333                            .on_click(cx.listener({
1334                                let workspace_for_new_thread = workspace_for_new_thread.clone();
1335                                let path_list_for_new_thread = path_list_for_new_thread.clone();
1336                                move |this, _, window, cx| {
1337                                    // Uncollapse the group if collapsed so
1338                                    // the new-thread entry becomes visible.
1339                                    this.collapsed_groups.remove(&path_list_for_new_thread);
1340                                    this.selection = None;
1341                                    this.create_new_thread(&workspace_for_new_thread, window, cx);
1342                                }
1343                            })),
1344                        )
1345                    })
1346            })
1347            .on_click(cx.listener(move |this, _, window, cx| {
1348                this.selection = None;
1349                this.toggle_collapse(&path_list_for_toggle, window, cx);
1350            }))
1351            .into_any_element()
1352    }
1353
1354    fn render_project_header_menu(
1355        &self,
1356        ix: usize,
1357        id_prefix: &str,
1358        workspace: &Entity<Workspace>,
1359        workspace_for_remove: &Entity<Workspace>,
1360        cx: &mut Context<Self>,
1361    ) -> impl IntoElement {
1362        let workspace_for_menu = workspace.clone();
1363        let workspace_for_remove = workspace_for_remove.clone();
1364        let multi_workspace = self.multi_workspace.clone();
1365        let this = cx.weak_entity();
1366
1367        PopoverMenu::new(format!("{id_prefix}project-header-menu-{ix}"))
1368            .on_open(Rc::new({
1369                let this = this.clone();
1370                move |_window, cx| {
1371                    this.update(cx, |sidebar, cx| {
1372                        sidebar.project_header_menu_ix = Some(ix);
1373                        cx.notify();
1374                    })
1375                    .ok();
1376                }
1377            }))
1378            .menu(move |window, cx| {
1379                let workspace = workspace_for_menu.clone();
1380                let workspace_for_remove = workspace_for_remove.clone();
1381                let multi_workspace = multi_workspace.clone();
1382
1383                let menu = ContextMenu::build_persistent(window, cx, move |menu, _window, cx| {
1384                    let worktrees: Vec<_> = workspace
1385                        .read(cx)
1386                        .visible_worktrees(cx)
1387                        .map(|worktree| {
1388                            let worktree_read = worktree.read(cx);
1389                            let id = worktree_read.id();
1390                            let name: SharedString =
1391                                worktree_read.root_name().as_unix_str().to_string().into();
1392                            (id, name)
1393                        })
1394                        .collect();
1395
1396                    let worktree_count = worktrees.len();
1397
1398                    let mut menu = menu
1399                        .header("Project Folders")
1400                        .end_slot_action(Box::new(menu::EndSlot));
1401
1402                    for (worktree_id, name) in &worktrees {
1403                        let worktree_id = *worktree_id;
1404                        let workspace_for_worktree = workspace.clone();
1405                        let workspace_for_remove_worktree = workspace_for_remove.clone();
1406                        let multi_workspace_for_worktree = multi_workspace.clone();
1407
1408                        let remove_handler = move |window: &mut Window, cx: &mut App| {
1409                            if worktree_count <= 1 {
1410                                if let Some(mw) = multi_workspace_for_worktree.upgrade() {
1411                                    let ws = workspace_for_remove_worktree.clone();
1412                                    mw.update(cx, |multi_workspace, cx| {
1413                                        if let Some(index) = multi_workspace
1414                                            .workspaces()
1415                                            .iter()
1416                                            .position(|w| *w == ws)
1417                                        {
1418                                            multi_workspace.remove_workspace(index, window, cx);
1419                                        }
1420                                    });
1421                                }
1422                            } else {
1423                                workspace_for_worktree.update(cx, |workspace, cx| {
1424                                    workspace.project().update(cx, |project, cx| {
1425                                        project.remove_worktree(worktree_id, cx);
1426                                    });
1427                                });
1428                            }
1429                        };
1430
1431                        menu = menu.entry_with_end_slot_on_hover(
1432                            name.clone(),
1433                            None,
1434                            |_, _| {},
1435                            IconName::Close,
1436                            "Remove Folder".into(),
1437                            remove_handler,
1438                        );
1439                    }
1440
1441                    let workspace_for_add = workspace.clone();
1442                    let multi_workspace_for_add = multi_workspace.clone();
1443                    let menu = menu.separator().entry(
1444                        "Add Folder to Project",
1445                        Some(Box::new(AddFolderToProject)),
1446                        move |window, cx| {
1447                            if let Some(mw) = multi_workspace_for_add.upgrade() {
1448                                mw.update(cx, |mw, cx| {
1449                                    mw.activate(workspace_for_add.clone(), cx);
1450                                });
1451                            }
1452                            workspace_for_add.update(cx, |workspace, cx| {
1453                                workspace.add_folder_to_project(&AddFolderToProject, window, cx);
1454                            });
1455                        },
1456                    );
1457
1458                    let workspace_count = multi_workspace
1459                        .upgrade()
1460                        .map_or(0, |mw| mw.read(cx).workspaces().len());
1461                    let menu = if workspace_count > 1 {
1462                        let workspace_for_move = workspace.clone();
1463                        let multi_workspace_for_move = multi_workspace.clone();
1464                        menu.entry(
1465                            "Move to New Window",
1466                            Some(Box::new(
1467                                zed_actions::agents_sidebar::MoveWorkspaceToNewWindow,
1468                            )),
1469                            move |window, cx| {
1470                                if let Some(mw) = multi_workspace_for_move.upgrade() {
1471                                    mw.update(cx, |multi_workspace, cx| {
1472                                        if let Some(index) = multi_workspace
1473                                            .workspaces()
1474                                            .iter()
1475                                            .position(|w| *w == workspace_for_move)
1476                                        {
1477                                            multi_workspace
1478                                                .move_workspace_to_new_window(index, window, cx);
1479                                        }
1480                                    });
1481                                }
1482                            },
1483                        )
1484                    } else {
1485                        menu
1486                    };
1487
1488                    let workspace_for_remove = workspace_for_remove.clone();
1489                    let multi_workspace_for_remove = multi_workspace.clone();
1490                    menu.separator()
1491                        .entry("Remove Project", None, move |window, cx| {
1492                            if let Some(mw) = multi_workspace_for_remove.upgrade() {
1493                                let ws = workspace_for_remove.clone();
1494                                mw.update(cx, |multi_workspace, cx| {
1495                                    if let Some(index) =
1496                                        multi_workspace.workspaces().iter().position(|w| *w == ws)
1497                                    {
1498                                        multi_workspace.remove_workspace(index, window, cx);
1499                                    }
1500                                });
1501                            }
1502                        })
1503                });
1504
1505                let this = this.clone();
1506                window
1507                    .subscribe(&menu, cx, move |_, _: &gpui::DismissEvent, _window, cx| {
1508                        this.update(cx, |sidebar, cx| {
1509                            sidebar.project_header_menu_ix = None;
1510                            cx.notify();
1511                        })
1512                        .ok();
1513                    })
1514                    .detach();
1515
1516                Some(menu)
1517            })
1518            .trigger(
1519                IconButton::new(
1520                    SharedString::from(format!("{id_prefix}-ellipsis-menu-{ix}")),
1521                    IconName::Ellipsis,
1522                )
1523                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1524                .icon_size(IconSize::Small)
1525                .icon_color(Color::Muted),
1526            )
1527            .anchor(gpui::Corner::TopRight)
1528            .offset(gpui::Point {
1529                x: px(0.),
1530                y: px(1.),
1531            })
1532    }
1533
1534    fn render_sticky_header(
1535        &self,
1536        window: &mut Window,
1537        cx: &mut Context<Self>,
1538    ) -> Option<AnyElement> {
1539        let scroll_top = self.list_state.logical_scroll_top();
1540
1541        let &header_idx = self
1542            .contents
1543            .project_header_indices
1544            .iter()
1545            .rev()
1546            .find(|&&idx| idx <= scroll_top.item_ix)?;
1547
1548        let needs_sticky = header_idx < scroll_top.item_ix
1549            || (header_idx == scroll_top.item_ix && scroll_top.offset_in_item > px(0.));
1550
1551        if !needs_sticky {
1552            return None;
1553        }
1554
1555        let ListEntry::ProjectHeader {
1556            path_list,
1557            label,
1558            workspace,
1559            highlight_positions,
1560            has_running_threads,
1561            waiting_thread_count,
1562            is_active,
1563        } = self.contents.entries.get(header_idx)?
1564        else {
1565            return None;
1566        };
1567
1568        let is_focused = self.focus_handle.is_focused(window);
1569        let is_selected = is_focused && self.selection == Some(header_idx);
1570
1571        let header_element = self.render_project_header(
1572            header_idx,
1573            true,
1574            &path_list,
1575            &label,
1576            workspace,
1577            &highlight_positions,
1578            *has_running_threads,
1579            *waiting_thread_count,
1580            *is_active,
1581            is_selected,
1582            cx,
1583        );
1584
1585        let top_offset = self
1586            .contents
1587            .project_header_indices
1588            .iter()
1589            .find(|&&idx| idx > header_idx)
1590            .and_then(|&next_idx| {
1591                let bounds = self.list_state.bounds_for_item(next_idx)?;
1592                let viewport = self.list_state.viewport_bounds();
1593                let y_in_viewport = bounds.origin.y - viewport.origin.y;
1594                let header_height = bounds.size.height;
1595                (y_in_viewport < header_height).then_some(y_in_viewport - header_height)
1596            })
1597            .unwrap_or(px(0.));
1598
1599        let color = cx.theme().colors();
1600        let background = color
1601            .title_bar_background
1602            .blend(color.panel_background.opacity(0.2));
1603
1604        let element = v_flex()
1605            .absolute()
1606            .top(top_offset)
1607            .left_0()
1608            .w_full()
1609            .bg(background)
1610            .border_b_1()
1611            .border_color(color.border.opacity(0.5))
1612            .child(header_element)
1613            .shadow_xs()
1614            .into_any_element();
1615
1616        Some(element)
1617    }
1618
1619    fn toggle_collapse(
1620        &mut self,
1621        path_list: &PathList,
1622        _window: &mut Window,
1623        cx: &mut Context<Self>,
1624    ) {
1625        if self.collapsed_groups.contains(path_list) {
1626            self.collapsed_groups.remove(path_list);
1627        } else {
1628            self.collapsed_groups.insert(path_list.clone());
1629        }
1630        self.update_entries(cx);
1631    }
1632
1633    fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
1634        let mut dispatch_context = KeyContext::new_with_defaults();
1635        dispatch_context.add("ThreadsSidebar");
1636        dispatch_context.add("menu");
1637
1638        let identifier = if self.filter_editor.focus_handle(cx).is_focused(window) {
1639            "searching"
1640        } else {
1641            "not_searching"
1642        };
1643
1644        dispatch_context.add(identifier);
1645        dispatch_context
1646    }
1647
1648    fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1649        if !self.focus_handle.is_focused(window) {
1650            return;
1651        }
1652
1653        if let SidebarView::Archive(archive) = &self.view {
1654            let has_selection = archive.read(cx).has_selection();
1655            if !has_selection {
1656                archive.update(cx, |view, cx| view.focus_filter_editor(window, cx));
1657            }
1658        } else if self.selection.is_none() {
1659            self.filter_editor.focus_handle(cx).focus(window, cx);
1660        }
1661    }
1662
1663    fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
1664        if self.reset_filter_editor_text(window, cx) {
1665            self.update_entries(cx);
1666        } else {
1667            self.selection = None;
1668            self.filter_editor.focus_handle(cx).focus(window, cx);
1669            cx.notify();
1670        }
1671    }
1672
1673    fn focus_sidebar_filter(
1674        &mut self,
1675        _: &FocusSidebarFilter,
1676        window: &mut Window,
1677        cx: &mut Context<Self>,
1678    ) {
1679        self.selection = None;
1680        if let SidebarView::Archive(archive) = &self.view {
1681            archive.update(cx, |view, cx| {
1682                view.clear_selection();
1683                view.focus_filter_editor(window, cx);
1684            });
1685        } else {
1686            self.filter_editor.focus_handle(cx).focus(window, cx);
1687        }
1688
1689        // When vim mode is active, the editor defaults to normal mode which
1690        // blocks text input. Switch to insert mode so the user can type
1691        // immediately.
1692        if vim_mode_setting::VimModeSetting::get_global(cx).0 {
1693            if let Ok(action) = cx.build_action("vim::SwitchToInsertMode", None) {
1694                window.dispatch_action(action, cx);
1695            }
1696        }
1697
1698        cx.notify();
1699    }
1700
1701    fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1702        self.filter_editor.update(cx, |editor, cx| {
1703            if editor.buffer().read(cx).len(cx).0 > 0 {
1704                editor.set_text("", window, cx);
1705                true
1706            } else {
1707                false
1708            }
1709        })
1710    }
1711
1712    fn has_filter_query(&self, cx: &App) -> bool {
1713        !self.filter_editor.read(cx).text(cx).is_empty()
1714    }
1715
1716    fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
1717        self.select_next(&SelectNext, window, cx);
1718        if self.selection.is_some() {
1719            self.focus_handle.focus(window, cx);
1720        }
1721    }
1722
1723    fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
1724        self.select_previous(&SelectPrevious, window, cx);
1725        if self.selection.is_some() {
1726            self.focus_handle.focus(window, cx);
1727        }
1728    }
1729
1730    fn editor_confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1731        if self.selection.is_none() {
1732            self.select_next(&SelectNext, window, cx);
1733        }
1734        if self.selection.is_some() {
1735            self.focus_handle.focus(window, cx);
1736        }
1737    }
1738
1739    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
1740        let next = match self.selection {
1741            Some(ix) if ix + 1 < self.contents.entries.len() => ix + 1,
1742            Some(_) if !self.contents.entries.is_empty() => 0,
1743            None if !self.contents.entries.is_empty() => 0,
1744            _ => return,
1745        };
1746        self.selection = Some(next);
1747        self.list_state.scroll_to_reveal_item(next);
1748        cx.notify();
1749    }
1750
1751    fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
1752        match self.selection {
1753            Some(0) => {
1754                self.selection = None;
1755                self.filter_editor.focus_handle(cx).focus(window, cx);
1756                cx.notify();
1757            }
1758            Some(ix) => {
1759                self.selection = Some(ix - 1);
1760                self.list_state.scroll_to_reveal_item(ix - 1);
1761                cx.notify();
1762            }
1763            None if !self.contents.entries.is_empty() => {
1764                let last = self.contents.entries.len() - 1;
1765                self.selection = Some(last);
1766                self.list_state.scroll_to_reveal_item(last);
1767                cx.notify();
1768            }
1769            None => {}
1770        }
1771    }
1772
1773    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
1774        if !self.contents.entries.is_empty() {
1775            self.selection = Some(0);
1776            self.list_state.scroll_to_reveal_item(0);
1777            cx.notify();
1778        }
1779    }
1780
1781    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
1782        if let Some(last) = self.contents.entries.len().checked_sub(1) {
1783            self.selection = Some(last);
1784            self.list_state.scroll_to_reveal_item(last);
1785            cx.notify();
1786        }
1787    }
1788
1789    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1790        let Some(ix) = self.selection else { return };
1791        let Some(entry) = self.contents.entries.get(ix) else {
1792            return;
1793        };
1794
1795        match entry {
1796            ListEntry::ProjectHeader { path_list, .. } => {
1797                let path_list = path_list.clone();
1798                self.toggle_collapse(&path_list, window, cx);
1799            }
1800            ListEntry::Thread(thread) => {
1801                let session_info = thread.session_info.clone();
1802                match &thread.workspace {
1803                    ThreadEntryWorkspace::Open(workspace) => {
1804                        let workspace = workspace.clone();
1805                        self.activate_thread(
1806                            thread.agent.clone(),
1807                            session_info,
1808                            &workspace,
1809                            window,
1810                            cx,
1811                        );
1812                    }
1813                    ThreadEntryWorkspace::Closed(path_list) => {
1814                        self.open_workspace_and_activate_thread(
1815                            thread.agent.clone(),
1816                            session_info,
1817                            path_list.clone(),
1818                            window,
1819                            cx,
1820                        );
1821                    }
1822                }
1823            }
1824            ListEntry::ViewMore {
1825                path_list,
1826                is_fully_expanded,
1827                ..
1828            } => {
1829                let path_list = path_list.clone();
1830                if *is_fully_expanded {
1831                    self.expanded_groups.remove(&path_list);
1832                } else {
1833                    let current = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
1834                    self.expanded_groups.insert(path_list, current + 1);
1835                }
1836                self.update_entries(cx);
1837            }
1838            ListEntry::NewThread { workspace, .. } => {
1839                let workspace = workspace.clone();
1840                self.create_new_thread(&workspace, window, cx);
1841            }
1842        }
1843    }
1844
1845    fn find_workspace_across_windows(
1846        &self,
1847        cx: &App,
1848        predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
1849    ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
1850        cx.windows()
1851            .into_iter()
1852            .filter_map(|window| window.downcast::<MultiWorkspace>())
1853            .find_map(|window| {
1854                let workspace = window.read(cx).ok().and_then(|multi_workspace| {
1855                    multi_workspace
1856                        .workspaces()
1857                        .iter()
1858                        .find(|workspace| predicate(workspace, cx))
1859                        .cloned()
1860                })?;
1861                Some((window, workspace))
1862            })
1863    }
1864
1865    fn find_workspace_in_current_window(
1866        &self,
1867        cx: &App,
1868        predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
1869    ) -> Option<Entity<Workspace>> {
1870        self.multi_workspace.upgrade().and_then(|multi_workspace| {
1871            multi_workspace
1872                .read(cx)
1873                .workspaces()
1874                .iter()
1875                .find(|workspace| predicate(workspace, cx))
1876                .cloned()
1877        })
1878    }
1879
1880    fn load_agent_thread_in_workspace(
1881        workspace: &Entity<Workspace>,
1882        agent: Agent,
1883        session_info: acp_thread::AgentSessionInfo,
1884        window: &mut Window,
1885        cx: &mut App,
1886    ) {
1887        workspace.update(cx, |workspace, cx| {
1888            workspace.open_panel::<AgentPanel>(window, cx);
1889        });
1890
1891        if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
1892            agent_panel.update(cx, |panel, cx| {
1893                panel.load_agent_thread(
1894                    agent,
1895                    session_info.session_id,
1896                    session_info.work_dirs,
1897                    session_info.title,
1898                    true,
1899                    window,
1900                    cx,
1901                );
1902            });
1903        }
1904    }
1905
1906    fn activate_thread_locally(
1907        &mut self,
1908        agent: Agent,
1909        session_info: acp_thread::AgentSessionInfo,
1910        workspace: &Entity<Workspace>,
1911        window: &mut Window,
1912        cx: &mut Context<Self>,
1913    ) {
1914        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1915            return;
1916        };
1917
1918        // Set focused_thread eagerly so the sidebar highlight updates
1919        // immediately, rather than waiting for a deferred AgentPanel
1920        // event which can race with ActiveWorkspaceChanged clearing it.
1921        self.focused_thread = Some(session_info.session_id.clone());
1922
1923        multi_workspace.update(cx, |multi_workspace, cx| {
1924            multi_workspace.activate(workspace.clone(), cx);
1925        });
1926
1927        Self::load_agent_thread_in_workspace(workspace, agent, session_info, window, cx);
1928
1929        self.update_entries(cx);
1930    }
1931
1932    fn activate_thread_in_other_window(
1933        &self,
1934        agent: Agent,
1935        session_info: acp_thread::AgentSessionInfo,
1936        workspace: Entity<Workspace>,
1937        target_window: WindowHandle<MultiWorkspace>,
1938        cx: &mut Context<Self>,
1939    ) {
1940        let target_session_id = session_info.session_id.clone();
1941
1942        let activated = target_window
1943            .update(cx, |multi_workspace, window, cx| {
1944                window.activate_window();
1945                multi_workspace.activate(workspace.clone(), cx);
1946                Self::load_agent_thread_in_workspace(&workspace, agent, session_info, window, cx);
1947            })
1948            .log_err()
1949            .is_some();
1950
1951        if activated {
1952            if let Some(target_sidebar) = target_window
1953                .read(cx)
1954                .ok()
1955                .and_then(|multi_workspace| {
1956                    multi_workspace.sidebar().map(|sidebar| sidebar.to_any())
1957                })
1958                .and_then(|sidebar| sidebar.downcast::<Self>().ok())
1959            {
1960                target_sidebar.update(cx, |sidebar, cx| {
1961                    sidebar.focused_thread = Some(target_session_id);
1962                    sidebar.update_entries(cx);
1963                });
1964            }
1965        }
1966    }
1967
1968    fn activate_thread(
1969        &mut self,
1970        agent: Agent,
1971        session_info: acp_thread::AgentSessionInfo,
1972        workspace: &Entity<Workspace>,
1973        window: &mut Window,
1974        cx: &mut Context<Self>,
1975    ) {
1976        if self
1977            .find_workspace_in_current_window(cx, |candidate, _| candidate == workspace)
1978            .is_some()
1979        {
1980            self.activate_thread_locally(agent, session_info, &workspace, window, cx);
1981            return;
1982        }
1983
1984        let Some((target_window, workspace)) =
1985            self.find_workspace_across_windows(cx, |candidate, _| candidate == workspace)
1986        else {
1987            return;
1988        };
1989
1990        self.activate_thread_in_other_window(agent, session_info, workspace, target_window, cx);
1991    }
1992
1993    fn open_workspace_and_activate_thread(
1994        &mut self,
1995        agent: Agent,
1996        session_info: acp_thread::AgentSessionInfo,
1997        path_list: PathList,
1998        window: &mut Window,
1999        cx: &mut Context<Self>,
2000    ) {
2001        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2002            return;
2003        };
2004
2005        let paths: Vec<std::path::PathBuf> =
2006            path_list.paths().iter().map(|p| p.to_path_buf()).collect();
2007
2008        let open_task = multi_workspace.update(cx, |mw, cx| mw.open_project(paths, window, cx));
2009
2010        cx.spawn_in(window, async move |this, cx| {
2011            let workspace = open_task.await?;
2012
2013            this.update_in(cx, |this, window, cx| {
2014                this.activate_thread(agent, session_info, &workspace, window, cx);
2015            })?;
2016            anyhow::Ok(())
2017        })
2018        .detach_and_log_err(cx);
2019    }
2020
2021    fn find_current_workspace_for_path_list(
2022        &self,
2023        path_list: &PathList,
2024        cx: &App,
2025    ) -> Option<Entity<Workspace>> {
2026        self.find_workspace_in_current_window(cx, |workspace, cx| {
2027            workspace_path_list(workspace, cx).paths() == path_list.paths()
2028        })
2029    }
2030
2031    fn find_open_workspace_for_path_list(
2032        &self,
2033        path_list: &PathList,
2034        cx: &App,
2035    ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
2036        self.find_workspace_across_windows(cx, |workspace, cx| {
2037            workspace_path_list(workspace, cx).paths() == path_list.paths()
2038        })
2039    }
2040
2041    fn activate_archived_thread(
2042        &mut self,
2043        agent: Agent,
2044        session_info: acp_thread::AgentSessionInfo,
2045        window: &mut Window,
2046        cx: &mut Context<Self>,
2047    ) {
2048        // Eagerly save thread metadata so that the sidebar is updated immediately
2049        SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| {
2050            store.save(
2051                ThreadMetadata::from_session_info(agent.id(), &session_info),
2052                cx,
2053            )
2054        });
2055
2056        if let Some(path_list) = &session_info.work_dirs {
2057            if let Some(workspace) = self.find_current_workspace_for_path_list(path_list, cx) {
2058                self.activate_thread_locally(agent, session_info, &workspace, window, cx);
2059            } else if let Some((target_window, workspace)) =
2060                self.find_open_workspace_for_path_list(path_list, cx)
2061            {
2062                self.activate_thread_in_other_window(
2063                    agent,
2064                    session_info,
2065                    workspace,
2066                    target_window,
2067                    cx,
2068                );
2069            } else {
2070                let path_list = path_list.clone();
2071                self.open_workspace_and_activate_thread(agent, session_info, path_list, window, cx);
2072            }
2073            return;
2074        }
2075
2076        let active_workspace = self.multi_workspace.upgrade().and_then(|w| {
2077            w.read(cx)
2078                .workspaces()
2079                .get(w.read(cx).active_workspace_index())
2080                .cloned()
2081        });
2082
2083        if let Some(workspace) = active_workspace {
2084            self.activate_thread_locally(agent, session_info, &workspace, window, cx);
2085        }
2086    }
2087
2088    fn expand_selected_entry(
2089        &mut self,
2090        _: &SelectChild,
2091        _window: &mut Window,
2092        cx: &mut Context<Self>,
2093    ) {
2094        let Some(ix) = self.selection else { return };
2095
2096        match self.contents.entries.get(ix) {
2097            Some(ListEntry::ProjectHeader { path_list, .. }) => {
2098                if self.collapsed_groups.contains(path_list) {
2099                    let path_list = path_list.clone();
2100                    self.collapsed_groups.remove(&path_list);
2101                    self.update_entries(cx);
2102                } else if ix + 1 < self.contents.entries.len() {
2103                    self.selection = Some(ix + 1);
2104                    self.list_state.scroll_to_reveal_item(ix + 1);
2105                    cx.notify();
2106                }
2107            }
2108            _ => {}
2109        }
2110    }
2111
2112    fn collapse_selected_entry(
2113        &mut self,
2114        _: &SelectParent,
2115        _window: &mut Window,
2116        cx: &mut Context<Self>,
2117    ) {
2118        let Some(ix) = self.selection else { return };
2119
2120        match self.contents.entries.get(ix) {
2121            Some(ListEntry::ProjectHeader { path_list, .. }) => {
2122                if !self.collapsed_groups.contains(path_list) {
2123                    let path_list = path_list.clone();
2124                    self.collapsed_groups.insert(path_list);
2125                    self.update_entries(cx);
2126                }
2127            }
2128            Some(
2129                ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
2130            ) => {
2131                for i in (0..ix).rev() {
2132                    if let Some(ListEntry::ProjectHeader { path_list, .. }) =
2133                        self.contents.entries.get(i)
2134                    {
2135                        let path_list = path_list.clone();
2136                        self.selection = Some(i);
2137                        self.collapsed_groups.insert(path_list);
2138                        self.update_entries(cx);
2139                        break;
2140                    }
2141                }
2142            }
2143            None => {}
2144        }
2145    }
2146
2147    fn toggle_selected_fold(
2148        &mut self,
2149        _: &editor::actions::ToggleFold,
2150        _window: &mut Window,
2151        cx: &mut Context<Self>,
2152    ) {
2153        let Some(ix) = self.selection else { return };
2154
2155        // Find the group header for the current selection.
2156        let header_ix = match self.contents.entries.get(ix) {
2157            Some(ListEntry::ProjectHeader { .. }) => Some(ix),
2158            Some(
2159                ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
2160            ) => (0..ix).rev().find(|&i| {
2161                matches!(
2162                    self.contents.entries.get(i),
2163                    Some(ListEntry::ProjectHeader { .. })
2164                )
2165            }),
2166            None => None,
2167        };
2168
2169        if let Some(header_ix) = header_ix {
2170            if let Some(ListEntry::ProjectHeader { path_list, .. }) =
2171                self.contents.entries.get(header_ix)
2172            {
2173                let path_list = path_list.clone();
2174                if self.collapsed_groups.contains(&path_list) {
2175                    self.collapsed_groups.remove(&path_list);
2176                } else {
2177                    self.selection = Some(header_ix);
2178                    self.collapsed_groups.insert(path_list);
2179                }
2180                self.update_entries(cx);
2181            }
2182        }
2183    }
2184
2185    fn fold_all(
2186        &mut self,
2187        _: &editor::actions::FoldAll,
2188        _window: &mut Window,
2189        cx: &mut Context<Self>,
2190    ) {
2191        for entry in &self.contents.entries {
2192            if let ListEntry::ProjectHeader { path_list, .. } = entry {
2193                self.collapsed_groups.insert(path_list.clone());
2194            }
2195        }
2196        self.update_entries(cx);
2197    }
2198
2199    fn unfold_all(
2200        &mut self,
2201        _: &editor::actions::UnfoldAll,
2202        _window: &mut Window,
2203        cx: &mut Context<Self>,
2204    ) {
2205        self.collapsed_groups.clear();
2206        self.update_entries(cx);
2207    }
2208
2209    fn stop_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
2210        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2211            return;
2212        };
2213
2214        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
2215        for workspace in workspaces {
2216            if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2217                let cancelled =
2218                    agent_panel.update(cx, |panel, cx| panel.cancel_thread(session_id, cx));
2219                if cancelled {
2220                    return;
2221                }
2222            }
2223        }
2224    }
2225
2226    fn archive_thread(
2227        &mut self,
2228        session_id: &acp::SessionId,
2229        window: &mut Window,
2230        cx: &mut Context<Self>,
2231    ) {
2232        // If we're archiving the currently focused thread, move focus to the
2233        // nearest thread within the same project group. We never cross group
2234        // boundaries — if the group has no other threads, clear focus and open
2235        // a blank new thread in the panel instead.
2236        if self.focused_thread.as_ref() == Some(session_id) {
2237            let current_pos = self.contents.entries.iter().position(|entry| {
2238                matches!(entry, ListEntry::Thread(t) if &t.session_info.session_id == session_id)
2239            });
2240
2241            // Find the workspace that owns this thread's project group by
2242            // walking backwards to the nearest ProjectHeader. We must use
2243            // *this* workspace (not the active workspace) because the user
2244            // might be archiving a thread in a non-active group.
2245            let group_workspace = current_pos.and_then(|pos| {
2246                self.contents.entries[..pos]
2247                    .iter()
2248                    .rev()
2249                    .find_map(|e| match e {
2250                        ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
2251                        _ => None,
2252                    })
2253            });
2254
2255            let next_thread = current_pos.and_then(|pos| {
2256                let group_start = self.contents.entries[..pos]
2257                    .iter()
2258                    .rposition(|e| matches!(e, ListEntry::ProjectHeader { .. }))
2259                    .map_or(0, |i| i + 1);
2260                let group_end = self.contents.entries[pos + 1..]
2261                    .iter()
2262                    .position(|e| matches!(e, ListEntry::ProjectHeader { .. }))
2263                    .map_or(self.contents.entries.len(), |i| pos + 1 + i);
2264
2265                let above = self.contents.entries[group_start..pos]
2266                    .iter()
2267                    .rev()
2268                    .find_map(|entry| {
2269                        if let ListEntry::Thread(t) = entry {
2270                            Some(t)
2271                        } else {
2272                            None
2273                        }
2274                    });
2275
2276                above.or_else(|| {
2277                    self.contents.entries[pos + 1..group_end]
2278                        .iter()
2279                        .find_map(|entry| {
2280                            if let ListEntry::Thread(t) = entry {
2281                                Some(t)
2282                            } else {
2283                                None
2284                            }
2285                        })
2286                })
2287            });
2288
2289            if let Some(next) = next_thread {
2290                self.focused_thread = Some(next.session_info.session_id.clone());
2291
2292                // Use the thread's own workspace when it has one open (e.g. an absorbed
2293                // linked worktree thread that appears under the main workspace's header
2294                // but belongs to its own workspace). Loading into the wrong panel binds
2295                // the thread to the wrong project, which corrupts its stored folder_paths
2296                // when metadata is saved via ThreadMetadata::from_thread.
2297                let target_workspace = match &next.workspace {
2298                    ThreadEntryWorkspace::Open(ws) => Some(ws.clone()),
2299                    ThreadEntryWorkspace::Closed(_) => group_workspace,
2300                };
2301
2302                if let Some(workspace) = target_workspace {
2303                    if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2304                        agent_panel.update(cx, |panel, cx| {
2305                            panel.load_agent_thread(
2306                                next.agent.clone(),
2307                                next.session_info.session_id.clone(),
2308                                next.session_info.work_dirs.clone(),
2309                                next.session_info.title.clone(),
2310                                true,
2311                                window,
2312                                cx,
2313                            );
2314                        });
2315                    }
2316                }
2317            } else {
2318                self.focused_thread = None;
2319                if let Some(workspace) = &group_workspace {
2320                    if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2321                        agent_panel.update(cx, |panel, cx| {
2322                            panel.new_thread(&NewThread, window, cx);
2323                        });
2324                    }
2325                }
2326            }
2327        }
2328
2329        SidebarThreadMetadataStore::global(cx)
2330            .update(cx, |store, cx| store.delete(session_id.clone(), cx));
2331    }
2332
2333    fn remove_selected_thread(
2334        &mut self,
2335        _: &RemoveSelectedThread,
2336        window: &mut Window,
2337        cx: &mut Context<Self>,
2338    ) {
2339        let Some(ix) = self.selection else {
2340            return;
2341        };
2342        let Some(ListEntry::Thread(thread)) = self.contents.entries.get(ix) else {
2343            return;
2344        };
2345        if thread.agent != Agent::NativeAgent {
2346            return;
2347        }
2348        let session_id = thread.session_info.session_id.clone();
2349        self.archive_thread(&session_id, window, cx);
2350    }
2351
2352    fn render_thread(
2353        &self,
2354        ix: usize,
2355        thread: &ThreadEntry,
2356        is_focused: bool,
2357        cx: &mut Context<Self>,
2358    ) -> AnyElement {
2359        let has_notification = self
2360            .contents
2361            .is_thread_notified(&thread.session_info.session_id);
2362
2363        let title: SharedString = thread
2364            .session_info
2365            .title
2366            .clone()
2367            .unwrap_or_else(|| "Untitled".into());
2368        let session_info = thread.session_info.clone();
2369        let thread_workspace = thread.workspace.clone();
2370
2371        let is_hovered = self.hovered_thread_index == Some(ix);
2372        let is_selected = self.agent_panel_visible
2373            && self.focused_thread.as_ref() == Some(&session_info.session_id);
2374        let is_running = matches!(
2375            thread.status,
2376            AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
2377        );
2378
2379        let session_id_for_delete = thread.session_info.session_id.clone();
2380        let focus_handle = self.focus_handle.clone();
2381
2382        let id = SharedString::from(format!("thread-entry-{}", ix));
2383
2384        let timestamp = thread
2385            .session_info
2386            .created_at
2387            .or(thread.session_info.updated_at)
2388            .map(format_history_entry_timestamp);
2389
2390        ThreadItem::new(id, title)
2391            .icon(thread.icon)
2392            .status(thread.status)
2393            .when_some(thread.icon_from_external_svg.clone(), |this, svg| {
2394                this.custom_icon_from_external_svg(svg)
2395            })
2396            .worktrees(
2397                thread
2398                    .worktrees
2399                    .iter()
2400                    .map(|wt| ThreadItemWorktreeInfo {
2401                        name: wt.name.clone(),
2402                        full_path: wt.full_path.clone(),
2403                        highlight_positions: wt.highlight_positions.clone(),
2404                    })
2405                    .collect(),
2406            )
2407            .when_some(timestamp, |this, ts| this.timestamp(ts))
2408            .highlight_positions(thread.highlight_positions.to_vec())
2409            .title_generating(thread.is_title_generating)
2410            .notified(has_notification)
2411            .when(thread.diff_stats.lines_added > 0, |this| {
2412                this.added(thread.diff_stats.lines_added as usize)
2413            })
2414            .when(thread.diff_stats.lines_removed > 0, |this| {
2415                this.removed(thread.diff_stats.lines_removed as usize)
2416            })
2417            .selected(is_selected)
2418            .focused(is_focused)
2419            .hovered(is_hovered)
2420            .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| {
2421                if *is_hovered {
2422                    this.hovered_thread_index = Some(ix);
2423                } else if this.hovered_thread_index == Some(ix) {
2424                    this.hovered_thread_index = None;
2425                }
2426                cx.notify();
2427            }))
2428            .when(is_hovered && is_running, |this| {
2429                this.action_slot(
2430                    IconButton::new("stop-thread", IconName::Stop)
2431                        .icon_size(IconSize::Small)
2432                        .icon_color(Color::Error)
2433                        .style(ButtonStyle::Tinted(TintColor::Error))
2434                        .tooltip(Tooltip::text("Stop Generation"))
2435                        .on_click({
2436                            let session_id = session_id_for_delete.clone();
2437                            cx.listener(move |this, _, _window, cx| {
2438                                this.stop_thread(&session_id, cx);
2439                            })
2440                        }),
2441                )
2442            })
2443            .when(is_hovered && !is_running, |this| {
2444                this.action_slot(
2445                    IconButton::new("archive-thread", IconName::Archive)
2446                        .icon_size(IconSize::Small)
2447                        .icon_color(Color::Muted)
2448                        .tooltip({
2449                            let focus_handle = focus_handle.clone();
2450                            move |_window, cx| {
2451                                Tooltip::for_action_in(
2452                                    "Archive Thread",
2453                                    &RemoveSelectedThread,
2454                                    &focus_handle,
2455                                    cx,
2456                                )
2457                            }
2458                        })
2459                        .on_click({
2460                            let session_id = session_id_for_delete.clone();
2461                            cx.listener(move |this, _, window, cx| {
2462                                this.archive_thread(&session_id, window, cx);
2463                            })
2464                        }),
2465                )
2466            })
2467            .on_click({
2468                let agent = thread.agent.clone();
2469                cx.listener(move |this, _, window, cx| {
2470                    this.selection = None;
2471                    match &thread_workspace {
2472                        ThreadEntryWorkspace::Open(workspace) => {
2473                            this.activate_thread(
2474                                agent.clone(),
2475                                session_info.clone(),
2476                                workspace,
2477                                window,
2478                                cx,
2479                            );
2480                        }
2481                        ThreadEntryWorkspace::Closed(path_list) => {
2482                            this.open_workspace_and_activate_thread(
2483                                agent.clone(),
2484                                session_info.clone(),
2485                                path_list.clone(),
2486                                window,
2487                                cx,
2488                            );
2489                        }
2490                    }
2491                })
2492            })
2493            .into_any_element()
2494    }
2495
2496    fn render_filter_input(&self, cx: &mut Context<Self>) -> impl IntoElement {
2497        div()
2498            .min_w_0()
2499            .flex_1()
2500            .capture_action(
2501                cx.listener(|this, _: &editor::actions::Newline, window, cx| {
2502                    this.editor_confirm(window, cx);
2503                }),
2504            )
2505            .child(self.filter_editor.clone())
2506    }
2507
2508    fn render_recent_projects_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
2509        let multi_workspace = self.multi_workspace.upgrade();
2510
2511        let workspace = multi_workspace
2512            .as_ref()
2513            .map(|mw| mw.read(cx).workspace().downgrade());
2514
2515        let focus_handle = workspace
2516            .as_ref()
2517            .and_then(|ws| ws.upgrade())
2518            .map(|w| w.read(cx).focus_handle(cx))
2519            .unwrap_or_else(|| cx.focus_handle());
2520
2521        let sibling_workspace_ids: HashSet<WorkspaceId> = multi_workspace
2522            .as_ref()
2523            .map(|mw| {
2524                mw.read(cx)
2525                    .workspaces()
2526                    .iter()
2527                    .filter_map(|ws| ws.read(cx).database_id())
2528                    .collect()
2529            })
2530            .unwrap_or_default();
2531
2532        let popover_handle = self.recent_projects_popover_handle.clone();
2533
2534        PopoverMenu::new("sidebar-recent-projects-menu")
2535            .with_handle(popover_handle)
2536            .menu(move |window, cx| {
2537                workspace.as_ref().map(|ws| {
2538                    SidebarRecentProjects::popover(
2539                        ws.clone(),
2540                        sibling_workspace_ids.clone(),
2541                        focus_handle.clone(),
2542                        window,
2543                        cx,
2544                    )
2545                })
2546            })
2547            .trigger_with_tooltip(
2548                IconButton::new("open-project", IconName::OpenFolder)
2549                    .icon_size(IconSize::Small)
2550                    .selected_style(ButtonStyle::Tinted(TintColor::Accent)),
2551                |_window, cx| {
2552                    Tooltip::for_action(
2553                        "Add Project",
2554                        &OpenRecent {
2555                            create_new_window: false,
2556                        },
2557                        cx,
2558                    )
2559                },
2560            )
2561            .offset(gpui::Point {
2562                x: px(-2.0),
2563                y: px(-2.0),
2564            })
2565            .anchor(gpui::Corner::BottomRight)
2566    }
2567
2568    fn render_view_more(
2569        &self,
2570        ix: usize,
2571        path_list: &PathList,
2572        is_fully_expanded: bool,
2573        is_selected: bool,
2574        cx: &mut Context<Self>,
2575    ) -> AnyElement {
2576        let path_list = path_list.clone();
2577        let id = SharedString::from(format!("view-more-{}", ix));
2578
2579        let label: SharedString = if is_fully_expanded {
2580            "Collapse".into()
2581        } else {
2582            "View More".into()
2583        };
2584
2585        ThreadItem::new(id, label)
2586            .focused(is_selected)
2587            .icon_visible(false)
2588            .title_label_color(Color::Muted)
2589            .on_click(cx.listener(move |this, _, _window, cx| {
2590                this.selection = None;
2591                if is_fully_expanded {
2592                    this.expanded_groups.remove(&path_list);
2593                } else {
2594                    let current = this.expanded_groups.get(&path_list).copied().unwrap_or(0);
2595                    this.expanded_groups.insert(path_list.clone(), current + 1);
2596                }
2597                this.update_entries(cx);
2598            }))
2599            .into_any_element()
2600    }
2601
2602    fn new_thread_in_group(
2603        &mut self,
2604        _: &NewThreadInGroup,
2605        window: &mut Window,
2606        cx: &mut Context<Self>,
2607    ) {
2608        // If there is a keyboard selection, walk backwards through
2609        // `project_header_indices` to find the header that owns the selected
2610        // row. Otherwise fall back to the active workspace.
2611        let workspace = if let Some(selected_ix) = self.selection {
2612            self.contents
2613                .project_header_indices
2614                .iter()
2615                .rev()
2616                .find(|&&header_ix| header_ix <= selected_ix)
2617                .and_then(|&header_ix| match &self.contents.entries[header_ix] {
2618                    ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
2619                    _ => None,
2620                })
2621        } else {
2622            // Use the currently active workspace.
2623            self.multi_workspace
2624                .upgrade()
2625                .map(|mw| mw.read(cx).workspace().clone())
2626        };
2627
2628        let Some(workspace) = workspace else {
2629            return;
2630        };
2631
2632        self.create_new_thread(&workspace, window, cx);
2633    }
2634
2635    fn create_new_thread(
2636        &mut self,
2637        workspace: &Entity<Workspace>,
2638        window: &mut Window,
2639        cx: &mut Context<Self>,
2640    ) {
2641        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2642            return;
2643        };
2644
2645        // Clear focused_thread immediately so no existing thread stays
2646        // highlighted while the new blank thread is being shown. Without this,
2647        // if the target workspace is already active (so ActiveWorkspaceChanged
2648        // never fires), the previous thread's highlight would linger.
2649        self.focused_thread = None;
2650
2651        multi_workspace.update(cx, |multi_workspace, cx| {
2652            multi_workspace.activate(workspace.clone(), cx);
2653        });
2654
2655        workspace.update(cx, |workspace, cx| {
2656            if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx) {
2657                agent_panel.update(cx, |panel, cx| {
2658                    panel.new_thread(&NewThread, window, cx);
2659                });
2660            }
2661            workspace.focus_panel::<AgentPanel>(window, cx);
2662        });
2663    }
2664
2665    fn render_new_thread(
2666        &self,
2667        ix: usize,
2668        _path_list: &PathList,
2669        workspace: &Entity<Workspace>,
2670        is_active_draft: bool,
2671        is_selected: bool,
2672        cx: &mut Context<Self>,
2673    ) -> AnyElement {
2674        let is_active = is_active_draft && self.agent_panel_visible && self.active_thread_is_draft;
2675
2676        let label: SharedString = if is_active {
2677            self.active_draft_text(cx)
2678                .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into())
2679        } else {
2680            DEFAULT_THREAD_TITLE.into()
2681        };
2682
2683        let workspace = workspace.clone();
2684        let id = SharedString::from(format!("new-thread-btn-{}", ix));
2685
2686        let thread_item = ThreadItem::new(id, label)
2687            .icon(IconName::Plus)
2688            .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.8)))
2689            .selected(is_active)
2690            .focused(is_selected)
2691            .when(!is_active, |this| {
2692                this.on_click(cx.listener(move |this, _, window, cx| {
2693                    this.selection = None;
2694                    this.create_new_thread(&workspace, window, cx);
2695                }))
2696            });
2697
2698        if is_active {
2699            div()
2700                .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
2701                    cx.stop_propagation();
2702                })
2703                .child(thread_item)
2704                .into_any_element()
2705        } else {
2706            thread_item.into_any_element()
2707        }
2708    }
2709
2710    fn render_no_results(&self, cx: &mut Context<Self>) -> impl IntoElement {
2711        let has_query = self.has_filter_query(cx);
2712        let message = if has_query {
2713            "No threads match your search."
2714        } else {
2715            "No threads yet"
2716        };
2717
2718        v_flex()
2719            .id("sidebar-no-results")
2720            .p_4()
2721            .size_full()
2722            .items_center()
2723            .justify_center()
2724            .child(
2725                Label::new(message)
2726                    .size(LabelSize::Small)
2727                    .color(Color::Muted),
2728            )
2729    }
2730
2731    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
2732        v_flex()
2733            .id("sidebar-empty-state")
2734            .p_4()
2735            .size_full()
2736            .items_center()
2737            .justify_center()
2738            .gap_1()
2739            .track_focus(&self.focus_handle(cx))
2740            .child(
2741                Button::new("open_project", "Open Project")
2742                    .full_width()
2743                    .key_binding(KeyBinding::for_action(&workspace::Open::default(), cx))
2744                    .on_click(|_, window, cx| {
2745                        window.dispatch_action(
2746                            Open {
2747                                create_new_window: false,
2748                            }
2749                            .boxed_clone(),
2750                            cx,
2751                        );
2752                    }),
2753            )
2754            .child(
2755                h_flex()
2756                    .w_1_2()
2757                    .gap_2()
2758                    .child(Divider::horizontal())
2759                    .child(Label::new("or").size(LabelSize::XSmall).color(Color::Muted))
2760                    .child(Divider::horizontal()),
2761            )
2762            .child(
2763                Button::new("clone_repo", "Clone Repository")
2764                    .full_width()
2765                    .on_click(|_, window, cx| {
2766                        window.dispatch_action(git::Clone.boxed_clone(), cx);
2767                    }),
2768            )
2769    }
2770
2771    fn render_sidebar_header(
2772        &self,
2773        no_open_projects: bool,
2774        window: &Window,
2775        cx: &mut Context<Self>,
2776    ) -> impl IntoElement {
2777        let has_query = self.has_filter_query(cx);
2778        let sidebar_on_left = self.side(cx) == SidebarSide::Left;
2779        let traffic_lights =
2780            cfg!(target_os = "macos") && !window.is_fullscreen() && sidebar_on_left;
2781        let header_height = platform_title_bar_height(window);
2782
2783        h_flex()
2784            .h(header_height)
2785            .mt_px()
2786            .pb_px()
2787            .map(|this| {
2788                if traffic_lights {
2789                    this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
2790                } else {
2791                    this.pl_1p5()
2792                }
2793            })
2794            .pr_1p5()
2795            .gap_1()
2796            .when(!no_open_projects, |this| {
2797                this.border_b_1()
2798                    .border_color(cx.theme().colors().border)
2799                    .when(traffic_lights, |this| {
2800                        this.child(Divider::vertical().color(ui::DividerColor::Border))
2801                    })
2802                    .child(
2803                        div().ml_1().child(
2804                            Icon::new(IconName::MagnifyingGlass)
2805                                .size(IconSize::Small)
2806                                .color(Color::Muted),
2807                        ),
2808                    )
2809                    .child(self.render_filter_input(cx))
2810                    .child(
2811                        h_flex()
2812                            .gap_1()
2813                            .when(
2814                                self.selection.is_some()
2815                                    && !self.filter_editor.focus_handle(cx).is_focused(window),
2816                                |this| this.child(KeyBinding::for_action(&FocusSidebarFilter, cx)),
2817                            )
2818                            .when(has_query, |this| {
2819                                this.child(
2820                                    IconButton::new("clear_filter", IconName::Close)
2821                                        .icon_size(IconSize::Small)
2822                                        .tooltip(Tooltip::text("Clear Search"))
2823                                        .on_click(cx.listener(|this, _, window, cx| {
2824                                            this.reset_filter_editor_text(window, cx);
2825                                            this.update_entries(cx);
2826                                        })),
2827                                )
2828                            }),
2829                    )
2830            })
2831    }
2832
2833    fn render_sidebar_toggle_button(&self, _cx: &mut Context<Self>) -> impl IntoElement {
2834        let on_right = AgentSettings::get_global(_cx).sidebar_side() == SidebarSide::Right;
2835
2836        sidebar_side_context_menu("sidebar-toggle-menu", _cx)
2837            .anchor(if on_right {
2838                gpui::Corner::BottomRight
2839            } else {
2840                gpui::Corner::BottomLeft
2841            })
2842            .attach(if on_right {
2843                gpui::Corner::TopRight
2844            } else {
2845                gpui::Corner::TopLeft
2846            })
2847            .trigger(move |_is_active, _window, _cx| {
2848                let icon = if on_right {
2849                    IconName::ThreadsSidebarRightOpen
2850                } else {
2851                    IconName::ThreadsSidebarLeftOpen
2852                };
2853                IconButton::new("sidebar-close-toggle", icon)
2854                    .icon_size(IconSize::Small)
2855                    .tooltip(Tooltip::element(move |_window, cx| {
2856                        v_flex()
2857                            .gap_1()
2858                            .child(
2859                                h_flex()
2860                                    .gap_2()
2861                                    .justify_between()
2862                                    .child(Label::new("Toggle Sidebar"))
2863                                    .child(KeyBinding::for_action(&ToggleWorkspaceSidebar, cx)),
2864                            )
2865                            .child(
2866                                h_flex()
2867                                    .pt_1()
2868                                    .gap_2()
2869                                    .border_t_1()
2870                                    .border_color(cx.theme().colors().border_variant)
2871                                    .justify_between()
2872                                    .child(Label::new("Focus Sidebar"))
2873                                    .child(KeyBinding::for_action(&FocusWorkspaceSidebar, cx)),
2874                            )
2875                            .into_any_element()
2876                    }))
2877                    .on_click(|_, window, cx| {
2878                        if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
2879                            multi_workspace.update(cx, |multi_workspace, cx| {
2880                                multi_workspace.close_sidebar(window, cx);
2881                            });
2882                        }
2883                    })
2884            })
2885    }
2886
2887    fn render_sidebar_bottom_bar(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
2888        let on_right = self.side(cx) == SidebarSide::Right;
2889        let is_archive = matches!(self.view, SidebarView::Archive(..));
2890        let action_buttons = h_flex()
2891            .gap_1()
2892            .child(
2893                IconButton::new("archive", IconName::Archive)
2894                    .icon_size(IconSize::Small)
2895                    .toggle_state(is_archive)
2896                    .tooltip(move |_, cx| {
2897                        Tooltip::for_action("Toggle Archived Threads", &ToggleArchive, cx)
2898                    })
2899                    .on_click(cx.listener(|this, _, window, cx| {
2900                        this.toggle_archive(&ToggleArchive, window, cx);
2901                    })),
2902            )
2903            .child(self.render_recent_projects_button(cx));
2904        let border_color = cx.theme().colors().border;
2905        let toggle_button = self.render_sidebar_toggle_button(cx);
2906
2907        let bar = h_flex()
2908            .p_1()
2909            .gap_1()
2910            .justify_between()
2911            .border_t_1()
2912            .border_color(border_color);
2913
2914        if on_right {
2915            bar.child(action_buttons).child(toggle_button)
2916        } else {
2917            bar.child(toggle_button).child(action_buttons)
2918        }
2919    }
2920
2921    fn toggle_archive(&mut self, _: &ToggleArchive, window: &mut Window, cx: &mut Context<Self>) {
2922        match &self.view {
2923            SidebarView::ThreadList => self.show_archive(window, cx),
2924            SidebarView::Archive(_) => self.show_thread_list(window, cx),
2925        }
2926    }
2927
2928    fn show_archive(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2929        let Some(active_workspace) = self.multi_workspace.upgrade().and_then(|w| {
2930            w.read(cx)
2931                .workspaces()
2932                .get(w.read(cx).active_workspace_index())
2933                .cloned()
2934        }) else {
2935            return;
2936        };
2937
2938        let Some(agent_panel) = active_workspace.read(cx).panel::<AgentPanel>(cx) else {
2939            return;
2940        };
2941
2942        let thread_store = agent_panel.read(cx).thread_store().clone();
2943        let fs = active_workspace.read(cx).project().read(cx).fs().clone();
2944        let agent_connection_store = agent_panel.read(cx).connection_store().clone();
2945        let agent_server_store = active_workspace
2946            .read(cx)
2947            .project()
2948            .read(cx)
2949            .agent_server_store()
2950            .clone();
2951
2952        let archive_view = cx.new(|cx| {
2953            ThreadsArchiveView::new(
2954                agent_connection_store,
2955                agent_server_store,
2956                thread_store,
2957                fs,
2958                window,
2959                cx,
2960            )
2961        });
2962        let subscription = cx.subscribe_in(
2963            &archive_view,
2964            window,
2965            |this, _, event: &ThreadsArchiveViewEvent, window, cx| match event {
2966                ThreadsArchiveViewEvent::Close => {
2967                    this.show_thread_list(window, cx);
2968                }
2969                ThreadsArchiveViewEvent::Unarchive {
2970                    agent,
2971                    session_info,
2972                } => {
2973                    this.show_thread_list(window, cx);
2974                    this.activate_archived_thread(agent.clone(), session_info.clone(), window, cx);
2975                }
2976            },
2977        );
2978
2979        self._subscriptions.push(subscription);
2980        self.view = SidebarView::Archive(archive_view.clone());
2981        archive_view.update(cx, |view, cx| view.focus_filter_editor(window, cx));
2982        cx.notify();
2983    }
2984
2985    fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2986        self.view = SidebarView::ThreadList;
2987        self._subscriptions.clear();
2988        let handle = self.filter_editor.read(cx).focus_handle(cx);
2989        handle.focus(window, cx);
2990        cx.notify();
2991    }
2992}
2993
2994impl WorkspaceSidebar for Sidebar {
2995    fn width(&self, _cx: &App) -> Pixels {
2996        self.width
2997    }
2998
2999    fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
3000        self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
3001        cx.notify();
3002    }
3003
3004    fn has_notifications(&self, _cx: &App) -> bool {
3005        !self.contents.notified_threads.is_empty()
3006    }
3007
3008    fn is_threads_list_view_active(&self) -> bool {
3009        matches!(self.view, SidebarView::ThreadList)
3010    }
3011
3012    fn side(&self, cx: &App) -> SidebarSide {
3013        AgentSettings::get_global(cx).sidebar_side()
3014    }
3015
3016    fn prepare_for_focus(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
3017        self.selection = None;
3018        cx.notify();
3019    }
3020}
3021
3022impl Focusable for Sidebar {
3023    fn focus_handle(&self, _cx: &App) -> FocusHandle {
3024        self.focus_handle.clone()
3025    }
3026}
3027
3028impl Render for Sidebar {
3029    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3030        let _titlebar_height = ui::utils::platform_title_bar_height(window);
3031        let ui_font = theme_settings::setup_ui_font(window, cx);
3032        let sticky_header = self.render_sticky_header(window, cx);
3033
3034        let color = cx.theme().colors();
3035        let bg = color
3036            .title_bar_background
3037            .blend(color.panel_background.opacity(0.32));
3038
3039        let no_open_projects = !self.contents.has_open_projects;
3040        let no_search_results = self.contents.entries.is_empty();
3041
3042        v_flex()
3043            .id("workspace-sidebar")
3044            .key_context(self.dispatch_context(window, cx))
3045            .track_focus(&self.focus_handle)
3046            .on_action(cx.listener(Self::select_next))
3047            .on_action(cx.listener(Self::select_previous))
3048            .on_action(cx.listener(Self::editor_move_down))
3049            .on_action(cx.listener(Self::editor_move_up))
3050            .on_action(cx.listener(Self::select_first))
3051            .on_action(cx.listener(Self::select_last))
3052            .on_action(cx.listener(Self::confirm))
3053            .on_action(cx.listener(Self::expand_selected_entry))
3054            .on_action(cx.listener(Self::collapse_selected_entry))
3055            .on_action(cx.listener(Self::toggle_selected_fold))
3056            .on_action(cx.listener(Self::fold_all))
3057            .on_action(cx.listener(Self::unfold_all))
3058            .on_action(cx.listener(Self::cancel))
3059            .on_action(cx.listener(Self::remove_selected_thread))
3060            .on_action(cx.listener(Self::new_thread_in_group))
3061            .on_action(cx.listener(Self::toggle_archive))
3062            .on_action(cx.listener(Self::focus_sidebar_filter))
3063            .on_action(cx.listener(|this, _: &OpenRecent, window, cx| {
3064                this.recent_projects_popover_handle.toggle(window, cx);
3065            }))
3066            .font(ui_font)
3067            .h_full()
3068            .w(self.width)
3069            .bg(bg)
3070            .when(self.side(cx) == SidebarSide::Left, |el| el.border_r_1())
3071            .when(self.side(cx) == SidebarSide::Right, |el| el.border_l_1())
3072            .border_color(color.border)
3073            .map(|this| match &self.view {
3074                SidebarView::ThreadList => this
3075                    .child(self.render_sidebar_header(no_open_projects, window, cx))
3076                    .map(|this| {
3077                        if no_open_projects {
3078                            this.child(self.render_empty_state(cx))
3079                        } else {
3080                            this.child(
3081                                v_flex()
3082                                    .relative()
3083                                    .flex_1()
3084                                    .overflow_hidden()
3085                                    .child(
3086                                        list(
3087                                            self.list_state.clone(),
3088                                            cx.processor(Self::render_list_entry),
3089                                        )
3090                                        .flex_1()
3091                                        .size_full(),
3092                                    )
3093                                    .when(no_search_results, |this| {
3094                                        this.child(self.render_no_results(cx))
3095                                    })
3096                                    .when_some(sticky_header, |this, header| this.child(header))
3097                                    .vertical_scrollbar_for(&self.list_state, window, cx),
3098                            )
3099                        }
3100                    }),
3101                SidebarView::Archive(archive_view) => this.child(archive_view.clone()),
3102            })
3103            .child(self.render_sidebar_bottom_bar(cx))
3104    }
3105}
3106
3107fn all_thread_infos_for_workspace(
3108    workspace: &Entity<Workspace>,
3109    cx: &App,
3110) -> impl Iterator<Item = ActiveThreadInfo> {
3111    let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
3112        return None.into_iter().flatten();
3113    };
3114    let agent_panel = agent_panel.read(cx);
3115
3116    let threads = agent_panel
3117        .parent_threads(cx)
3118        .into_iter()
3119        .map(|thread_view| {
3120            let thread_view_ref = thread_view.read(cx);
3121            let thread = thread_view_ref.thread.read(cx);
3122
3123            let icon = thread_view_ref.agent_icon;
3124            let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone();
3125            let title = thread
3126                .title()
3127                .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into());
3128            let is_native = thread_view_ref.as_native_thread(cx).is_some();
3129            let is_title_generating = is_native && thread.has_provisional_title();
3130            let session_id = thread.session_id().clone();
3131            let is_background = agent_panel.is_background_thread(&session_id);
3132
3133            let status = if thread.is_waiting_for_confirmation() {
3134                AgentThreadStatus::WaitingForConfirmation
3135            } else if thread.had_error() {
3136                AgentThreadStatus::Error
3137            } else {
3138                match thread.status() {
3139                    ThreadStatus::Generating => AgentThreadStatus::Running,
3140                    ThreadStatus::Idle => AgentThreadStatus::Completed,
3141                }
3142            };
3143
3144            let diff_stats = thread.action_log().read(cx).diff_stats(cx);
3145
3146            ActiveThreadInfo {
3147                session_id,
3148                title,
3149                status,
3150                icon,
3151                icon_from_external_svg,
3152                is_background,
3153                is_title_generating,
3154                diff_stats,
3155            }
3156        });
3157
3158    Some(threads).into_iter().flatten()
3159}