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