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        for (group_key, group_workspaces) in mw.project_groups(cx) {
 802            let path_list = group_key.path_list().clone();
 803            if path_list.paths().is_empty() {
 804                continue;
 805            }
 806
 807            let label = group_key.display_name();
 808
 809            let is_collapsed = self.collapsed_groups.contains(&path_list);
 810            let should_load_threads = !is_collapsed || !query.is_empty();
 811
 812            let is_active = active_workspace
 813                .as_ref()
 814                .is_some_and(|active| group_workspaces.contains(active));
 815
 816            // Collect live thread infos from all workspaces in this group.
 817            let live_infos: Vec<_> = group_workspaces
 818                .iter()
 819                .flat_map(|ws| all_thread_infos_for_workspace(ws, cx))
 820                .collect();
 821
 822            let mut threads: Vec<ThreadEntry> = Vec::new();
 823            let mut has_running_threads = false;
 824            let mut waiting_thread_count: usize = 0;
 825
 826            if should_load_threads {
 827                let mut seen_session_ids: HashSet<acp::SessionId> = HashSet::new();
 828                let thread_store = ThreadMetadataStore::global(cx);
 829
 830                // Build a lookup from workspace root paths to their workspace
 831                // entity, used to assign ThreadEntryWorkspace::Open for threads
 832                // whose folder_paths match an open workspace.
 833                let workspace_by_path_list: HashMap<PathList, &Entity<Workspace>> =
 834                    group_workspaces
 835                        .iter()
 836                        .map(|ws| (workspace_path_list(ws, cx), ws))
 837                        .collect();
 838
 839                // Resolve a ThreadEntryWorkspace for a thread row. If any open
 840                // workspace's root paths match the thread's folder_paths, use
 841                // Open; otherwise use Closed.
 842                let resolve_workspace = |row: &ThreadMetadata| -> ThreadEntryWorkspace {
 843                    workspace_by_path_list
 844                        .get(&row.folder_paths)
 845                        .map(|ws| ThreadEntryWorkspace::Open((*ws).clone()))
 846                        .unwrap_or_else(|| ThreadEntryWorkspace::Closed(row.folder_paths.clone()))
 847                };
 848
 849                // Build a ThreadEntry from a metadata row.
 850                let make_thread_entry = |row: ThreadMetadata,
 851                                         workspace: ThreadEntryWorkspace|
 852                 -> ThreadEntry {
 853                    let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id);
 854                    let worktrees = worktree_info_from_thread_paths(&row.folder_paths, &group_key);
 855                    ThreadEntry {
 856                        metadata: row,
 857                        icon,
 858                        icon_from_external_svg,
 859                        status: AgentThreadStatus::default(),
 860                        workspace,
 861                        is_live: false,
 862                        is_background: false,
 863                        is_title_generating: false,
 864                        highlight_positions: Vec::new(),
 865                        worktrees,
 866                        diff_stats: DiffStats::default(),
 867                    }
 868                };
 869
 870                // === Main code path: one query per group via main_worktree_paths ===
 871                // The main_worktree_paths column is set on all new threads and
 872                // points to the group's canonical paths regardless of which
 873                // linked worktree the thread was opened in.
 874                for row in thread_store
 875                    .read(cx)
 876                    .entries_for_main_worktree_path(&path_list)
 877                    .cloned()
 878                {
 879                    if !seen_session_ids.insert(row.session_id.clone()) {
 880                        continue;
 881                    }
 882                    let workspace = resolve_workspace(&row);
 883                    threads.push(make_thread_entry(row, workspace));
 884                }
 885
 886                // Legacy threads did not have `main_worktree_paths` populated, so they
 887                // must be queried by their `folder_paths`.
 888
 889                // Load any legacy threads for the main worktrees of this project group.
 890                for row in thread_store.read(cx).entries_for_path(&path_list).cloned() {
 891                    if !seen_session_ids.insert(row.session_id.clone()) {
 892                        continue;
 893                    }
 894                    let workspace = resolve_workspace(&row);
 895                    threads.push(make_thread_entry(row, workspace));
 896                }
 897
 898                // Load any legacy threads for any single linked wortree of this project group.
 899                let mut linked_worktree_paths = HashSet::new();
 900                for workspace in &group_workspaces {
 901                    if workspace.read(cx).visible_worktrees(cx).count() != 1 {
 902                        continue;
 903                    }
 904                    for snapshot in root_repository_snapshots(workspace, cx) {
 905                        for linked_worktree in snapshot.linked_worktrees() {
 906                            linked_worktree_paths.insert(linked_worktree.path.clone());
 907                        }
 908                    }
 909                }
 910                for path in linked_worktree_paths {
 911                    let worktree_path_list = PathList::new(std::slice::from_ref(&path));
 912                    for row in thread_store
 913                        .read(cx)
 914                        .entries_for_path(&worktree_path_list)
 915                        .cloned()
 916                    {
 917                        if !seen_session_ids.insert(row.session_id.clone()) {
 918                            continue;
 919                        }
 920                        threads.push(make_thread_entry(
 921                            row,
 922                            ThreadEntryWorkspace::Closed(worktree_path_list.clone()),
 923                        ));
 924                    }
 925                }
 926
 927                // Build a lookup from live_infos and compute running/waiting
 928                // counts in a single pass.
 929                let mut live_info_by_session: HashMap<&acp::SessionId, &ActiveThreadInfo> =
 930                    HashMap::new();
 931                for info in &live_infos {
 932                    live_info_by_session.insert(&info.session_id, info);
 933                    if info.status == AgentThreadStatus::Running {
 934                        has_running_threads = true;
 935                    }
 936                    if info.status == AgentThreadStatus::WaitingForConfirmation {
 937                        waiting_thread_count += 1;
 938                    }
 939                }
 940
 941                // Merge live info into threads and update notification state
 942                // in a single pass.
 943                for thread in &mut threads {
 944                    if let Some(info) = live_info_by_session.get(&thread.metadata.session_id) {
 945                        thread.apply_active_info(info);
 946                    }
 947
 948                    let session_id = &thread.metadata.session_id;
 949
 950                    let is_active_thread = self.active_entry.as_ref().is_some_and(|entry| {
 951                        entry.is_active_thread(session_id)
 952                            && active_workspace
 953                                .as_ref()
 954                                .is_some_and(|active| active == entry.workspace())
 955                    });
 956
 957                    if thread.status == AgentThreadStatus::Completed
 958                        && !is_active_thread
 959                        && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running)
 960                    {
 961                        notified_threads.insert(session_id.clone());
 962                    }
 963
 964                    if is_active_thread && !thread.is_background {
 965                        notified_threads.remove(session_id);
 966                    }
 967                }
 968
 969                threads.sort_by(|a, b| {
 970                    let a_time = self
 971                        .thread_last_message_sent_or_queued
 972                        .get(&a.metadata.session_id)
 973                        .copied()
 974                        .or(a.metadata.created_at)
 975                        .or(Some(a.metadata.updated_at));
 976                    let b_time = self
 977                        .thread_last_message_sent_or_queued
 978                        .get(&b.metadata.session_id)
 979                        .copied()
 980                        .or(b.metadata.created_at)
 981                        .or(Some(b.metadata.updated_at));
 982                    b_time.cmp(&a_time)
 983                });
 984            } else {
 985                for info in live_infos {
 986                    if info.status == AgentThreadStatus::Running {
 987                        has_running_threads = true;
 988                    }
 989                    if info.status == AgentThreadStatus::WaitingForConfirmation {
 990                        waiting_thread_count += 1;
 991                    }
 992                }
 993            }
 994
 995            if !query.is_empty() {
 996                let workspace_highlight_positions =
 997                    fuzzy_match_positions(&query, &label).unwrap_or_default();
 998                let workspace_matched = !workspace_highlight_positions.is_empty();
 999
1000                let mut matched_threads: Vec<ThreadEntry> = Vec::new();
1001                for mut thread in threads {
1002                    let title: &str = &thread.metadata.title;
1003                    if let Some(positions) = fuzzy_match_positions(&query, title) {
1004                        thread.highlight_positions = positions;
1005                    }
1006                    let mut worktree_matched = false;
1007                    for worktree in &mut thread.worktrees {
1008                        if let Some(positions) = fuzzy_match_positions(&query, &worktree.name) {
1009                            worktree.highlight_positions = positions;
1010                            worktree_matched = true;
1011                        }
1012                    }
1013                    if workspace_matched
1014                        || !thread.highlight_positions.is_empty()
1015                        || worktree_matched
1016                    {
1017                        matched_threads.push(thread);
1018                    }
1019                }
1020
1021                if matched_threads.is_empty() && !workspace_matched {
1022                    continue;
1023                }
1024
1025                project_header_indices.push(entries.len());
1026                entries.push(ListEntry::ProjectHeader {
1027                    key: group_key.clone(),
1028                    label,
1029                    highlight_positions: workspace_highlight_positions,
1030                    has_running_threads,
1031                    waiting_thread_count,
1032                    is_active,
1033                });
1034
1035                for thread in matched_threads {
1036                    current_session_ids.insert(thread.metadata.session_id.clone());
1037                    entries.push(thread.into());
1038                }
1039            } else {
1040                let is_draft_for_group = is_active
1041                    && matches!(&self.active_entry, Some(ActiveEntry::Draft(ws)) if group_workspaces.contains(ws));
1042
1043                project_header_indices.push(entries.len());
1044                entries.push(ListEntry::ProjectHeader {
1045                    key: group_key.clone(),
1046                    label,
1047                    highlight_positions: Vec::new(),
1048                    has_running_threads,
1049                    waiting_thread_count,
1050                    is_active,
1051                });
1052
1053                if is_collapsed {
1054                    continue;
1055                }
1056
1057                // Emit a DraftThread entry when the active draft belongs to this group.
1058                if is_draft_for_group {
1059                    if let Some(ActiveEntry::Draft(draft_ws)) = &self.active_entry {
1060                        let ws_path_list = workspace_path_list(draft_ws, cx);
1061                        let worktrees = worktree_info_from_thread_paths(&ws_path_list, &group_key);
1062                        entries.push(ListEntry::DraftThread { worktrees });
1063                    }
1064                }
1065
1066                // Emit a NewThread entry when:
1067                // 1. The group has zero threads (convenient affordance).
1068                // 2. The active workspace has linked worktrees but no threads
1069                //    for the active workspace's specific set of worktrees.
1070                let group_has_no_threads = threads.is_empty() && !group_workspaces.is_empty();
1071                let active_ws_has_threadless_linked_worktrees = is_active
1072                    && !is_draft_for_group
1073                    && active_workspace.as_ref().is_some_and(|active_ws| {
1074                        let ws_path_list = workspace_path_list(active_ws, cx);
1075                        let has_linked_worktrees =
1076                            !worktree_info_from_thread_paths(&ws_path_list, &group_key).is_empty();
1077                        if !has_linked_worktrees {
1078                            return false;
1079                        }
1080                        let thread_store = ThreadMetadataStore::global(cx);
1081                        let has_threads_for_ws = thread_store
1082                            .read(cx)
1083                            .entries_for_path(&ws_path_list)
1084                            .next()
1085                            .is_some()
1086                            || thread_store
1087                                .read(cx)
1088                                .entries_for_main_worktree_path(&ws_path_list)
1089                                .next()
1090                                .is_some();
1091                        !has_threads_for_ws
1092                    });
1093
1094                if !is_draft_for_group
1095                    && (group_has_no_threads || active_ws_has_threadless_linked_worktrees)
1096                {
1097                    let worktrees = if active_ws_has_threadless_linked_worktrees {
1098                        active_workspace
1099                            .as_ref()
1100                            .map(|ws| {
1101                                worktree_info_from_thread_paths(
1102                                    &workspace_path_list(ws, cx),
1103                                    &group_key,
1104                                )
1105                            })
1106                            .unwrap_or_default()
1107                    } else {
1108                        Vec::new()
1109                    };
1110                    entries.push(ListEntry::NewThread {
1111                        key: group_key.clone(),
1112                        worktrees,
1113                    });
1114                }
1115
1116                let total = threads.len();
1117
1118                let extra_batches = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
1119                let threads_to_show =
1120                    DEFAULT_THREADS_SHOWN + (extra_batches * DEFAULT_THREADS_SHOWN);
1121                let count = threads_to_show.min(total);
1122
1123                let mut promoted_threads: HashSet<acp::SessionId> = HashSet::new();
1124
1125                // Build visible entries in a single pass. Threads within
1126                // the cutoff are always shown. Threads beyond it are shown
1127                // only if they should be promoted (running, waiting, or
1128                // focused)
1129                for (index, thread) in threads.into_iter().enumerate() {
1130                    let is_hidden = index >= count;
1131
1132                    let session_id = &thread.metadata.session_id;
1133                    if is_hidden {
1134                        let is_promoted = thread.status == AgentThreadStatus::Running
1135                            || thread.status == AgentThreadStatus::WaitingForConfirmation
1136                            || notified_threads.contains(session_id)
1137                            || self.active_entry.as_ref().is_some_and(|active| {
1138                                active.matches_entry(&ListEntry::Thread(thread.clone()))
1139                            });
1140                        if is_promoted {
1141                            promoted_threads.insert(session_id.clone());
1142                        }
1143                        if !promoted_threads.contains(session_id) {
1144                            continue;
1145                        }
1146                    }
1147
1148                    current_session_ids.insert(session_id.clone());
1149                    entries.push(thread.into());
1150                }
1151
1152                let visible = count + promoted_threads.len();
1153                let is_fully_expanded = visible >= total;
1154
1155                if total > DEFAULT_THREADS_SHOWN {
1156                    entries.push(ListEntry::ViewMore {
1157                        key: group_key.clone(),
1158                        is_fully_expanded,
1159                    });
1160                }
1161            }
1162        }
1163
1164        // Prune stale notifications using the session IDs we collected during
1165        // the build pass (no extra scan needed).
1166        notified_threads.retain(|id| current_session_ids.contains(id));
1167
1168        self.thread_last_accessed
1169            .retain(|id, _| current_session_ids.contains(id));
1170        self.thread_last_message_sent_or_queued
1171            .retain(|id, _| current_session_ids.contains(id));
1172
1173        self.contents = SidebarContents {
1174            entries,
1175            notified_threads,
1176            project_header_indices,
1177            has_open_projects,
1178        };
1179    }
1180
1181    /// Rebuilds the sidebar's visible entries from already-cached state.
1182    fn update_entries(&mut self, cx: &mut Context<Self>) {
1183        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1184            return;
1185        };
1186        if !multi_workspace.read(cx).multi_workspace_enabled(cx) {
1187            return;
1188        }
1189
1190        let had_notifications = self.has_notifications(cx);
1191        let scroll_position = self.list_state.logical_scroll_top();
1192
1193        self.rebuild_contents(cx);
1194
1195        self.list_state.reset(self.contents.entries.len());
1196        self.list_state.scroll_to(scroll_position);
1197
1198        if had_notifications != self.has_notifications(cx) {
1199            multi_workspace.update(cx, |_, cx| {
1200                cx.notify();
1201            });
1202        }
1203
1204        cx.notify();
1205    }
1206
1207    fn select_first_entry(&mut self) {
1208        self.selection = self
1209            .contents
1210            .entries
1211            .iter()
1212            .position(|entry| matches!(entry, ListEntry::Thread(_)))
1213            .or_else(|| {
1214                if self.contents.entries.is_empty() {
1215                    None
1216                } else {
1217                    Some(0)
1218                }
1219            });
1220    }
1221
1222    fn render_list_entry(
1223        &mut self,
1224        ix: usize,
1225        window: &mut Window,
1226        cx: &mut Context<Self>,
1227    ) -> AnyElement {
1228        let Some(entry) = self.contents.entries.get(ix) else {
1229            return div().into_any_element();
1230        };
1231        let is_focused = self.focus_handle.is_focused(window);
1232        // is_selected means the keyboard selector is here.
1233        let is_selected = is_focused && self.selection == Some(ix);
1234
1235        let is_group_header_after_first =
1236            ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. });
1237
1238        let is_active = self
1239            .active_entry
1240            .as_ref()
1241            .is_some_and(|active| active.matches_entry(entry));
1242
1243        let rendered = match entry {
1244            ListEntry::ProjectHeader {
1245                key,
1246                label,
1247                highlight_positions,
1248                has_running_threads,
1249                waiting_thread_count,
1250                is_active: is_active_group,
1251            } => self.render_project_header(
1252                ix,
1253                false,
1254                key,
1255                label,
1256                highlight_positions,
1257                *has_running_threads,
1258                *waiting_thread_count,
1259                *is_active_group,
1260                is_selected,
1261                cx,
1262            ),
1263            ListEntry::Thread(thread) => self.render_thread(ix, thread, is_active, is_selected, cx),
1264            ListEntry::ViewMore {
1265                key,
1266                is_fully_expanded,
1267            } => self.render_view_more(ix, key.path_list(), *is_fully_expanded, is_selected, cx),
1268            ListEntry::DraftThread { worktrees, .. } => {
1269                self.render_draft_thread(ix, is_active, worktrees, is_selected, cx)
1270            }
1271            ListEntry::NewThread { key, worktrees, .. } => {
1272                self.render_new_thread(ix, key, worktrees, is_selected, cx)
1273            }
1274        };
1275
1276        if is_group_header_after_first {
1277            v_flex()
1278                .w_full()
1279                .border_t_1()
1280                .border_color(cx.theme().colors().border)
1281                .child(rendered)
1282                .into_any_element()
1283        } else {
1284            rendered
1285        }
1286    }
1287
1288    fn render_remote_project_icon(
1289        &self,
1290        ix: usize,
1291        host: Option<&RemoteConnectionOptions>,
1292    ) -> Option<AnyElement> {
1293        let remote_icon_per_type = match host? {
1294            RemoteConnectionOptions::Wsl(_) => IconName::Linux,
1295            RemoteConnectionOptions::Docker(_) => IconName::Box,
1296            _ => IconName::Server,
1297        };
1298
1299        Some(
1300            div()
1301                .id(format!("remote-project-icon-{}", ix))
1302                .child(
1303                    Icon::new(remote_icon_per_type)
1304                        .size(IconSize::XSmall)
1305                        .color(Color::Muted),
1306                )
1307                .tooltip(Tooltip::text("Remote Project"))
1308                .into_any_element(),
1309        )
1310    }
1311
1312    fn render_project_header(
1313        &self,
1314        ix: usize,
1315        is_sticky: bool,
1316        key: &ProjectGroupKey,
1317        label: &SharedString,
1318        highlight_positions: &[usize],
1319        has_running_threads: bool,
1320        waiting_thread_count: usize,
1321        is_active: bool,
1322        is_focused: bool,
1323        cx: &mut Context<Self>,
1324    ) -> AnyElement {
1325        let path_list = key.path_list();
1326        let host = key.host();
1327
1328        let id_prefix = if is_sticky { "sticky-" } else { "" };
1329        let id = SharedString::from(format!("{id_prefix}project-header-{ix}"));
1330        let disclosure_id = SharedString::from(format!("disclosure-{ix}"));
1331        let group_name = SharedString::from(format!("{id_prefix}header-group-{ix}"));
1332
1333        let is_collapsed = self.collapsed_groups.contains(path_list);
1334        let (disclosure_icon, disclosure_tooltip) = if is_collapsed {
1335            (IconName::ChevronRight, "Expand Project")
1336        } else {
1337            (IconName::ChevronDown, "Collapse Project")
1338        };
1339
1340        let has_new_thread_entry = self.contents.entries.get(ix + 1).is_some_and(|entry| {
1341            matches!(
1342                entry,
1343                ListEntry::NewThread { .. } | ListEntry::DraftThread { .. }
1344            )
1345        });
1346        let show_new_thread_button = !has_new_thread_entry && !self.has_filter_query(cx);
1347
1348        let workspace = self.workspace_for_group(path_list, cx);
1349
1350        let path_list_for_toggle = path_list.clone();
1351        let path_list_for_collapse = path_list.clone();
1352        let view_more_expanded = self.expanded_groups.contains_key(path_list);
1353
1354        let label = if highlight_positions.is_empty() {
1355            Label::new(label.clone())
1356                .when(!is_active, |this| this.color(Color::Muted))
1357                .into_any_element()
1358        } else {
1359            HighlightedLabel::new(label.clone(), highlight_positions.to_vec())
1360                .when(!is_active, |this| this.color(Color::Muted))
1361                .into_any_element()
1362        };
1363
1364        let color = cx.theme().colors();
1365        let hover_color = color
1366            .element_active
1367            .blend(color.element_background.opacity(0.2));
1368
1369        h_flex()
1370            .id(id)
1371            .group(&group_name)
1372            .h(Tab::content_height(cx))
1373            .w_full()
1374            .pl(px(5.))
1375            .pr_1p5()
1376            .border_1()
1377            .map(|this| {
1378                if is_focused {
1379                    this.border_color(color.border_focused)
1380                } else {
1381                    this.border_color(gpui::transparent_black())
1382                }
1383            })
1384            .justify_between()
1385            .child(
1386                h_flex()
1387                    .when(!is_active, |this| this.cursor_pointer())
1388                    .relative()
1389                    .min_w_0()
1390                    .w_full()
1391                    .gap(px(5.))
1392                    .child(
1393                        IconButton::new(disclosure_id, disclosure_icon)
1394                            .shape(ui::IconButtonShape::Square)
1395                            .icon_size(IconSize::Small)
1396                            .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.5)))
1397                            .tooltip(Tooltip::text(disclosure_tooltip))
1398                            .on_click(cx.listener(move |this, _, window, cx| {
1399                                this.selection = None;
1400                                this.toggle_collapse(&path_list_for_toggle, window, cx);
1401                            })),
1402                    )
1403                    .child(label)
1404                    .when_some(
1405                        self.render_remote_project_icon(ix, host.as_ref()),
1406                        |this, icon| this.child(icon),
1407                    )
1408                    .when(is_collapsed, |this| {
1409                        this.when(has_running_threads, |this| {
1410                            this.child(
1411                                Icon::new(IconName::LoadCircle)
1412                                    .size(IconSize::XSmall)
1413                                    .color(Color::Muted)
1414                                    .with_rotate_animation(2),
1415                            )
1416                        })
1417                        .when(waiting_thread_count > 0, |this| {
1418                            let tooltip_text = if waiting_thread_count == 1 {
1419                                "1 thread is waiting for confirmation".to_string()
1420                            } else {
1421                                format!(
1422                                    "{waiting_thread_count} threads are waiting for confirmation",
1423                                )
1424                            };
1425                            this.child(
1426                                div()
1427                                    .id(format!("{id_prefix}waiting-indicator-{ix}"))
1428                                    .child(
1429                                        Icon::new(IconName::Warning)
1430                                            .size(IconSize::XSmall)
1431                                            .color(Color::Warning),
1432                                    )
1433                                    .tooltip(Tooltip::text(tooltip_text)),
1434                            )
1435                        })
1436                    }),
1437            )
1438            .child(
1439                h_flex()
1440                    .when(self.project_header_menu_ix != Some(ix), |this| {
1441                        this.visible_on_hover(group_name)
1442                    })
1443                    .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
1444                        cx.stop_propagation();
1445                    })
1446                    .child(self.render_project_header_menu(ix, id_prefix, key, cx))
1447                    .when(view_more_expanded && !is_collapsed, |this| {
1448                        this.child(
1449                            IconButton::new(
1450                                SharedString::from(format!(
1451                                    "{id_prefix}project-header-collapse-{ix}",
1452                                )),
1453                                IconName::ListCollapse,
1454                            )
1455                            .icon_size(IconSize::Small)
1456                            .tooltip(Tooltip::text("Collapse Displayed Threads"))
1457                            .on_click(cx.listener({
1458                                let path_list_for_collapse = path_list_for_collapse.clone();
1459                                move |this, _, _window, cx| {
1460                                    this.selection = None;
1461                                    this.expanded_groups.remove(&path_list_for_collapse);
1462                                    this.serialize(cx);
1463                                    this.update_entries(cx);
1464                                }
1465                            })),
1466                        )
1467                    })
1468                    .when_some(
1469                        workspace.filter(|_| show_new_thread_button),
1470                        |this, workspace| {
1471                            let path_list = path_list.clone();
1472                            this.child(
1473                                IconButton::new(
1474                                    SharedString::from(format!(
1475                                        "{id_prefix}project-header-new-thread-{ix}",
1476                                    )),
1477                                    IconName::Plus,
1478                                )
1479                                .icon_size(IconSize::Small)
1480                                .tooltip(Tooltip::text("New Thread"))
1481                                .on_click(cx.listener(
1482                                    move |this, _, window, cx| {
1483                                        this.collapsed_groups.remove(&path_list);
1484                                        this.selection = None;
1485                                        this.create_new_thread(&workspace, window, cx);
1486                                    },
1487                                )),
1488                            )
1489                        },
1490                    ),
1491            )
1492            .when(!is_active, |this| {
1493                let path_list = path_list.clone();
1494                this.cursor_pointer()
1495                    .hover(|s| s.bg(hover_color))
1496                    .tooltip(Tooltip::text("Open Workspace"))
1497                    .on_click(cx.listener(move |this, _, window, cx| {
1498                        if let Some(workspace) = this.workspace_for_group(&path_list, cx) {
1499                            this.active_entry = Some(ActiveEntry::Draft(workspace.clone()));
1500                            if let Some(multi_workspace) = this.multi_workspace.upgrade() {
1501                                multi_workspace.update(cx, |multi_workspace, cx| {
1502                                    multi_workspace.activate(workspace.clone(), window, cx);
1503                                });
1504                            }
1505                            if AgentPanel::is_visible(&workspace, cx) {
1506                                workspace.update(cx, |workspace, cx| {
1507                                    workspace.focus_panel::<AgentPanel>(window, cx);
1508                                });
1509                            }
1510                        } else {
1511                            this.open_workspace_for_group(&path_list, window, cx);
1512                        }
1513                    }))
1514            })
1515            .into_any_element()
1516    }
1517
1518    fn render_project_header_menu(
1519        &self,
1520        ix: usize,
1521        id_prefix: &str,
1522        project_group_key: &ProjectGroupKey,
1523        cx: &mut Context<Self>,
1524    ) -> impl IntoElement {
1525        let multi_workspace = self.multi_workspace.clone();
1526        let this = cx.weak_entity();
1527        let project_group_key = project_group_key.clone();
1528
1529        PopoverMenu::new(format!("{id_prefix}project-header-menu-{ix}"))
1530            .on_open(Rc::new({
1531                let this = this.clone();
1532                move |_window, cx| {
1533                    this.update(cx, |sidebar, cx| {
1534                        sidebar.project_header_menu_ix = Some(ix);
1535                        cx.notify();
1536                    })
1537                    .ok();
1538                }
1539            }))
1540            .menu(move |window, cx| {
1541                let multi_workspace = multi_workspace.clone();
1542                let project_group_key = project_group_key.clone();
1543
1544                let menu = ContextMenu::build_persistent(window, cx, move |menu, _window, cx| {
1545                    let mut menu = menu
1546                        .header("Project Folders")
1547                        .end_slot_action(Box::new(menu::EndSlot));
1548
1549                    for path in project_group_key.path_list().paths() {
1550                        let Some(name) = path.file_name() else {
1551                            continue;
1552                        };
1553                        let name: SharedString = name.to_string_lossy().into_owned().into();
1554                        let path = path.clone();
1555                        let project_group_key = project_group_key.clone();
1556                        let multi_workspace = multi_workspace.clone();
1557                        menu = menu.entry_with_end_slot_on_hover(
1558                            name.clone(),
1559                            None,
1560                            |_, _| {},
1561                            IconName::Close,
1562                            "Remove Folder".into(),
1563                            move |_window, cx| {
1564                                multi_workspace
1565                                    .update(cx, |multi_workspace, cx| {
1566                                        multi_workspace.remove_folder_from_project_group(
1567                                            &project_group_key,
1568                                            &path,
1569                                            cx,
1570                                        );
1571                                    })
1572                                    .ok();
1573                            },
1574                        );
1575                    }
1576
1577                    let menu = menu.separator().entry(
1578                        "Add Folder to Project",
1579                        Some(Box::new(AddFolderToProject)),
1580                        {
1581                            let project_group_key = project_group_key.clone();
1582                            let multi_workspace = multi_workspace.clone();
1583                            move |window, cx| {
1584                                multi_workspace
1585                                    .update(cx, |multi_workspace, cx| {
1586                                        multi_workspace.prompt_to_add_folders_to_project_group(
1587                                            &project_group_key,
1588                                            window,
1589                                            cx,
1590                                        );
1591                                    })
1592                                    .ok();
1593                            }
1594                        },
1595                    );
1596
1597                    let group_count = multi_workspace
1598                        .upgrade()
1599                        .map_or(0, |mw| mw.read(cx).project_group_keys().count());
1600                    let menu = if group_count > 1 {
1601                        let project_group_key = project_group_key.clone();
1602                        let multi_workspace = multi_workspace.clone();
1603                        menu.entry(
1604                            "Move to New Window",
1605                            Some(Box::new(
1606                                zed_actions::agents_sidebar::MoveWorkspaceToNewWindow,
1607                            )),
1608                            move |window, cx| {
1609                                multi_workspace
1610                                    .update(cx, |multi_workspace, cx| {
1611                                        multi_workspace.move_project_group_to_new_window(
1612                                            &project_group_key,
1613                                            window,
1614                                            cx,
1615                                        );
1616                                    })
1617                                    .ok();
1618                            },
1619                        )
1620                    } else {
1621                        menu
1622                    };
1623
1624                    let project_group_key = project_group_key.clone();
1625                    let multi_workspace = multi_workspace.clone();
1626                    menu.separator()
1627                        .entry("Remove Project", None, move |window, cx| {
1628                            multi_workspace
1629                                .update(cx, |multi_workspace, cx| {
1630                                    multi_workspace.remove_project_group(
1631                                        &project_group_key,
1632                                        window,
1633                                        cx,
1634                                    );
1635                                })
1636                                .ok();
1637                        })
1638                });
1639
1640                let this = this.clone();
1641                window
1642                    .subscribe(&menu, cx, move |_, _: &gpui::DismissEvent, _window, cx| {
1643                        this.update(cx, |sidebar, cx| {
1644                            sidebar.project_header_menu_ix = None;
1645                            cx.notify();
1646                        })
1647                        .ok();
1648                    })
1649                    .detach();
1650
1651                Some(menu)
1652            })
1653            .trigger(
1654                IconButton::new(
1655                    SharedString::from(format!("{id_prefix}-ellipsis-menu-{ix}")),
1656                    IconName::Ellipsis,
1657                )
1658                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1659                .icon_size(IconSize::Small),
1660            )
1661            .anchor(gpui::Corner::TopRight)
1662            .offset(gpui::Point {
1663                x: px(0.),
1664                y: px(1.),
1665            })
1666    }
1667
1668    fn render_sticky_header(
1669        &self,
1670        window: &mut Window,
1671        cx: &mut Context<Self>,
1672    ) -> Option<AnyElement> {
1673        let scroll_top = self.list_state.logical_scroll_top();
1674
1675        let &header_idx = self
1676            .contents
1677            .project_header_indices
1678            .iter()
1679            .rev()
1680            .find(|&&idx| idx <= scroll_top.item_ix)?;
1681
1682        let needs_sticky = header_idx < scroll_top.item_ix
1683            || (header_idx == scroll_top.item_ix && scroll_top.offset_in_item > px(0.));
1684
1685        if !needs_sticky {
1686            return None;
1687        }
1688
1689        let ListEntry::ProjectHeader {
1690            key,
1691            label,
1692            highlight_positions,
1693            has_running_threads,
1694            waiting_thread_count,
1695            is_active,
1696        } = self.contents.entries.get(header_idx)?
1697        else {
1698            return None;
1699        };
1700
1701        let is_focused = self.focus_handle.is_focused(window);
1702        let is_selected = is_focused && self.selection == Some(header_idx);
1703
1704        let header_element = self.render_project_header(
1705            header_idx,
1706            true,
1707            key,
1708            &label,
1709            &highlight_positions,
1710            *has_running_threads,
1711            *waiting_thread_count,
1712            *is_active,
1713            is_selected,
1714            cx,
1715        );
1716
1717        let top_offset = self
1718            .contents
1719            .project_header_indices
1720            .iter()
1721            .find(|&&idx| idx > header_idx)
1722            .and_then(|&next_idx| {
1723                let bounds = self.list_state.bounds_for_item(next_idx)?;
1724                let viewport = self.list_state.viewport_bounds();
1725                let y_in_viewport = bounds.origin.y - viewport.origin.y;
1726                let header_height = bounds.size.height;
1727                (y_in_viewport < header_height).then_some(y_in_viewport - header_height)
1728            })
1729            .unwrap_or(px(0.));
1730
1731        let color = cx.theme().colors();
1732        let background = color
1733            .title_bar_background
1734            .blend(color.panel_background.opacity(0.2));
1735
1736        let element = v_flex()
1737            .absolute()
1738            .top(top_offset)
1739            .left_0()
1740            .w_full()
1741            .bg(background)
1742            .border_b_1()
1743            .border_color(color.border.opacity(0.5))
1744            .child(header_element)
1745            .shadow_xs()
1746            .into_any_element();
1747
1748        Some(element)
1749    }
1750
1751    fn toggle_collapse(
1752        &mut self,
1753        path_list: &PathList,
1754        _window: &mut Window,
1755        cx: &mut Context<Self>,
1756    ) {
1757        if self.collapsed_groups.contains(path_list) {
1758            self.collapsed_groups.remove(path_list);
1759        } else {
1760            self.collapsed_groups.insert(path_list.clone());
1761        }
1762        self.serialize(cx);
1763        self.update_entries(cx);
1764    }
1765
1766    fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
1767        let mut dispatch_context = KeyContext::new_with_defaults();
1768        dispatch_context.add("ThreadsSidebar");
1769        dispatch_context.add("menu");
1770
1771        let is_archived_search_focused = matches!(&self.view, SidebarView::Archive(archive) if archive.read(cx).is_filter_editor_focused(window, cx));
1772
1773        let identifier = if self.filter_editor.focus_handle(cx).is_focused(window)
1774            || is_archived_search_focused
1775        {
1776            "searching"
1777        } else {
1778            "not_searching"
1779        };
1780
1781        dispatch_context.add(identifier);
1782        dispatch_context
1783    }
1784
1785    fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1786        if !self.focus_handle.is_focused(window) {
1787            return;
1788        }
1789
1790        if let SidebarView::Archive(archive) = &self.view {
1791            let has_selection = archive.read(cx).has_selection();
1792            if !has_selection {
1793                archive.update(cx, |view, cx| view.focus_filter_editor(window, cx));
1794            }
1795        } else if self.selection.is_none() {
1796            self.filter_editor.focus_handle(cx).focus(window, cx);
1797        }
1798    }
1799
1800    fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
1801        if self.reset_filter_editor_text(window, cx) {
1802            self.update_entries(cx);
1803        } else {
1804            self.selection = None;
1805            self.filter_editor.focus_handle(cx).focus(window, cx);
1806            cx.notify();
1807        }
1808    }
1809
1810    fn focus_sidebar_filter(
1811        &mut self,
1812        _: &FocusSidebarFilter,
1813        window: &mut Window,
1814        cx: &mut Context<Self>,
1815    ) {
1816        self.selection = None;
1817        if let SidebarView::Archive(archive) = &self.view {
1818            archive.update(cx, |view, cx| {
1819                view.clear_selection();
1820                view.focus_filter_editor(window, cx);
1821            });
1822        } else {
1823            self.filter_editor.focus_handle(cx).focus(window, cx);
1824        }
1825
1826        // When vim mode is active, the editor defaults to normal mode which
1827        // blocks text input. Switch to insert mode so the user can type
1828        // immediately.
1829        if vim_mode_setting::VimModeSetting::get_global(cx).0 {
1830            if let Ok(action) = cx.build_action("vim::SwitchToInsertMode", None) {
1831                window.dispatch_action(action, cx);
1832            }
1833        }
1834
1835        cx.notify();
1836    }
1837
1838    fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1839        self.filter_editor.update(cx, |editor, cx| {
1840            if editor.buffer().read(cx).len(cx).0 > 0 {
1841                editor.set_text("", window, cx);
1842                true
1843            } else {
1844                false
1845            }
1846        })
1847    }
1848
1849    fn has_filter_query(&self, cx: &App) -> bool {
1850        !self.filter_editor.read(cx).text(cx).is_empty()
1851    }
1852
1853    fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
1854        self.select_next(&SelectNext, window, cx);
1855        if self.selection.is_some() {
1856            self.focus_handle.focus(window, cx);
1857        }
1858    }
1859
1860    fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
1861        self.select_previous(&SelectPrevious, window, cx);
1862        if self.selection.is_some() {
1863            self.focus_handle.focus(window, cx);
1864        }
1865    }
1866
1867    fn editor_confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1868        if self.selection.is_none() {
1869            self.select_next(&SelectNext, window, cx);
1870        }
1871        if self.selection.is_some() {
1872            self.focus_handle.focus(window, cx);
1873        }
1874    }
1875
1876    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
1877        let next = match self.selection {
1878            Some(ix) if ix + 1 < self.contents.entries.len() => ix + 1,
1879            Some(_) if !self.contents.entries.is_empty() => 0,
1880            None if !self.contents.entries.is_empty() => 0,
1881            _ => return,
1882        };
1883        self.selection = Some(next);
1884        self.list_state.scroll_to_reveal_item(next);
1885        cx.notify();
1886    }
1887
1888    fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
1889        match self.selection {
1890            Some(0) => {
1891                self.selection = None;
1892                self.filter_editor.focus_handle(cx).focus(window, cx);
1893                cx.notify();
1894            }
1895            Some(ix) => {
1896                self.selection = Some(ix - 1);
1897                self.list_state.scroll_to_reveal_item(ix - 1);
1898                cx.notify();
1899            }
1900            None if !self.contents.entries.is_empty() => {
1901                let last = self.contents.entries.len() - 1;
1902                self.selection = Some(last);
1903                self.list_state.scroll_to_reveal_item(last);
1904                cx.notify();
1905            }
1906            None => {}
1907        }
1908    }
1909
1910    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
1911        if !self.contents.entries.is_empty() {
1912            self.selection = Some(0);
1913            self.list_state.scroll_to_reveal_item(0);
1914            cx.notify();
1915        }
1916    }
1917
1918    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
1919        if let Some(last) = self.contents.entries.len().checked_sub(1) {
1920            self.selection = Some(last);
1921            self.list_state.scroll_to_reveal_item(last);
1922            cx.notify();
1923        }
1924    }
1925
1926    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1927        let Some(ix) = self.selection else { return };
1928        let Some(entry) = self.contents.entries.get(ix) else {
1929            return;
1930        };
1931
1932        match entry {
1933            ListEntry::ProjectHeader { key, .. } => {
1934                let path_list = key.path_list().clone();
1935                self.toggle_collapse(&path_list, window, cx);
1936            }
1937            ListEntry::Thread(thread) => {
1938                let metadata = thread.metadata.clone();
1939                match &thread.workspace {
1940                    ThreadEntryWorkspace::Open(workspace) => {
1941                        let workspace = workspace.clone();
1942                        self.activate_thread(metadata, &workspace, window, cx);
1943                    }
1944                    ThreadEntryWorkspace::Closed(path_list) => {
1945                        self.open_workspace_and_activate_thread(
1946                            metadata,
1947                            path_list.clone(),
1948                            window,
1949                            cx,
1950                        );
1951                    }
1952                }
1953            }
1954            ListEntry::ViewMore {
1955                key,
1956                is_fully_expanded,
1957                ..
1958            } => {
1959                let path_list = key.path_list().clone();
1960                if *is_fully_expanded {
1961                    self.expanded_groups.remove(&path_list);
1962                } else {
1963                    let current = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
1964                    self.expanded_groups.insert(path_list, current + 1);
1965                }
1966                self.serialize(cx);
1967                self.update_entries(cx);
1968            }
1969            ListEntry::DraftThread { .. } => {
1970                // Already active — nothing to do.
1971            }
1972            ListEntry::NewThread { key, .. } => {
1973                let path_list = key.path_list().clone();
1974                if let Some(workspace) = self.workspace_for_group(&path_list, cx) {
1975                    self.create_new_thread(&workspace, window, cx);
1976                } else {
1977                    self.open_workspace_for_group(&path_list, window, cx);
1978                }
1979            }
1980        }
1981    }
1982
1983    fn find_workspace_across_windows(
1984        &self,
1985        cx: &App,
1986        predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
1987    ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
1988        cx.windows()
1989            .into_iter()
1990            .filter_map(|window| window.downcast::<MultiWorkspace>())
1991            .find_map(|window| {
1992                let workspace = window.read(cx).ok().and_then(|multi_workspace| {
1993                    multi_workspace
1994                        .workspaces()
1995                        .find(|workspace| predicate(workspace, cx))
1996                        .cloned()
1997                })?;
1998                Some((window, workspace))
1999            })
2000    }
2001
2002    fn find_workspace_in_current_window(
2003        &self,
2004        cx: &App,
2005        predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
2006    ) -> Option<Entity<Workspace>> {
2007        self.multi_workspace.upgrade().and_then(|multi_workspace| {
2008            multi_workspace
2009                .read(cx)
2010                .workspaces()
2011                .find(|workspace| predicate(workspace, cx))
2012                .cloned()
2013        })
2014    }
2015
2016    fn load_agent_thread_in_workspace(
2017        workspace: &Entity<Workspace>,
2018        metadata: &ThreadMetadata,
2019        focus: bool,
2020        window: &mut Window,
2021        cx: &mut App,
2022    ) {
2023        workspace.update(cx, |workspace, cx| {
2024            workspace.reveal_panel::<AgentPanel>(window, cx);
2025        });
2026
2027        if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2028            agent_panel.update(cx, |panel, cx| {
2029                panel.load_agent_thread(
2030                    Agent::from(metadata.agent_id.clone()),
2031                    metadata.session_id.clone(),
2032                    Some(metadata.folder_paths.clone()),
2033                    Some(metadata.title.clone()),
2034                    focus,
2035                    window,
2036                    cx,
2037                );
2038            });
2039        }
2040    }
2041
2042    fn activate_thread_locally(
2043        &mut self,
2044        metadata: &ThreadMetadata,
2045        workspace: &Entity<Workspace>,
2046        window: &mut Window,
2047        cx: &mut Context<Self>,
2048    ) {
2049        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2050            return;
2051        };
2052
2053        // Set active_entry eagerly so the sidebar highlight updates
2054        // immediately, rather than waiting for a deferred AgentPanel
2055        // event which can race with ActiveWorkspaceChanged clearing it.
2056        self.active_entry = Some(ActiveEntry::Thread {
2057            session_id: metadata.session_id.clone(),
2058            workspace: workspace.clone(),
2059        });
2060        self.record_thread_access(&metadata.session_id);
2061
2062        multi_workspace.update(cx, |multi_workspace, cx| {
2063            multi_workspace.activate(workspace.clone(), window, cx);
2064        });
2065
2066        Self::load_agent_thread_in_workspace(workspace, metadata, true, window, cx);
2067
2068        self.update_entries(cx);
2069    }
2070
2071    fn activate_thread_in_other_window(
2072        &self,
2073        metadata: ThreadMetadata,
2074        workspace: Entity<Workspace>,
2075        target_window: WindowHandle<MultiWorkspace>,
2076        cx: &mut Context<Self>,
2077    ) {
2078        let target_session_id = metadata.session_id.clone();
2079        let workspace_for_entry = workspace.clone();
2080
2081        let activated = target_window
2082            .update(cx, |multi_workspace, window, cx| {
2083                window.activate_window();
2084                multi_workspace.activate(workspace.clone(), window, cx);
2085                Self::load_agent_thread_in_workspace(&workspace, &metadata, true, window, cx);
2086            })
2087            .log_err()
2088            .is_some();
2089
2090        if activated {
2091            if let Some(target_sidebar) = target_window
2092                .read(cx)
2093                .ok()
2094                .and_then(|multi_workspace| {
2095                    multi_workspace.sidebar().map(|sidebar| sidebar.to_any())
2096                })
2097                .and_then(|sidebar| sidebar.downcast::<Self>().ok())
2098            {
2099                target_sidebar.update(cx, |sidebar, cx| {
2100                    sidebar.active_entry = Some(ActiveEntry::Thread {
2101                        session_id: target_session_id.clone(),
2102                        workspace: workspace_for_entry.clone(),
2103                    });
2104                    sidebar.record_thread_access(&target_session_id);
2105                    sidebar.update_entries(cx);
2106                });
2107            }
2108        }
2109    }
2110
2111    fn activate_thread(
2112        &mut self,
2113        metadata: ThreadMetadata,
2114        workspace: &Entity<Workspace>,
2115        window: &mut Window,
2116        cx: &mut Context<Self>,
2117    ) {
2118        if self
2119            .find_workspace_in_current_window(cx, |candidate, _| candidate == workspace)
2120            .is_some()
2121        {
2122            self.activate_thread_locally(&metadata, &workspace, window, cx);
2123            return;
2124        }
2125
2126        let Some((target_window, workspace)) =
2127            self.find_workspace_across_windows(cx, |candidate, _| candidate == workspace)
2128        else {
2129            return;
2130        };
2131
2132        self.activate_thread_in_other_window(metadata, workspace, target_window, cx);
2133    }
2134
2135    fn open_workspace_and_activate_thread(
2136        &mut self,
2137        metadata: ThreadMetadata,
2138        path_list: PathList,
2139        window: &mut Window,
2140        cx: &mut Context<Self>,
2141    ) {
2142        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2143            return;
2144        };
2145
2146        let open_task = multi_workspace.update(cx, |this, cx| {
2147            this.find_or_create_local_workspace(path_list, window, cx)
2148        });
2149
2150        cx.spawn_in(window, async move |this, cx| {
2151            let workspace = open_task.await?;
2152            this.update_in(cx, |this, window, cx| {
2153                this.activate_thread(metadata, &workspace, window, cx);
2154            })?;
2155            anyhow::Ok(())
2156        })
2157        .detach_and_log_err(cx);
2158    }
2159
2160    fn find_current_workspace_for_path_list(
2161        &self,
2162        path_list: &PathList,
2163        cx: &App,
2164    ) -> Option<Entity<Workspace>> {
2165        self.find_workspace_in_current_window(cx, |workspace, cx| {
2166            workspace_path_list(workspace, cx).paths() == path_list.paths()
2167        })
2168    }
2169
2170    fn find_open_workspace_for_path_list(
2171        &self,
2172        path_list: &PathList,
2173        cx: &App,
2174    ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
2175        self.find_workspace_across_windows(cx, |workspace, cx| {
2176            workspace_path_list(workspace, cx).paths() == path_list.paths()
2177        })
2178    }
2179
2180    fn activate_archived_thread(
2181        &mut self,
2182        metadata: ThreadMetadata,
2183        window: &mut Window,
2184        cx: &mut Context<Self>,
2185    ) {
2186        ThreadMetadataStore::global(cx)
2187            .update(cx, |store, cx| store.unarchive(&metadata.session_id, cx));
2188
2189        if !metadata.folder_paths.paths().is_empty() {
2190            let path_list = metadata.folder_paths.clone();
2191            if let Some(workspace) = self.find_current_workspace_for_path_list(&path_list, cx) {
2192                self.activate_thread_locally(&metadata, &workspace, window, cx);
2193            } else if let Some((target_window, workspace)) =
2194                self.find_open_workspace_for_path_list(&path_list, cx)
2195            {
2196                self.activate_thread_in_other_window(metadata, workspace, target_window, cx);
2197            } else {
2198                self.open_workspace_and_activate_thread(metadata, path_list, window, cx);
2199            }
2200            return;
2201        }
2202
2203        let active_workspace = self
2204            .multi_workspace
2205            .upgrade()
2206            .map(|w| w.read(cx).workspace().clone());
2207
2208        if let Some(workspace) = active_workspace {
2209            self.activate_thread_locally(&metadata, &workspace, window, cx);
2210        }
2211    }
2212
2213    fn expand_selected_entry(
2214        &mut self,
2215        _: &SelectChild,
2216        _window: &mut Window,
2217        cx: &mut Context<Self>,
2218    ) {
2219        let Some(ix) = self.selection else { return };
2220
2221        match self.contents.entries.get(ix) {
2222            Some(ListEntry::ProjectHeader { key, .. }) => {
2223                if self.collapsed_groups.contains(key.path_list()) {
2224                    let path_list = key.path_list().clone();
2225                    self.collapsed_groups.remove(&path_list);
2226                    self.update_entries(cx);
2227                } else if ix + 1 < self.contents.entries.len() {
2228                    self.selection = Some(ix + 1);
2229                    self.list_state.scroll_to_reveal_item(ix + 1);
2230                    cx.notify();
2231                }
2232            }
2233            _ => {}
2234        }
2235    }
2236
2237    fn collapse_selected_entry(
2238        &mut self,
2239        _: &SelectParent,
2240        _window: &mut Window,
2241        cx: &mut Context<Self>,
2242    ) {
2243        let Some(ix) = self.selection else { return };
2244
2245        match self.contents.entries.get(ix) {
2246            Some(ListEntry::ProjectHeader { key, .. }) => {
2247                if !self.collapsed_groups.contains(key.path_list()) {
2248                    self.collapsed_groups.insert(key.path_list().clone());
2249                    self.update_entries(cx);
2250                }
2251            }
2252            Some(
2253                ListEntry::Thread(_)
2254                | ListEntry::ViewMore { .. }
2255                | ListEntry::NewThread { .. }
2256                | ListEntry::DraftThread { .. },
2257            ) => {
2258                for i in (0..ix).rev() {
2259                    if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(i)
2260                    {
2261                        self.selection = Some(i);
2262                        self.collapsed_groups.insert(key.path_list().clone());
2263                        self.update_entries(cx);
2264                        break;
2265                    }
2266                }
2267            }
2268            None => {}
2269        }
2270    }
2271
2272    fn toggle_selected_fold(
2273        &mut self,
2274        _: &editor::actions::ToggleFold,
2275        _window: &mut Window,
2276        cx: &mut Context<Self>,
2277    ) {
2278        let Some(ix) = self.selection else { return };
2279
2280        // Find the group header for the current selection.
2281        let header_ix = match self.contents.entries.get(ix) {
2282            Some(ListEntry::ProjectHeader { .. }) => Some(ix),
2283            Some(
2284                ListEntry::Thread(_)
2285                | ListEntry::ViewMore { .. }
2286                | ListEntry::NewThread { .. }
2287                | ListEntry::DraftThread { .. },
2288            ) => (0..ix).rev().find(|&i| {
2289                matches!(
2290                    self.contents.entries.get(i),
2291                    Some(ListEntry::ProjectHeader { .. })
2292                )
2293            }),
2294            None => None,
2295        };
2296
2297        if let Some(header_ix) = header_ix {
2298            if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(header_ix)
2299            {
2300                let path_list = key.path_list();
2301                if self.collapsed_groups.contains(path_list) {
2302                    self.collapsed_groups.remove(path_list);
2303                } else {
2304                    self.selection = Some(header_ix);
2305                    self.collapsed_groups.insert(path_list.clone());
2306                }
2307                self.update_entries(cx);
2308            }
2309        }
2310    }
2311
2312    fn fold_all(
2313        &mut self,
2314        _: &editor::actions::FoldAll,
2315        _window: &mut Window,
2316        cx: &mut Context<Self>,
2317    ) {
2318        for entry in &self.contents.entries {
2319            if let ListEntry::ProjectHeader { key, .. } = entry {
2320                self.collapsed_groups.insert(key.path_list().clone());
2321            }
2322        }
2323        self.update_entries(cx);
2324    }
2325
2326    fn unfold_all(
2327        &mut self,
2328        _: &editor::actions::UnfoldAll,
2329        _window: &mut Window,
2330        cx: &mut Context<Self>,
2331    ) {
2332        self.collapsed_groups.clear();
2333        self.update_entries(cx);
2334    }
2335
2336    fn stop_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
2337        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2338            return;
2339        };
2340
2341        let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect();
2342        for workspace in workspaces {
2343            if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2344                let cancelled =
2345                    agent_panel.update(cx, |panel, cx| panel.cancel_thread(session_id, cx));
2346                if cancelled {
2347                    return;
2348                }
2349            }
2350        }
2351    }
2352
2353    fn archive_thread(
2354        &mut self,
2355        session_id: &acp::SessionId,
2356        window: &mut Window,
2357        cx: &mut Context<Self>,
2358    ) {
2359        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.archive(session_id, cx));
2360
2361        // If we're archiving the currently focused thread, move focus to the
2362        // nearest thread within the same project group. We never cross group
2363        // boundaries — if the group has no other threads, clear focus and open
2364        // a blank new thread in the panel instead.
2365        if self
2366            .active_entry
2367            .as_ref()
2368            .is_some_and(|e| e.is_active_thread(session_id))
2369        {
2370            let current_pos = self.contents.entries.iter().position(|entry| {
2371                matches!(entry, ListEntry::Thread(t) if &t.metadata.session_id == session_id)
2372            });
2373
2374            // Find the workspace that owns this thread's project group by
2375            // walking backwards to the nearest ProjectHeader and looking up
2376            // an open workspace for that group's path_list.
2377            let group_workspace = current_pos.and_then(|pos| {
2378                let path_list =
2379                    self.contents.entries[..pos]
2380                        .iter()
2381                        .rev()
2382                        .find_map(|e| match e {
2383                            ListEntry::ProjectHeader { key, .. } => Some(key.path_list()),
2384                            _ => None,
2385                        })?;
2386                self.workspace_for_group(path_list, cx)
2387            });
2388
2389            let next_thread = current_pos.and_then(|pos| {
2390                let group_start = self.contents.entries[..pos]
2391                    .iter()
2392                    .rposition(|e| matches!(e, ListEntry::ProjectHeader { .. }))
2393                    .map_or(0, |i| i + 1);
2394                let group_end = self.contents.entries[pos + 1..]
2395                    .iter()
2396                    .position(|e| matches!(e, ListEntry::ProjectHeader { .. }))
2397                    .map_or(self.contents.entries.len(), |i| pos + 1 + i);
2398
2399                let above = self.contents.entries[group_start..pos]
2400                    .iter()
2401                    .rev()
2402                    .find_map(|entry| {
2403                        if let ListEntry::Thread(t) = entry {
2404                            Some(t)
2405                        } else {
2406                            None
2407                        }
2408                    });
2409
2410                above.or_else(|| {
2411                    self.contents.entries[pos + 1..group_end]
2412                        .iter()
2413                        .find_map(|entry| {
2414                            if let ListEntry::Thread(t) = entry {
2415                                Some(t)
2416                            } else {
2417                                None
2418                            }
2419                        })
2420                })
2421            });
2422
2423            if let Some(next) = next_thread {
2424                let next_metadata = next.metadata.clone();
2425                // Use the thread's own workspace when it has one open (e.g. an absorbed
2426                // linked worktree thread that appears under the main workspace's header
2427                // but belongs to its own workspace). Loading into the wrong panel binds
2428                // the thread to the wrong project, which corrupts its stored folder_paths
2429                // when metadata is saved via ThreadMetadata::from_thread.
2430                let target_workspace = match &next.workspace {
2431                    ThreadEntryWorkspace::Open(ws) => Some(ws.clone()),
2432                    ThreadEntryWorkspace::Closed(_) => group_workspace,
2433                };
2434                if let Some(ref ws) = target_workspace {
2435                    self.active_entry = Some(ActiveEntry::Thread {
2436                        session_id: next_metadata.session_id.clone(),
2437                        workspace: ws.clone(),
2438                    });
2439                }
2440                self.record_thread_access(&next_metadata.session_id);
2441
2442                if let Some(workspace) = target_workspace {
2443                    if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2444                        agent_panel.update(cx, |panel, cx| {
2445                            panel.load_agent_thread(
2446                                Agent::from(next_metadata.agent_id.clone()),
2447                                next_metadata.session_id.clone(),
2448                                Some(next_metadata.folder_paths.clone()),
2449                                Some(next_metadata.title.clone()),
2450                                true,
2451                                window,
2452                                cx,
2453                            );
2454                        });
2455                    }
2456                }
2457            } else {
2458                if let Some(workspace) = &group_workspace {
2459                    self.active_entry = Some(ActiveEntry::Draft(workspace.clone()));
2460                    if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2461                        agent_panel.update(cx, |panel, cx| {
2462                            panel.new_thread(&NewThread, window, cx);
2463                        });
2464                    }
2465                }
2466            }
2467        }
2468    }
2469
2470    fn remove_selected_thread(
2471        &mut self,
2472        _: &RemoveSelectedThread,
2473        window: &mut Window,
2474        cx: &mut Context<Self>,
2475    ) {
2476        let Some(ix) = self.selection else {
2477            return;
2478        };
2479        let Some(ListEntry::Thread(thread)) = self.contents.entries.get(ix) else {
2480            return;
2481        };
2482        match thread.status {
2483            AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation => return,
2484            AgentThreadStatus::Completed | AgentThreadStatus::Error => {}
2485        }
2486
2487        let session_id = thread.metadata.session_id.clone();
2488        self.archive_thread(&session_id, window, cx)
2489    }
2490
2491    fn record_thread_access(&mut self, session_id: &acp::SessionId) {
2492        self.thread_last_accessed
2493            .insert(session_id.clone(), Utc::now());
2494    }
2495
2496    fn record_thread_message_sent(&mut self, session_id: &acp::SessionId) {
2497        self.thread_last_message_sent_or_queued
2498            .insert(session_id.clone(), Utc::now());
2499    }
2500
2501    fn mru_threads_for_switcher(&self, cx: &App) -> Vec<ThreadSwitcherEntry> {
2502        let mut current_header_label: Option<SharedString> = None;
2503        let mut current_header_path_list: Option<PathList> = None;
2504        let mut entries: Vec<ThreadSwitcherEntry> = self
2505            .contents
2506            .entries
2507            .iter()
2508            .filter_map(|entry| match entry {
2509                ListEntry::ProjectHeader { label, key, .. } => {
2510                    current_header_label = Some(label.clone());
2511                    current_header_path_list = Some(key.path_list().clone());
2512                    None
2513                }
2514                ListEntry::Thread(thread) => {
2515                    let workspace = match &thread.workspace {
2516                        ThreadEntryWorkspace::Open(workspace) => Some(workspace.clone()),
2517                        ThreadEntryWorkspace::Closed(_) => current_header_path_list
2518                            .as_ref()
2519                            .and_then(|pl| self.workspace_for_group(pl, cx)),
2520                    }?;
2521                    let notified = self
2522                        .contents
2523                        .is_thread_notified(&thread.metadata.session_id);
2524                    let timestamp: SharedString = format_history_entry_timestamp(
2525                        self.thread_last_message_sent_or_queued
2526                            .get(&thread.metadata.session_id)
2527                            .copied()
2528                            .or(thread.metadata.created_at)
2529                            .unwrap_or(thread.metadata.updated_at),
2530                    )
2531                    .into();
2532                    Some(ThreadSwitcherEntry {
2533                        session_id: thread.metadata.session_id.clone(),
2534                        title: thread.metadata.title.clone(),
2535                        icon: thread.icon,
2536                        icon_from_external_svg: thread.icon_from_external_svg.clone(),
2537                        status: thread.status,
2538                        metadata: thread.metadata.clone(),
2539                        workspace,
2540                        project_name: current_header_label.clone(),
2541                        worktrees: thread
2542                            .worktrees
2543                            .iter()
2544                            .map(|wt| ThreadItemWorktreeInfo {
2545                                name: wt.name.clone(),
2546                                full_path: wt.full_path.clone(),
2547                                highlight_positions: Vec::new(),
2548                            })
2549                            .collect(),
2550                        diff_stats: thread.diff_stats,
2551                        is_title_generating: thread.is_title_generating,
2552                        notified,
2553                        timestamp,
2554                    })
2555                }
2556                _ => None,
2557            })
2558            .collect();
2559
2560        entries.sort_by(|a, b| {
2561            let a_accessed = self.thread_last_accessed.get(&a.session_id);
2562            let b_accessed = self.thread_last_accessed.get(&b.session_id);
2563
2564            match (a_accessed, b_accessed) {
2565                (Some(a_time), Some(b_time)) => b_time.cmp(a_time),
2566                (Some(_), None) => std::cmp::Ordering::Less,
2567                (None, Some(_)) => std::cmp::Ordering::Greater,
2568                (None, None) => {
2569                    let a_sent = self.thread_last_message_sent_or_queued.get(&a.session_id);
2570                    let b_sent = self.thread_last_message_sent_or_queued.get(&b.session_id);
2571
2572                    match (a_sent, b_sent) {
2573                        (Some(a_time), Some(b_time)) => b_time.cmp(a_time),
2574                        (Some(_), None) => std::cmp::Ordering::Less,
2575                        (None, Some(_)) => std::cmp::Ordering::Greater,
2576                        (None, None) => {
2577                            let a_time = a.metadata.created_at.or(Some(a.metadata.updated_at));
2578                            let b_time = b.metadata.created_at.or(Some(b.metadata.updated_at));
2579                            b_time.cmp(&a_time)
2580                        }
2581                    }
2582                }
2583            }
2584        });
2585
2586        entries
2587    }
2588
2589    fn dismiss_thread_switcher(&mut self, cx: &mut Context<Self>) {
2590        self.thread_switcher = None;
2591        self._thread_switcher_subscriptions.clear();
2592        if let Some(mw) = self.multi_workspace.upgrade() {
2593            mw.update(cx, |mw, cx| {
2594                mw.set_sidebar_overlay(None, cx);
2595            });
2596        }
2597    }
2598
2599    fn on_toggle_thread_switcher(
2600        &mut self,
2601        action: &ToggleThreadSwitcher,
2602        window: &mut Window,
2603        cx: &mut Context<Self>,
2604    ) {
2605        self.toggle_thread_switcher_impl(action.select_last, window, cx);
2606    }
2607
2608    fn toggle_thread_switcher_impl(
2609        &mut self,
2610        select_last: bool,
2611        window: &mut Window,
2612        cx: &mut Context<Self>,
2613    ) {
2614        if let Some(thread_switcher) = &self.thread_switcher {
2615            thread_switcher.update(cx, |switcher, cx| {
2616                if select_last {
2617                    switcher.select_last(cx);
2618                } else {
2619                    switcher.cycle_selection(cx);
2620                }
2621            });
2622            return;
2623        }
2624
2625        let entries = self.mru_threads_for_switcher(cx);
2626        if entries.len() < 2 {
2627            return;
2628        }
2629
2630        let weak_multi_workspace = self.multi_workspace.clone();
2631
2632        let original_metadata = match &self.active_entry {
2633            Some(ActiveEntry::Thread { session_id, .. }) => entries
2634                .iter()
2635                .find(|e| &e.session_id == session_id)
2636                .map(|e| e.metadata.clone()),
2637            _ => None,
2638        };
2639        let original_workspace = self
2640            .multi_workspace
2641            .upgrade()
2642            .map(|mw| mw.read(cx).workspace().clone());
2643
2644        let thread_switcher = cx.new(|cx| ThreadSwitcher::new(entries, select_last, window, cx));
2645
2646        let mut subscriptions = Vec::new();
2647
2648        subscriptions.push(cx.subscribe_in(&thread_switcher, window, {
2649            let thread_switcher = thread_switcher.clone();
2650            move |this, _emitter, event: &ThreadSwitcherEvent, window, cx| match event {
2651                ThreadSwitcherEvent::Preview {
2652                    metadata,
2653                    workspace,
2654                } => {
2655                    if let Some(mw) = weak_multi_workspace.upgrade() {
2656                        mw.update(cx, |mw, cx| {
2657                            mw.activate(workspace.clone(), window, cx);
2658                        });
2659                    }
2660                    this.active_entry = Some(ActiveEntry::Thread {
2661                        session_id: metadata.session_id.clone(),
2662                        workspace: workspace.clone(),
2663                    });
2664                    this.update_entries(cx);
2665                    Self::load_agent_thread_in_workspace(workspace, metadata, false, window, cx);
2666                    let focus = thread_switcher.focus_handle(cx);
2667                    window.focus(&focus, cx);
2668                }
2669                ThreadSwitcherEvent::Confirmed {
2670                    metadata,
2671                    workspace,
2672                } => {
2673                    if let Some(mw) = weak_multi_workspace.upgrade() {
2674                        mw.update(cx, |mw, cx| {
2675                            mw.activate(workspace.clone(), window, cx);
2676                        });
2677                    }
2678                    this.record_thread_access(&metadata.session_id);
2679                    this.active_entry = Some(ActiveEntry::Thread {
2680                        session_id: metadata.session_id.clone(),
2681                        workspace: workspace.clone(),
2682                    });
2683                    this.update_entries(cx);
2684                    Self::load_agent_thread_in_workspace(workspace, metadata, false, window, cx);
2685                    this.dismiss_thread_switcher(cx);
2686                    workspace.update(cx, |workspace, cx| {
2687                        workspace.focus_panel::<AgentPanel>(window, cx);
2688                    });
2689                }
2690                ThreadSwitcherEvent::Dismissed => {
2691                    if let Some(mw) = weak_multi_workspace.upgrade() {
2692                        if let Some(original_ws) = &original_workspace {
2693                            mw.update(cx, |mw, cx| {
2694                                mw.activate(original_ws.clone(), window, cx);
2695                            });
2696                        }
2697                    }
2698                    if let Some(metadata) = &original_metadata {
2699                        if let Some(original_ws) = &original_workspace {
2700                            this.active_entry = Some(ActiveEntry::Thread {
2701                                session_id: metadata.session_id.clone(),
2702                                workspace: original_ws.clone(),
2703                            });
2704                        }
2705                        this.update_entries(cx);
2706                        if let Some(original_ws) = &original_workspace {
2707                            Self::load_agent_thread_in_workspace(
2708                                original_ws,
2709                                metadata,
2710                                false,
2711                                window,
2712                                cx,
2713                            );
2714                        }
2715                    }
2716                    this.dismiss_thread_switcher(cx);
2717                }
2718            }
2719        }));
2720
2721        subscriptions.push(cx.subscribe_in(
2722            &thread_switcher,
2723            window,
2724            |this, _emitter, _event: &gpui::DismissEvent, _window, cx| {
2725                this.dismiss_thread_switcher(cx);
2726            },
2727        ));
2728
2729        let focus = thread_switcher.focus_handle(cx);
2730        let overlay_view = gpui::AnyView::from(thread_switcher.clone());
2731
2732        // Replay the initial preview that was emitted during construction
2733        // before subscriptions were wired up.
2734        let initial_preview = thread_switcher
2735            .read(cx)
2736            .selected_entry()
2737            .map(|entry| (entry.metadata.clone(), entry.workspace.clone()));
2738
2739        self.thread_switcher = Some(thread_switcher);
2740        self._thread_switcher_subscriptions = subscriptions;
2741        if let Some(mw) = self.multi_workspace.upgrade() {
2742            mw.update(cx, |mw, cx| {
2743                mw.set_sidebar_overlay(Some(overlay_view), cx);
2744            });
2745        }
2746
2747        if let Some((metadata, workspace)) = initial_preview {
2748            if let Some(mw) = self.multi_workspace.upgrade() {
2749                mw.update(cx, |mw, cx| {
2750                    mw.activate(workspace.clone(), window, cx);
2751                });
2752            }
2753            self.active_entry = Some(ActiveEntry::Thread {
2754                session_id: metadata.session_id.clone(),
2755                workspace: workspace.clone(),
2756            });
2757            self.update_entries(cx);
2758            Self::load_agent_thread_in_workspace(&workspace, &metadata, false, window, cx);
2759        }
2760
2761        window.focus(&focus, cx);
2762    }
2763
2764    fn render_thread(
2765        &self,
2766        ix: usize,
2767        thread: &ThreadEntry,
2768        is_active: bool,
2769        is_focused: bool,
2770        cx: &mut Context<Self>,
2771    ) -> AnyElement {
2772        let has_notification = self
2773            .contents
2774            .is_thread_notified(&thread.metadata.session_id);
2775
2776        let title: SharedString = thread.metadata.title.clone();
2777        let metadata = thread.metadata.clone();
2778        let thread_workspace = thread.workspace.clone();
2779
2780        let is_hovered = self.hovered_thread_index == Some(ix);
2781        let is_selected = is_active;
2782        let is_running = matches!(
2783            thread.status,
2784            AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
2785        );
2786
2787        let session_id_for_delete = thread.metadata.session_id.clone();
2788        let focus_handle = self.focus_handle.clone();
2789
2790        let id = SharedString::from(format!("thread-entry-{}", ix));
2791
2792        let color = cx.theme().colors();
2793        let sidebar_bg = color
2794            .title_bar_background
2795            .blend(color.panel_background.opacity(0.25));
2796
2797        let timestamp = format_history_entry_timestamp(
2798            self.thread_last_message_sent_or_queued
2799                .get(&thread.metadata.session_id)
2800                .copied()
2801                .or(thread.metadata.created_at)
2802                .unwrap_or(thread.metadata.updated_at),
2803        );
2804
2805        ThreadItem::new(id, title)
2806            .base_bg(sidebar_bg)
2807            .icon(thread.icon)
2808            .status(thread.status)
2809            .when_some(thread.icon_from_external_svg.clone(), |this, svg| {
2810                this.custom_icon_from_external_svg(svg)
2811            })
2812            .worktrees(
2813                thread
2814                    .worktrees
2815                    .iter()
2816                    .map(|wt| ThreadItemWorktreeInfo {
2817                        name: wt.name.clone(),
2818                        full_path: wt.full_path.clone(),
2819                        highlight_positions: wt.highlight_positions.clone(),
2820                    })
2821                    .collect(),
2822            )
2823            .timestamp(timestamp)
2824            .highlight_positions(thread.highlight_positions.to_vec())
2825            .title_generating(thread.is_title_generating)
2826            .notified(has_notification)
2827            .when(thread.diff_stats.lines_added > 0, |this| {
2828                this.added(thread.diff_stats.lines_added as usize)
2829            })
2830            .when(thread.diff_stats.lines_removed > 0, |this| {
2831                this.removed(thread.diff_stats.lines_removed as usize)
2832            })
2833            .selected(is_selected)
2834            .focused(is_focused)
2835            .hovered(is_hovered)
2836            .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| {
2837                if *is_hovered {
2838                    this.hovered_thread_index = Some(ix);
2839                } else if this.hovered_thread_index == Some(ix) {
2840                    this.hovered_thread_index = None;
2841                }
2842                cx.notify();
2843            }))
2844            .when(is_hovered && is_running, |this| {
2845                this.action_slot(
2846                    IconButton::new("stop-thread", IconName::Stop)
2847                        .icon_size(IconSize::Small)
2848                        .icon_color(Color::Error)
2849                        .style(ButtonStyle::Tinted(TintColor::Error))
2850                        .tooltip(Tooltip::text("Stop Generation"))
2851                        .on_click({
2852                            let session_id = session_id_for_delete.clone();
2853                            cx.listener(move |this, _, _window, cx| {
2854                                this.stop_thread(&session_id, cx);
2855                            })
2856                        }),
2857                )
2858            })
2859            .when(is_hovered && !is_running, |this| {
2860                this.action_slot(
2861                    IconButton::new("archive-thread", IconName::Archive)
2862                        .icon_size(IconSize::Small)
2863                        .icon_color(Color::Muted)
2864                        .tooltip({
2865                            let focus_handle = focus_handle.clone();
2866                            move |_window, cx| {
2867                                Tooltip::for_action_in(
2868                                    "Archive Thread",
2869                                    &RemoveSelectedThread,
2870                                    &focus_handle,
2871                                    cx,
2872                                )
2873                            }
2874                        })
2875                        .on_click({
2876                            let session_id = session_id_for_delete.clone();
2877                            cx.listener(move |this, _, window, cx| {
2878                                this.archive_thread(&session_id, window, cx);
2879                            })
2880                        }),
2881                )
2882            })
2883            .on_click({
2884                cx.listener(move |this, _, window, cx| {
2885                    this.selection = None;
2886                    match &thread_workspace {
2887                        ThreadEntryWorkspace::Open(workspace) => {
2888                            this.activate_thread(metadata.clone(), workspace, window, cx);
2889                        }
2890                        ThreadEntryWorkspace::Closed(path_list) => {
2891                            this.open_workspace_and_activate_thread(
2892                                metadata.clone(),
2893                                path_list.clone(),
2894                                window,
2895                                cx,
2896                            );
2897                        }
2898                    }
2899                })
2900            })
2901            .into_any_element()
2902    }
2903
2904    fn render_filter_input(&self, cx: &mut Context<Self>) -> impl IntoElement {
2905        div()
2906            .min_w_0()
2907            .flex_1()
2908            .capture_action(
2909                cx.listener(|this, _: &editor::actions::Newline, window, cx| {
2910                    this.editor_confirm(window, cx);
2911                }),
2912            )
2913            .child(self.filter_editor.clone())
2914    }
2915
2916    fn render_recent_projects_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
2917        let multi_workspace = self.multi_workspace.upgrade();
2918
2919        let workspace = multi_workspace
2920            .as_ref()
2921            .map(|mw| mw.read(cx).workspace().downgrade());
2922
2923        let focus_handle = workspace
2924            .as_ref()
2925            .and_then(|ws| ws.upgrade())
2926            .map(|w| w.read(cx).focus_handle(cx))
2927            .unwrap_or_else(|| cx.focus_handle());
2928
2929        let sibling_workspace_ids: HashSet<WorkspaceId> = multi_workspace
2930            .as_ref()
2931            .map(|mw| {
2932                mw.read(cx)
2933                    .workspaces()
2934                    .filter_map(|ws| ws.read(cx).database_id())
2935                    .collect()
2936            })
2937            .unwrap_or_default();
2938
2939        let popover_handle = self.recent_projects_popover_handle.clone();
2940
2941        PopoverMenu::new("sidebar-recent-projects-menu")
2942            .with_handle(popover_handle)
2943            .menu(move |window, cx| {
2944                workspace.as_ref().map(|ws| {
2945                    SidebarRecentProjects::popover(
2946                        ws.clone(),
2947                        sibling_workspace_ids.clone(),
2948                        focus_handle.clone(),
2949                        window,
2950                        cx,
2951                    )
2952                })
2953            })
2954            .trigger_with_tooltip(
2955                IconButton::new("open-project", IconName::OpenFolder)
2956                    .icon_size(IconSize::Small)
2957                    .selected_style(ButtonStyle::Tinted(TintColor::Accent)),
2958                |_window, cx| {
2959                    Tooltip::for_action(
2960                        "Add Project",
2961                        &OpenRecent {
2962                            create_new_window: false,
2963                        },
2964                        cx,
2965                    )
2966                },
2967            )
2968            .offset(gpui::Point {
2969                x: px(-2.0),
2970                y: px(-2.0),
2971            })
2972            .anchor(gpui::Corner::BottomRight)
2973    }
2974
2975    fn render_view_more(
2976        &self,
2977        ix: usize,
2978        path_list: &PathList,
2979        is_fully_expanded: bool,
2980        is_selected: bool,
2981        cx: &mut Context<Self>,
2982    ) -> AnyElement {
2983        let path_list = path_list.clone();
2984        let id = SharedString::from(format!("view-more-{}", ix));
2985
2986        let label: SharedString = if is_fully_expanded {
2987            "Collapse".into()
2988        } else {
2989            "View More".into()
2990        };
2991
2992        ThreadItem::new(id, label)
2993            .focused(is_selected)
2994            .icon_visible(false)
2995            .title_label_color(Color::Muted)
2996            .on_click(cx.listener(move |this, _, _window, cx| {
2997                this.selection = None;
2998                if is_fully_expanded {
2999                    this.expanded_groups.remove(&path_list);
3000                } else {
3001                    let current = this.expanded_groups.get(&path_list).copied().unwrap_or(0);
3002                    this.expanded_groups.insert(path_list.clone(), current + 1);
3003                }
3004                this.serialize(cx);
3005                this.update_entries(cx);
3006            }))
3007            .into_any_element()
3008    }
3009
3010    fn new_thread_in_group(
3011        &mut self,
3012        _: &NewThreadInGroup,
3013        window: &mut Window,
3014        cx: &mut Context<Self>,
3015    ) {
3016        // If there is a keyboard selection, walk backwards through
3017        // `project_header_indices` to find the header that owns the selected
3018        // row. Otherwise fall back to the active workspace.
3019        let workspace = if let Some(selected_ix) = self.selection {
3020            self.contents
3021                .project_header_indices
3022                .iter()
3023                .rev()
3024                .find(|&&header_ix| header_ix <= selected_ix)
3025                .and_then(|&header_ix| match &self.contents.entries[header_ix] {
3026                    ListEntry::ProjectHeader { key, .. } => {
3027                        self.workspace_for_group(key.path_list(), cx)
3028                    }
3029                    _ => None,
3030                })
3031        } else {
3032            // Use the currently active workspace.
3033            self.multi_workspace
3034                .upgrade()
3035                .map(|mw| mw.read(cx).workspace().clone())
3036        };
3037
3038        let Some(workspace) = workspace else {
3039            return;
3040        };
3041
3042        self.create_new_thread(&workspace, window, cx);
3043    }
3044
3045    fn create_new_thread(
3046        &mut self,
3047        workspace: &Entity<Workspace>,
3048        window: &mut Window,
3049        cx: &mut Context<Self>,
3050    ) {
3051        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
3052            return;
3053        };
3054
3055        self.active_entry = Some(ActiveEntry::Draft(workspace.clone()));
3056
3057        multi_workspace.update(cx, |multi_workspace, cx| {
3058            multi_workspace.activate(workspace.clone(), window, cx);
3059        });
3060
3061        workspace.update(cx, |workspace, cx| {
3062            if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx) {
3063                agent_panel.update(cx, |panel, cx| {
3064                    panel.new_thread(&NewThread, window, cx);
3065                });
3066            }
3067            workspace.focus_panel::<AgentPanel>(window, cx);
3068        });
3069    }
3070
3071    fn render_draft_thread(
3072        &self,
3073        ix: usize,
3074        is_active: bool,
3075        worktrees: &[WorktreeInfo],
3076        is_selected: bool,
3077        cx: &mut Context<Self>,
3078    ) -> AnyElement {
3079        let label: SharedString = if is_active {
3080            self.active_draft_text(cx)
3081                .unwrap_or_else(|| "Untitled Thread".into())
3082        } else {
3083            "Untitled Thread".into()
3084        };
3085
3086        let id = SharedString::from(format!("draft-thread-btn-{}", ix));
3087
3088        let thread_item = ThreadItem::new(id, label)
3089            .icon(IconName::Plus)
3090            .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.8)))
3091            .worktrees(
3092                worktrees
3093                    .iter()
3094                    .map(|wt| ThreadItemWorktreeInfo {
3095                        name: wt.name.clone(),
3096                        full_path: wt.full_path.clone(),
3097                        highlight_positions: wt.highlight_positions.clone(),
3098                    })
3099                    .collect(),
3100            )
3101            .selected(true)
3102            .focused(is_selected);
3103
3104        div()
3105            .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
3106                cx.stop_propagation();
3107            })
3108            .child(thread_item)
3109            .into_any_element()
3110    }
3111
3112    fn render_new_thread(
3113        &self,
3114        ix: usize,
3115        key: &ProjectGroupKey,
3116        worktrees: &[WorktreeInfo],
3117        is_selected: bool,
3118        cx: &mut Context<Self>,
3119    ) -> AnyElement {
3120        let label: SharedString = DEFAULT_THREAD_TITLE.into();
3121        let path_list = key.path_list().clone();
3122
3123        let id = SharedString::from(format!("new-thread-btn-{}", ix));
3124
3125        let thread_item = ThreadItem::new(id, label)
3126            .icon(IconName::Plus)
3127            .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.8)))
3128            .worktrees(
3129                worktrees
3130                    .iter()
3131                    .map(|wt| ThreadItemWorktreeInfo {
3132                        name: wt.name.clone(),
3133                        full_path: wt.full_path.clone(),
3134                        highlight_positions: wt.highlight_positions.clone(),
3135                    })
3136                    .collect(),
3137            )
3138            .selected(false)
3139            .focused(is_selected)
3140            .on_click(cx.listener(move |this, _, window, cx| {
3141                this.selection = None;
3142                if let Some(workspace) = this.workspace_for_group(&path_list, cx) {
3143                    this.create_new_thread(&workspace, window, cx);
3144                } else {
3145                    this.open_workspace_for_group(&path_list, window, cx);
3146                }
3147            }));
3148
3149        thread_item.into_any_element()
3150    }
3151
3152    fn render_no_results(&self, cx: &mut Context<Self>) -> impl IntoElement {
3153        let has_query = self.has_filter_query(cx);
3154        let message = if has_query {
3155            "No threads match your search."
3156        } else {
3157            "No threads yet"
3158        };
3159
3160        v_flex()
3161            .id("sidebar-no-results")
3162            .p_4()
3163            .size_full()
3164            .items_center()
3165            .justify_center()
3166            .child(
3167                Label::new(message)
3168                    .size(LabelSize::Small)
3169                    .color(Color::Muted),
3170            )
3171    }
3172
3173    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
3174        v_flex()
3175            .id("sidebar-empty-state")
3176            .p_4()
3177            .size_full()
3178            .items_center()
3179            .justify_center()
3180            .gap_1()
3181            .track_focus(&self.focus_handle(cx))
3182            .child(
3183                Button::new("open_project", "Open Project")
3184                    .full_width()
3185                    .key_binding(KeyBinding::for_action(&workspace::Open::default(), cx))
3186                    .on_click(|_, window, cx| {
3187                        window.dispatch_action(
3188                            Open {
3189                                create_new_window: false,
3190                            }
3191                            .boxed_clone(),
3192                            cx,
3193                        );
3194                    }),
3195            )
3196            .child(
3197                h_flex()
3198                    .w_1_2()
3199                    .gap_2()
3200                    .child(Divider::horizontal().color(ui::DividerColor::Border))
3201                    .child(Label::new("or").size(LabelSize::XSmall).color(Color::Muted))
3202                    .child(Divider::horizontal().color(ui::DividerColor::Border)),
3203            )
3204            .child(
3205                Button::new("clone_repo", "Clone Repository")
3206                    .full_width()
3207                    .on_click(|_, window, cx| {
3208                        window.dispatch_action(git::Clone.boxed_clone(), cx);
3209                    }),
3210            )
3211    }
3212
3213    fn render_sidebar_header(
3214        &self,
3215        no_open_projects: bool,
3216        window: &Window,
3217        cx: &mut Context<Self>,
3218    ) -> impl IntoElement {
3219        let has_query = self.has_filter_query(cx);
3220        let sidebar_on_left = self.side(cx) == SidebarSide::Left;
3221        let sidebar_on_right = self.side(cx) == SidebarSide::Right;
3222        let not_fullscreen = !window.is_fullscreen();
3223        let traffic_lights = cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left;
3224        let left_window_controls = !cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left;
3225        let right_window_controls =
3226            !cfg!(target_os = "macos") && not_fullscreen && sidebar_on_right;
3227        let header_height = platform_title_bar_height(window);
3228
3229        h_flex()
3230            .h(header_height)
3231            .mt_px()
3232            .pb_px()
3233            .when(left_window_controls, |this| {
3234                this.children(Self::render_left_window_controls(window, cx))
3235            })
3236            .map(|this| {
3237                if traffic_lights {
3238                    this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
3239                } else if !left_window_controls {
3240                    this.pl_1p5()
3241                } else {
3242                    this
3243                }
3244            })
3245            .when(!right_window_controls, |this| this.pr_1p5())
3246            .gap_1()
3247            .when(!no_open_projects, |this| {
3248                this.border_b_1()
3249                    .border_color(cx.theme().colors().border)
3250                    .when(traffic_lights, |this| {
3251                        this.child(Divider::vertical().color(ui::DividerColor::Border))
3252                    })
3253                    .child(
3254                        div().ml_1().child(
3255                            Icon::new(IconName::MagnifyingGlass)
3256                                .size(IconSize::Small)
3257                                .color(Color::Muted),
3258                        ),
3259                    )
3260                    .child(self.render_filter_input(cx))
3261                    .child(
3262                        h_flex()
3263                            .gap_1()
3264                            .when(
3265                                self.selection.is_some()
3266                                    && !self.filter_editor.focus_handle(cx).is_focused(window),
3267                                |this| this.child(KeyBinding::for_action(&FocusSidebarFilter, cx)),
3268                            )
3269                            .when(has_query, |this| {
3270                                this.child(
3271                                    IconButton::new("clear_filter", IconName::Close)
3272                                        .icon_size(IconSize::Small)
3273                                        .tooltip(Tooltip::text("Clear Search"))
3274                                        .on_click(cx.listener(|this, _, window, cx| {
3275                                            this.reset_filter_editor_text(window, cx);
3276                                            this.update_entries(cx);
3277                                        })),
3278                                )
3279                            }),
3280                    )
3281            })
3282            .when(right_window_controls, |this| {
3283                this.children(Self::render_right_window_controls(window, cx))
3284            })
3285    }
3286
3287    fn render_left_window_controls(window: &Window, cx: &mut App) -> Option<AnyElement> {
3288        platform_title_bar::render_left_window_controls(
3289            cx.button_layout(),
3290            Box::new(CloseWindow),
3291            window,
3292        )
3293    }
3294
3295    fn render_right_window_controls(window: &Window, cx: &mut App) -> Option<AnyElement> {
3296        platform_title_bar::render_right_window_controls(
3297            cx.button_layout(),
3298            Box::new(CloseWindow),
3299            window,
3300        )
3301    }
3302
3303    fn render_sidebar_toggle_button(&self, _cx: &mut Context<Self>) -> impl IntoElement {
3304        let on_right = AgentSettings::get_global(_cx).sidebar_side() == SidebarSide::Right;
3305
3306        sidebar_side_context_menu("sidebar-toggle-menu", _cx)
3307            .anchor(if on_right {
3308                gpui::Corner::BottomRight
3309            } else {
3310                gpui::Corner::BottomLeft
3311            })
3312            .attach(if on_right {
3313                gpui::Corner::TopRight
3314            } else {
3315                gpui::Corner::TopLeft
3316            })
3317            .trigger(move |_is_active, _window, _cx| {
3318                let icon = if on_right {
3319                    IconName::ThreadsSidebarRightOpen
3320                } else {
3321                    IconName::ThreadsSidebarLeftOpen
3322                };
3323                IconButton::new("sidebar-close-toggle", icon)
3324                    .icon_size(IconSize::Small)
3325                    .tooltip(Tooltip::element(move |_window, cx| {
3326                        v_flex()
3327                            .gap_1()
3328                            .child(
3329                                h_flex()
3330                                    .gap_2()
3331                                    .justify_between()
3332                                    .child(Label::new("Toggle Sidebar"))
3333                                    .child(KeyBinding::for_action(&ToggleWorkspaceSidebar, cx)),
3334                            )
3335                            .child(
3336                                h_flex()
3337                                    .pt_1()
3338                                    .gap_2()
3339                                    .border_t_1()
3340                                    .border_color(cx.theme().colors().border_variant)
3341                                    .justify_between()
3342                                    .child(Label::new("Focus Sidebar"))
3343                                    .child(KeyBinding::for_action(&FocusWorkspaceSidebar, cx)),
3344                            )
3345                            .into_any_element()
3346                    }))
3347                    .on_click(|_, window, cx| {
3348                        if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
3349                            multi_workspace.update(cx, |multi_workspace, cx| {
3350                                multi_workspace.close_sidebar(window, cx);
3351                            });
3352                        }
3353                    })
3354            })
3355    }
3356
3357    fn render_sidebar_bottom_bar(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
3358        let is_archive = matches!(self.view, SidebarView::Archive(..));
3359        let show_import_button = is_archive && !self.should_render_acp_import_onboarding(cx);
3360        let on_right = self.side(cx) == SidebarSide::Right;
3361
3362        let action_buttons = h_flex()
3363            .gap_1()
3364            .when(on_right, |this| this.flex_row_reverse())
3365            .when(show_import_button, |this| {
3366                this.child(
3367                    IconButton::new("thread-import", IconName::ThreadImport)
3368                        .icon_size(IconSize::Small)
3369                        .tooltip(Tooltip::text("Import ACP Threads"))
3370                        .on_click(cx.listener(|this, _, window, cx| {
3371                            this.show_archive(window, cx);
3372                            this.show_thread_import_modal(window, cx);
3373                        })),
3374                )
3375            })
3376            .child(
3377                IconButton::new("archive", IconName::Archive)
3378                    .icon_size(IconSize::Small)
3379                    .toggle_state(is_archive)
3380                    .tooltip(move |_, cx| {
3381                        Tooltip::for_action("Toggle Archived Threads", &ToggleArchive, cx)
3382                    })
3383                    .on_click(cx.listener(|this, _, window, cx| {
3384                        this.toggle_archive(&ToggleArchive, window, cx);
3385                    })),
3386            )
3387            .child(self.render_recent_projects_button(cx));
3388
3389        h_flex()
3390            .p_1()
3391            .gap_1()
3392            .when(on_right, |this| this.flex_row_reverse())
3393            .justify_between()
3394            .border_t_1()
3395            .border_color(cx.theme().colors().border)
3396            .child(self.render_sidebar_toggle_button(cx))
3397            .child(action_buttons)
3398    }
3399
3400    fn active_workspace(&self, cx: &App) -> Option<Entity<Workspace>> {
3401        self.multi_workspace
3402            .upgrade()
3403            .map(|w| w.read(cx).workspace().clone())
3404    }
3405
3406    fn show_thread_import_modal(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3407        let Some(active_workspace) = self.active_workspace(cx) else {
3408            return;
3409        };
3410
3411        let Some(agent_registry_store) = AgentRegistryStore::try_global(cx) else {
3412            return;
3413        };
3414
3415        let agent_server_store = active_workspace
3416            .read(cx)
3417            .project()
3418            .read(cx)
3419            .agent_server_store()
3420            .clone();
3421
3422        let workspace_handle = active_workspace.downgrade();
3423        let multi_workspace = self.multi_workspace.clone();
3424
3425        active_workspace.update(cx, |workspace, cx| {
3426            workspace.toggle_modal(window, cx, |window, cx| {
3427                ThreadImportModal::new(
3428                    agent_server_store,
3429                    agent_registry_store,
3430                    workspace_handle.clone(),
3431                    multi_workspace.clone(),
3432                    window,
3433                    cx,
3434                )
3435            });
3436        });
3437    }
3438
3439    fn should_render_acp_import_onboarding(&self, cx: &App) -> bool {
3440        let has_external_agents = self
3441            .active_workspace(cx)
3442            .map(|ws| {
3443                ws.read(cx)
3444                    .project()
3445                    .read(cx)
3446                    .agent_server_store()
3447                    .read(cx)
3448                    .has_external_agents()
3449            })
3450            .unwrap_or(false);
3451
3452        has_external_agents && !AcpThreadImportOnboarding::dismissed(cx)
3453    }
3454
3455    fn render_acp_import_onboarding(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
3456        let description =
3457            "Import threads from your ACP agents — whether started in Zed or another client.";
3458
3459        let bg = cx.theme().colors().text_accent;
3460
3461        v_flex()
3462            .min_w_0()
3463            .w_full()
3464            .p_2()
3465            .border_t_1()
3466            .border_color(cx.theme().colors().border)
3467            .bg(linear_gradient(
3468                360.,
3469                linear_color_stop(bg.opacity(0.06), 1.),
3470                linear_color_stop(bg.opacity(0.), 0.),
3471            ))
3472            .child(
3473                h_flex()
3474                    .min_w_0()
3475                    .w_full()
3476                    .gap_1()
3477                    .justify_between()
3478                    .child(Label::new("Looking for ACP threads?"))
3479                    .child(
3480                        IconButton::new("close-onboarding", IconName::Close)
3481                            .icon_size(IconSize::Small)
3482                            .on_click(|_, _window, cx| AcpThreadImportOnboarding::dismiss(cx)),
3483                    ),
3484            )
3485            .child(Label::new(description).color(Color::Muted).mb_2())
3486            .child(
3487                Button::new("import-acp", "Import ACP Threads")
3488                    .full_width()
3489                    .style(ButtonStyle::OutlinedCustom(cx.theme().colors().border))
3490                    .label_size(LabelSize::Small)
3491                    .start_icon(
3492                        Icon::new(IconName::ThreadImport)
3493                            .size(IconSize::Small)
3494                            .color(Color::Muted),
3495                    )
3496                    .on_click(cx.listener(|this, _, window, cx| {
3497                        this.show_archive(window, cx);
3498                        this.show_thread_import_modal(window, cx);
3499                    })),
3500            )
3501    }
3502
3503    fn toggle_archive(&mut self, _: &ToggleArchive, window: &mut Window, cx: &mut Context<Self>) {
3504        match &self.view {
3505            SidebarView::ThreadList => self.show_archive(window, cx),
3506            SidebarView::Archive(_) => self.show_thread_list(window, cx),
3507        }
3508    }
3509
3510    fn show_archive(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3511        let Some(active_workspace) = self
3512            .multi_workspace
3513            .upgrade()
3514            .map(|w| w.read(cx).workspace().clone())
3515        else {
3516            return;
3517        };
3518        let Some(agent_panel) = active_workspace.read(cx).panel::<AgentPanel>(cx) else {
3519            return;
3520        };
3521
3522        let agent_server_store = active_workspace
3523            .read(cx)
3524            .project()
3525            .read(cx)
3526            .agent_server_store()
3527            .downgrade();
3528
3529        let agent_connection_store = agent_panel.read(cx).connection_store().downgrade();
3530
3531        let archive_view = cx.new(|cx| {
3532            ThreadsArchiveView::new(
3533                active_workspace.downgrade(),
3534                agent_connection_store.clone(),
3535                agent_server_store.clone(),
3536                window,
3537                cx,
3538            )
3539        });
3540
3541        let subscription = cx.subscribe_in(
3542            &archive_view,
3543            window,
3544            |this, _, event: &ThreadsArchiveViewEvent, window, cx| match event {
3545                ThreadsArchiveViewEvent::Close => {
3546                    this.show_thread_list(window, cx);
3547                }
3548                ThreadsArchiveViewEvent::Unarchive { thread } => {
3549                    this.show_thread_list(window, cx);
3550                    this.activate_archived_thread(thread.clone(), window, cx);
3551                }
3552            },
3553        );
3554
3555        self._subscriptions.push(subscription);
3556        self.view = SidebarView::Archive(archive_view.clone());
3557        archive_view.update(cx, |view, cx| view.focus_filter_editor(window, cx));
3558        self.serialize(cx);
3559        cx.notify();
3560    }
3561
3562    fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3563        self.view = SidebarView::ThreadList;
3564        self._subscriptions.clear();
3565        let handle = self.filter_editor.read(cx).focus_handle(cx);
3566        handle.focus(window, cx);
3567        self.serialize(cx);
3568        cx.notify();
3569    }
3570}
3571
3572impl WorkspaceSidebar for Sidebar {
3573    fn width(&self, _cx: &App) -> Pixels {
3574        self.width
3575    }
3576
3577    fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
3578        self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
3579        cx.notify();
3580    }
3581
3582    fn has_notifications(&self, _cx: &App) -> bool {
3583        !self.contents.notified_threads.is_empty()
3584    }
3585
3586    fn is_threads_list_view_active(&self) -> bool {
3587        matches!(self.view, SidebarView::ThreadList)
3588    }
3589
3590    fn side(&self, cx: &App) -> SidebarSide {
3591        AgentSettings::get_global(cx).sidebar_side()
3592    }
3593
3594    fn prepare_for_focus(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
3595        self.selection = None;
3596        cx.notify();
3597    }
3598
3599    fn toggle_thread_switcher(
3600        &mut self,
3601        select_last: bool,
3602        window: &mut Window,
3603        cx: &mut Context<Self>,
3604    ) {
3605        self.toggle_thread_switcher_impl(select_last, window, cx);
3606    }
3607
3608    fn serialized_state(&self, _cx: &App) -> Option<String> {
3609        let serialized = SerializedSidebar {
3610            width: Some(f32::from(self.width)),
3611            collapsed_groups: self
3612                .collapsed_groups
3613                .iter()
3614                .map(|pl| pl.serialize())
3615                .collect(),
3616            expanded_groups: self
3617                .expanded_groups
3618                .iter()
3619                .map(|(pl, count)| (pl.serialize(), *count))
3620                .collect(),
3621            active_view: match self.view {
3622                SidebarView::ThreadList => SerializedSidebarView::ThreadList,
3623                SidebarView::Archive(_) => SerializedSidebarView::Archive,
3624            },
3625        };
3626        serde_json::to_string(&serialized).ok()
3627    }
3628
3629    fn restore_serialized_state(
3630        &mut self,
3631        state: &str,
3632        window: &mut Window,
3633        cx: &mut Context<Self>,
3634    ) {
3635        if let Some(serialized) = serde_json::from_str::<SerializedSidebar>(state).log_err() {
3636            if let Some(width) = serialized.width {
3637                self.width = px(width).clamp(MIN_WIDTH, MAX_WIDTH);
3638            }
3639            self.collapsed_groups = serialized
3640                .collapsed_groups
3641                .into_iter()
3642                .map(|s| PathList::deserialize(&s))
3643                .collect();
3644            self.expanded_groups = serialized
3645                .expanded_groups
3646                .into_iter()
3647                .map(|(s, count)| (PathList::deserialize(&s), count))
3648                .collect();
3649            if serialized.active_view == SerializedSidebarView::Archive {
3650                cx.defer_in(window, |this, window, cx| {
3651                    this.show_archive(window, cx);
3652                });
3653            }
3654        }
3655        cx.notify();
3656    }
3657}
3658
3659impl gpui::EventEmitter<workspace::SidebarEvent> for Sidebar {}
3660
3661impl Focusable for Sidebar {
3662    fn focus_handle(&self, _cx: &App) -> FocusHandle {
3663        self.focus_handle.clone()
3664    }
3665}
3666
3667impl Render for Sidebar {
3668    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3669        let _titlebar_height = ui::utils::platform_title_bar_height(window);
3670        let ui_font = theme_settings::setup_ui_font(window, cx);
3671        let sticky_header = self.render_sticky_header(window, cx);
3672
3673        let color = cx.theme().colors();
3674        let bg = color
3675            .title_bar_background
3676            .blend(color.panel_background.opacity(0.25));
3677
3678        let no_open_projects = !self.contents.has_open_projects;
3679        let no_search_results = self.contents.entries.is_empty();
3680
3681        v_flex()
3682            .id("workspace-sidebar")
3683            .key_context(self.dispatch_context(window, cx))
3684            .track_focus(&self.focus_handle)
3685            .on_action(cx.listener(Self::select_next))
3686            .on_action(cx.listener(Self::select_previous))
3687            .on_action(cx.listener(Self::editor_move_down))
3688            .on_action(cx.listener(Self::editor_move_up))
3689            .on_action(cx.listener(Self::select_first))
3690            .on_action(cx.listener(Self::select_last))
3691            .on_action(cx.listener(Self::confirm))
3692            .on_action(cx.listener(Self::expand_selected_entry))
3693            .on_action(cx.listener(Self::collapse_selected_entry))
3694            .on_action(cx.listener(Self::toggle_selected_fold))
3695            .on_action(cx.listener(Self::fold_all))
3696            .on_action(cx.listener(Self::unfold_all))
3697            .on_action(cx.listener(Self::cancel))
3698            .on_action(cx.listener(Self::remove_selected_thread))
3699            .on_action(cx.listener(Self::new_thread_in_group))
3700            .on_action(cx.listener(Self::toggle_archive))
3701            .on_action(cx.listener(Self::focus_sidebar_filter))
3702            .on_action(cx.listener(Self::on_toggle_thread_switcher))
3703            .on_action(cx.listener(|this, _: &OpenRecent, window, cx| {
3704                this.recent_projects_popover_handle.toggle(window, cx);
3705            }))
3706            .font(ui_font)
3707            .h_full()
3708            .w(self.width)
3709            .bg(bg)
3710            .when(self.side(cx) == SidebarSide::Left, |el| el.border_r_1())
3711            .when(self.side(cx) == SidebarSide::Right, |el| el.border_l_1())
3712            .border_color(color.border)
3713            .map(|this| match &self.view {
3714                SidebarView::ThreadList => this
3715                    .child(self.render_sidebar_header(no_open_projects, window, cx))
3716                    .map(|this| {
3717                        if no_open_projects {
3718                            this.child(self.render_empty_state(cx))
3719                        } else {
3720                            this.child(
3721                                v_flex()
3722                                    .relative()
3723                                    .flex_1()
3724                                    .overflow_hidden()
3725                                    .child(
3726                                        list(
3727                                            self.list_state.clone(),
3728                                            cx.processor(Self::render_list_entry),
3729                                        )
3730                                        .flex_1()
3731                                        .size_full(),
3732                                    )
3733                                    .when(no_search_results, |this| {
3734                                        this.child(self.render_no_results(cx))
3735                                    })
3736                                    .when_some(sticky_header, |this, header| this.child(header))
3737                                    .vertical_scrollbar_for(&self.list_state, window, cx),
3738                            )
3739                        }
3740                    }),
3741                SidebarView::Archive(archive_view) => this.child(archive_view.clone()),
3742            })
3743            .when(self.should_render_acp_import_onboarding(cx), |this| {
3744                this.child(self.render_acp_import_onboarding(cx))
3745            })
3746            .child(self.render_sidebar_bottom_bar(cx))
3747    }
3748}
3749
3750fn all_thread_infos_for_workspace(
3751    workspace: &Entity<Workspace>,
3752    cx: &App,
3753) -> impl Iterator<Item = ActiveThreadInfo> {
3754    let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
3755        return None.into_iter().flatten();
3756    };
3757    let agent_panel = agent_panel.read(cx);
3758
3759    let threads = agent_panel
3760        .parent_threads(cx)
3761        .into_iter()
3762        .map(|thread_view| {
3763            let thread_view_ref = thread_view.read(cx);
3764            let thread = thread_view_ref.thread.read(cx);
3765
3766            let icon = thread_view_ref.agent_icon;
3767            let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone();
3768            let title = thread
3769                .title()
3770                .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into());
3771            let is_native = thread_view_ref.as_native_thread(cx).is_some();
3772            let is_title_generating = is_native && thread.has_provisional_title();
3773            let session_id = thread.session_id().clone();
3774            let is_background = agent_panel.is_background_thread(&session_id);
3775
3776            let status = if thread.is_waiting_for_confirmation() {
3777                AgentThreadStatus::WaitingForConfirmation
3778            } else if thread.had_error() {
3779                AgentThreadStatus::Error
3780            } else {
3781                match thread.status() {
3782                    ThreadStatus::Generating => AgentThreadStatus::Running,
3783                    ThreadStatus::Idle => AgentThreadStatus::Completed,
3784                }
3785            };
3786
3787            let diff_stats = thread.action_log().read(cx).diff_stats(cx);
3788
3789            ActiveThreadInfo {
3790                session_id,
3791                title,
3792                status,
3793                icon,
3794                icon_from_external_svg,
3795                is_background,
3796                is_title_generating,
3797                diff_stats,
3798            }
3799        });
3800
3801    Some(threads).into_iter().flatten()
3802}
3803
3804pub fn dump_workspace_info(
3805    workspace: &mut Workspace,
3806    _: &DumpWorkspaceInfo,
3807    window: &mut gpui::Window,
3808    cx: &mut gpui::Context<Workspace>,
3809) {
3810    use std::fmt::Write;
3811
3812    let mut output = String::new();
3813    let this_entity = cx.entity();
3814
3815    let multi_workspace = workspace.multi_workspace().and_then(|weak| weak.upgrade());
3816    let workspaces: Vec<gpui::Entity<Workspace>> = match &multi_workspace {
3817        Some(mw) => mw.read(cx).workspaces().cloned().collect(),
3818        None => vec![this_entity.clone()],
3819    };
3820    let active_workspace = multi_workspace
3821        .as_ref()
3822        .map(|mw| mw.read(cx).workspace().clone());
3823
3824    writeln!(output, "MultiWorkspace: {} workspace(s)", workspaces.len()).ok();
3825
3826    if let Some(mw) = &multi_workspace {
3827        let keys: Vec<_> = mw.read(cx).project_group_keys().cloned().collect();
3828        writeln!(output, "Project group keys ({}):", keys.len()).ok();
3829        for key in keys {
3830            writeln!(output, "  - {key:?}").ok();
3831        }
3832    }
3833
3834    writeln!(output).ok();
3835
3836    for (index, ws) in workspaces.iter().enumerate() {
3837        let is_active = active_workspace.as_ref() == Some(ws);
3838        writeln!(
3839            output,
3840            "--- Workspace {index}{} ---",
3841            if is_active { " (active)" } else { "" }
3842        )
3843        .ok();
3844
3845        // The action handler is already inside an update on `this_entity`,
3846        // so we must avoid a nested read/update on that same entity.
3847        if *ws == this_entity {
3848            dump_single_workspace(workspace, &mut output, cx);
3849        } else {
3850            ws.read_with(cx, |ws, cx| {
3851                dump_single_workspace(ws, &mut output, cx);
3852            });
3853        }
3854    }
3855
3856    let project = workspace.project().clone();
3857    cx.spawn_in(window, async move |_this, cx| {
3858        let buffer = project
3859            .update(cx, |project, cx| project.create_buffer(None, false, cx))
3860            .await?;
3861
3862        buffer.update(cx, |buffer, cx| {
3863            buffer.set_text(output, cx);
3864        });
3865
3866        let buffer = cx.new(|cx| {
3867            editor::MultiBuffer::singleton(buffer, cx).with_title("Workspace Info".into())
3868        });
3869
3870        _this.update_in(cx, |workspace, window, cx| {
3871            workspace.add_item_to_active_pane(
3872                Box::new(cx.new(|cx| {
3873                    let mut editor =
3874                        editor::Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
3875                    editor.set_read_only(true);
3876                    editor.set_should_serialize(false, cx);
3877                    editor.set_breadcrumb_header("Workspace Info".into());
3878                    editor
3879                })),
3880                None,
3881                true,
3882                window,
3883                cx,
3884            );
3885        })
3886    })
3887    .detach_and_log_err(cx);
3888}
3889
3890fn dump_single_workspace(workspace: &Workspace, output: &mut String, cx: &gpui::App) {
3891    use std::fmt::Write;
3892
3893    let workspace_db_id = workspace.database_id();
3894    match workspace_db_id {
3895        Some(id) => writeln!(output, "Workspace DB ID: {id:?}").ok(),
3896        None => writeln!(output, "Workspace DB ID: (none)").ok(),
3897    };
3898
3899    let project = workspace.project().read(cx);
3900
3901    let repos: Vec<_> = project
3902        .repositories(cx)
3903        .values()
3904        .map(|repo| repo.read(cx).snapshot())
3905        .collect();
3906
3907    writeln!(output, "Worktrees:").ok();
3908    for worktree in project.worktrees(cx) {
3909        let worktree = worktree.read(cx);
3910        let abs_path = worktree.abs_path();
3911        let visible = worktree.is_visible();
3912
3913        let repo_info = repos
3914            .iter()
3915            .find(|snapshot| abs_path.starts_with(&*snapshot.work_directory_abs_path));
3916
3917        let is_linked = repo_info.map(|s| s.is_linked_worktree()).unwrap_or(false);
3918        let original_repo_path = repo_info.map(|s| &s.original_repo_abs_path);
3919        let branch = repo_info.and_then(|s| s.branch.as_ref().map(|b| b.ref_name.clone()));
3920
3921        write!(output, "  - {}", abs_path.display()).ok();
3922        if !visible {
3923            write!(output, " (hidden)").ok();
3924        }
3925        if let Some(branch) = &branch {
3926            write!(output, " [branch: {branch}]").ok();
3927        }
3928        if is_linked {
3929            if let Some(original) = original_repo_path {
3930                write!(output, " [linked worktree -> {}]", original.display()).ok();
3931            } else {
3932                write!(output, " [linked worktree]").ok();
3933            }
3934        }
3935        writeln!(output).ok();
3936    }
3937
3938    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
3939        let panel = panel.read(cx);
3940
3941        let panel_workspace_id = panel.workspace_id();
3942        if panel_workspace_id != workspace_db_id {
3943            writeln!(
3944                output,
3945                "  \u{26a0} workspace ID mismatch! panel has {panel_workspace_id:?}, workspace has {workspace_db_id:?}"
3946            )
3947            .ok();
3948        }
3949
3950        if let Some(thread) = panel.active_agent_thread(cx) {
3951            let thread = thread.read(cx);
3952            let title = thread.title().unwrap_or_else(|| "(untitled)".into());
3953            let session_id = thread.session_id();
3954            let status = match thread.status() {
3955                ThreadStatus::Idle => "idle",
3956                ThreadStatus::Generating => "generating",
3957            };
3958            let entry_count = thread.entries().len();
3959            write!(output, "Active thread: {title} (session: {session_id})").ok();
3960            write!(output, " [{status}, {entry_count} entries").ok();
3961            if thread.is_waiting_for_confirmation() {
3962                write!(output, ", awaiting confirmation").ok();
3963            }
3964            writeln!(output, "]").ok();
3965        } else {
3966            writeln!(output, "Active thread: (none)").ok();
3967        }
3968
3969        let background_threads = panel.background_threads();
3970        if !background_threads.is_empty() {
3971            writeln!(
3972                output,
3973                "Background threads ({}): ",
3974                background_threads.len()
3975            )
3976            .ok();
3977            for (session_id, conversation_view) in background_threads {
3978                if let Some(thread_view) = conversation_view.read(cx).root_thread(cx) {
3979                    let thread = thread_view.read(cx).thread.read(cx);
3980                    let title = thread.title().unwrap_or_else(|| "(untitled)".into());
3981                    let status = match thread.status() {
3982                        ThreadStatus::Idle => "idle",
3983                        ThreadStatus::Generating => "generating",
3984                    };
3985                    let entry_count = thread.entries().len();
3986                    write!(output, "  - {title} (session: {session_id})").ok();
3987                    write!(output, " [{status}, {entry_count} entries").ok();
3988                    if thread.is_waiting_for_confirmation() {
3989                        write!(output, ", awaiting confirmation").ok();
3990                    }
3991                    writeln!(output, "]").ok();
3992                } else {
3993                    writeln!(output, "  - (not connected) (session: {session_id})").ok();
3994                }
3995            }
3996        }
3997    } else {
3998        writeln!(output, "Agent panel: not loaded").ok();
3999    }
4000
4001    writeln!(output).ok();
4002}