sidebar.rs

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