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