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