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