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::{ArchivedGitWorktree, 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 git::repository::{AskPassDelegate, CommitOptions, ResetMode};
  19use gpui::{
  20    Action as _, AnyElement, App, AsyncWindowContext, Context, Entity, FocusHandle, Focusable,
  21    KeyContext, ListState, Pixels, PromptLevel, Render, SharedString, Task, WeakEntity, Window,
  22    WindowHandle, linear_color_stop, linear_gradient, list, prelude::*, px,
  23};
  24use menu::{
  25    Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious,
  26};
  27use notifications::status_toast::{StatusToast, ToastIcon};
  28use project::git_store;
  29use project::{AgentId, AgentRegistryStore, Event as ProjectEvent, linked_worktree_short_name};
  30use recent_projects::sidebar_recent_projects::SidebarRecentProjects;
  31use remote::RemoteConnectionOptions;
  32use ui::utils::platform_title_bar_height;
  33
  34use serde::{Deserialize, Serialize};
  35use settings::Settings as _;
  36use std::collections::{HashMap, HashSet};
  37use std::mem;
  38use std::path::PathBuf;
  39use std::rc::Rc;
  40use theme::ActiveTheme;
  41use ui::{
  42    AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, HighlightedLabel, KeyBinding,
  43    PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, ThreadItemWorktreeInfo, TintColor, Tooltip,
  44    WithScrollbar, prelude::*,
  45};
  46use util::ResultExt as _;
  47use util::path_list::{PathList, SerializedPathList};
  48use workspace::{
  49    AddFolderToProject, CloseWindow, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent,
  50    Open, Sidebar as WorkspaceSidebar, SidebarSide, ToggleWorkspaceSidebar, Workspace, WorkspaceId,
  51    sidebar_side_context_menu,
  52};
  53
  54use zed_actions::OpenRecent;
  55use zed_actions::editor::{MoveDown, MoveUp};
  56
  57use zed_actions::agents_sidebar::{FocusSidebarFilter, ToggleThreadSwitcher};
  58
  59use crate::thread_switcher::{ThreadSwitcher, ThreadSwitcherEntry, ThreadSwitcherEvent};
  60
  61use crate::project_group_builder::ProjectGroupBuilder;
  62
  63mod project_group_builder;
  64
  65#[cfg(test)]
  66mod sidebar_tests;
  67
  68gpui::actions!(
  69    agents_sidebar,
  70    [
  71        /// Creates a new thread in the currently selected or active project group.
  72        NewThreadInGroup,
  73        /// Toggles between the thread list and the archive view.
  74        ToggleArchive,
  75    ]
  76);
  77
  78gpui::actions!(
  79    dev,
  80    [
  81        /// Dumps multi-workspace state (projects, worktrees, active threads) into a new buffer.
  82        DumpWorkspaceInfo,
  83    ]
  84);
  85
  86const DEFAULT_WIDTH: Pixels = px(300.0);
  87const MIN_WIDTH: Pixels = px(200.0);
  88const MAX_WIDTH: Pixels = px(800.0);
  89const DEFAULT_THREADS_SHOWN: usize = 5;
  90
  91#[derive(Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
  92enum SerializedSidebarView {
  93    #[default]
  94    ThreadList,
  95    Archive,
  96}
  97
  98#[derive(Default, Serialize, Deserialize)]
  99struct SerializedSidebar {
 100    #[serde(default)]
 101    width: Option<f32>,
 102    #[serde(default)]
 103    collapsed_groups: Vec<SerializedPathList>,
 104    #[serde(default)]
 105    expanded_groups: Vec<(SerializedPathList, usize)>,
 106    #[serde(default)]
 107    active_view: SerializedSidebarView,
 108}
 109
 110#[derive(Debug, Default)]
 111enum SidebarView {
 112    #[default]
 113    ThreadList,
 114    Archive(Entity<ThreadsArchiveView>),
 115}
 116
 117#[derive(Clone, Debug)]
 118enum ActiveEntry {
 119    Thread {
 120        session_id: acp::SessionId,
 121        workspace: Entity<Workspace>,
 122    },
 123    Draft(Entity<Workspace>),
 124}
 125
 126impl ActiveEntry {
 127    fn workspace(&self) -> &Entity<Workspace> {
 128        match self {
 129            ActiveEntry::Thread { workspace, .. } => workspace,
 130            ActiveEntry::Draft(workspace) => workspace,
 131        }
 132    }
 133
 134    fn is_active_thread(&self, session_id: &acp::SessionId) -> bool {
 135        matches!(self, ActiveEntry::Thread { session_id: id, .. } if id == session_id)
 136    }
 137
 138    fn matches_entry(&self, entry: &ListEntry) -> bool {
 139        match (self, entry) {
 140            (ActiveEntry::Thread { session_id, .. }, ListEntry::Thread(thread)) => {
 141                thread.metadata.session_id == *session_id
 142            }
 143            (
 144                ActiveEntry::Draft(workspace),
 145                ListEntry::NewThread {
 146                    workspace: entry_workspace,
 147                    ..
 148                },
 149            ) => workspace == entry_workspace,
 150            _ => false,
 151        }
 152    }
 153}
 154
 155#[derive(Clone, Debug)]
 156struct ActiveThreadInfo {
 157    session_id: acp::SessionId,
 158    title: SharedString,
 159    status: AgentThreadStatus,
 160    icon: IconName,
 161    icon_from_external_svg: Option<SharedString>,
 162    is_background: bool,
 163    is_title_generating: bool,
 164    diff_stats: DiffStats,
 165}
 166
 167#[derive(Clone)]
 168enum ThreadEntryWorkspace {
 169    Open(Entity<Workspace>),
 170    Closed(PathList),
 171}
 172
 173#[derive(Clone)]
 174struct WorktreeInfo {
 175    name: SharedString,
 176    full_path: SharedString,
 177    highlight_positions: Vec<usize>,
 178}
 179
 180#[derive(Clone)]
 181struct ThreadEntry {
 182    metadata: ThreadMetadata,
 183    icon: IconName,
 184    icon_from_external_svg: Option<SharedString>,
 185    status: AgentThreadStatus,
 186    workspace: ThreadEntryWorkspace,
 187    is_live: bool,
 188    is_background: bool,
 189    is_title_generating: bool,
 190    highlight_positions: Vec<usize>,
 191    worktrees: Vec<WorktreeInfo>,
 192    diff_stats: DiffStats,
 193}
 194
 195impl ThreadEntry {
 196    /// Updates this thread entry with active thread information.
 197    ///
 198    /// The existing [`ThreadEntry`] was likely deserialized from the database
 199    /// but if we have a correspond thread already loaded we want to apply the
 200    /// live information.
 201    fn apply_active_info(&mut self, info: &ActiveThreadInfo) {
 202        self.metadata.title = info.title.clone();
 203        self.status = info.status;
 204        self.icon = info.icon;
 205        self.icon_from_external_svg = info.icon_from_external_svg.clone();
 206        self.is_live = true;
 207        self.is_background = info.is_background;
 208        self.is_title_generating = info.is_title_generating;
 209        self.diff_stats = info.diff_stats;
 210    }
 211}
 212
 213#[derive(Clone)]
 214enum ListEntry {
 215    ProjectHeader {
 216        path_list: PathList,
 217        label: SharedString,
 218        workspace: Entity<Workspace>,
 219        highlight_positions: Vec<usize>,
 220        has_running_threads: bool,
 221        waiting_thread_count: usize,
 222        is_active: bool,
 223    },
 224    Thread(ThreadEntry),
 225    ViewMore {
 226        path_list: PathList,
 227        is_fully_expanded: bool,
 228    },
 229    NewThread {
 230        path_list: PathList,
 231        workspace: Entity<Workspace>,
 232        worktrees: Vec<WorktreeInfo>,
 233    },
 234}
 235
 236#[cfg(test)]
 237impl ListEntry {
 238    fn workspace(&self) -> Option<Entity<Workspace>> {
 239        match self {
 240            ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
 241            ListEntry::Thread(thread_entry) => match &thread_entry.workspace {
 242                ThreadEntryWorkspace::Open(workspace) => Some(workspace.clone()),
 243                ThreadEntryWorkspace::Closed(_) => None,
 244            },
 245            ListEntry::ViewMore { .. } => None,
 246            ListEntry::NewThread { workspace, .. } => Some(workspace.clone()),
 247        }
 248    }
 249
 250    fn session_id(&self) -> Option<&acp::SessionId> {
 251        match self {
 252            ListEntry::Thread(thread_entry) => Some(&thread_entry.metadata.session_id),
 253            _ => None,
 254        }
 255    }
 256}
 257
 258impl From<ThreadEntry> for ListEntry {
 259    fn from(thread: ThreadEntry) -> Self {
 260        ListEntry::Thread(thread)
 261    }
 262}
 263
 264#[derive(Default)]
 265struct SidebarContents {
 266    entries: Vec<ListEntry>,
 267    notified_threads: HashSet<acp::SessionId>,
 268    project_header_indices: Vec<usize>,
 269    has_open_projects: bool,
 270}
 271
 272impl SidebarContents {
 273    fn is_thread_notified(&self, session_id: &acp::SessionId) -> bool {
 274        self.notified_threads.contains(session_id)
 275    }
 276}
 277
 278fn fuzzy_match_positions(query: &str, candidate: &str) -> Option<Vec<usize>> {
 279    let mut positions = Vec::new();
 280    let mut query_chars = query.chars().peekable();
 281
 282    for (byte_idx, candidate_char) in candidate.char_indices() {
 283        if let Some(&query_char) = query_chars.peek() {
 284            if candidate_char.eq_ignore_ascii_case(&query_char) {
 285                positions.push(byte_idx);
 286                query_chars.next();
 287            }
 288        } else {
 289            break;
 290        }
 291    }
 292
 293    if query_chars.peek().is_none() {
 294        Some(positions)
 295    } else {
 296        None
 297    }
 298}
 299
 300// TODO: The mapping from workspace root paths to git repositories needs a
 301// unified approach across the codebase: this function, `AgentPanel::classify_worktrees`,
 302// thread persistence (which PathList is saved to the database), and thread
 303// querying (which PathList is used to read threads back). All of these need
 304// to agree on how repos are resolved for a given workspace, especially in
 305// multi-root and nested-repo configurations.
 306fn root_repository_snapshots(
 307    workspace: &Entity<Workspace>,
 308    cx: &App,
 309) -> impl Iterator<Item = project::git_store::RepositorySnapshot> {
 310    let path_list = workspace_path_list(workspace, cx);
 311    let project = workspace.read(cx).project().read(cx);
 312    project.repositories(cx).values().filter_map(move |repo| {
 313        let snapshot = repo.read(cx).snapshot();
 314        let is_root = path_list
 315            .paths()
 316            .iter()
 317            .any(|p| p.as_path() == snapshot.work_directory_abs_path.as_ref());
 318        is_root.then_some(snapshot)
 319    })
 320}
 321
 322fn workspace_path_list(workspace: &Entity<Workspace>, cx: &App) -> PathList {
 323    PathList::new(&workspace.read(cx).root_paths(cx))
 324}
 325
 326/// Derives worktree display info from a thread's stored path list.
 327///
 328/// For each path in the thread's `folder_paths` that canonicalizes to a
 329/// different path (i.e. it's a git worktree), produces a [`WorktreeInfo`]
 330/// with the short worktree name and full path.
 331fn worktree_info_from_thread_paths(
 332    folder_paths: &PathList,
 333    project_groups: &ProjectGroupBuilder,
 334) -> Vec<WorktreeInfo> {
 335    folder_paths
 336        .paths()
 337        .iter()
 338        .filter_map(|path| {
 339            let canonical = project_groups.canonicalize_path(path);
 340            if canonical != path.as_path() {
 341                Some(WorktreeInfo {
 342                    name: linked_worktree_short_name(canonical, path).unwrap_or_default(),
 343                    full_path: SharedString::from(path.display().to_string()),
 344                    highlight_positions: Vec::new(),
 345                })
 346            } else {
 347                None
 348            }
 349        })
 350        .collect()
 351}
 352
 353/// The sidebar re-derives its entire entry list from scratch on every
 354/// change via `update_entries` → `rebuild_contents`. Avoid adding
 355/// incremental or inter-event coordination state — if something can
 356/// be computed from the current world state, compute it in the rebuild.
 357pub struct Sidebar {
 358    multi_workspace: WeakEntity<MultiWorkspace>,
 359    width: Pixels,
 360    focus_handle: FocusHandle,
 361    filter_editor: Entity<Editor>,
 362    list_state: ListState,
 363    contents: SidebarContents,
 364    /// The index of the list item that currently has the keyboard focus
 365    ///
 366    /// Note: This is NOT the same as the active item.
 367    selection: Option<usize>,
 368    /// Tracks which sidebar entry is currently active (highlighted).
 369    active_entry: Option<ActiveEntry>,
 370    hovered_thread_index: Option<usize>,
 371    collapsed_groups: HashSet<PathList>,
 372    expanded_groups: HashMap<PathList, usize>,
 373    /// Updated only in response to explicit user actions (clicking a
 374    /// thread, confirming in the thread switcher, etc.) — never from
 375    /// background data changes. Used to sort the thread switcher popup.
 376    thread_last_accessed: HashMap<acp::SessionId, DateTime<Utc>>,
 377    /// Updated when the user presses a key to send or queue a message.
 378    /// Used for sorting threads in the sidebar and as a secondary sort
 379    /// key in the thread switcher.
 380    thread_last_message_sent_or_queued: HashMap<acp::SessionId, DateTime<Utc>>,
 381    thread_switcher: Option<Entity<ThreadSwitcher>>,
 382    _thread_switcher_subscriptions: Vec<gpui::Subscription>,
 383    view: SidebarView,
 384    recent_projects_popover_handle: PopoverMenuHandle<SidebarRecentProjects>,
 385    project_header_menu_ix: Option<usize>,
 386    _subscriptions: Vec<gpui::Subscription>,
 387    _draft_observation: Option<gpui::Subscription>,
 388    pending_worktree_archives: HashMap<PathBuf, Task<anyhow::Result<()>>>,
 389}
 390
 391fn archived_worktree_ref_name(id: i64) -> String {
 392    format!("refs/archived-worktrees/{}", id)
 393}
 394
 395/// Finds the main (non-linked-worktree) repository entity for a given path
 396/// by searching across all open workspaces. This cross-workspace search is
 397/// needed because the restore flow starts from a DB record (not an open repo
 398/// entity), so the main repo could be in any open workspace window.
 399fn find_main_repo_in_workspaces(
 400    workspaces: &[Entity<Workspace>],
 401    main_repo_path: &std::path::Path,
 402    cx: &App,
 403) -> Option<Entity<git_store::Repository>> {
 404    workspaces.iter().find_map(|workspace| {
 405        let project = workspace.read(cx).project().clone();
 406        project
 407            .read(cx)
 408            .repositories(cx)
 409            .values()
 410            .find_map(|repo_entity| {
 411                let repo = repo_entity.read(cx);
 412                (repo.is_main_worktree() && *repo.work_directory_abs_path == *main_repo_path)
 413                    .then(|| repo_entity.clone())
 414            })
 415    })
 416}
 417
 418impl Sidebar {
 419    pub fn new(
 420        multi_workspace: Entity<MultiWorkspace>,
 421        window: &mut Window,
 422        cx: &mut Context<Self>,
 423    ) -> Self {
 424        let focus_handle = cx.focus_handle();
 425        cx.on_focus_in(&focus_handle, window, Self::focus_in)
 426            .detach();
 427
 428        let filter_editor = cx.new(|cx| {
 429            let mut editor = Editor::single_line(window, cx);
 430            editor.set_use_modal_editing(true);
 431            editor.set_placeholder_text("Search…", window, cx);
 432            editor
 433        });
 434
 435        cx.subscribe_in(
 436            &multi_workspace,
 437            window,
 438            |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
 439                MultiWorkspaceEvent::ActiveWorkspaceChanged => {
 440                    this.observe_draft_editor(cx);
 441                    this.update_entries(cx);
 442                }
 443                MultiWorkspaceEvent::WorkspaceAdded(workspace) => {
 444                    this.subscribe_to_workspace(workspace, window, cx);
 445                    this.update_entries(cx);
 446                }
 447                MultiWorkspaceEvent::WorkspaceRemoved(_) => {
 448                    this.update_entries(cx);
 449                }
 450            },
 451        )
 452        .detach();
 453
 454        cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
 455            if let editor::EditorEvent::BufferEdited = event {
 456                let query = this.filter_editor.read(cx).text(cx);
 457                if !query.is_empty() {
 458                    this.selection.take();
 459                }
 460                this.update_entries(cx);
 461                if !query.is_empty() {
 462                    this.select_first_entry();
 463                }
 464            }
 465        })
 466        .detach();
 467
 468        cx.observe(&ThreadMetadataStore::global(cx), |this, _store, cx| {
 469            this.update_entries(cx);
 470        })
 471        .detach();
 472
 473        cx.observe_flag::<AgentV2FeatureFlag, _>(window, |_is_enabled, this, _window, cx| {
 474            this.update_entries(cx);
 475        })
 476        .detach();
 477
 478        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
 479        cx.defer_in(window, move |this, window, cx| {
 480            for workspace in &workspaces {
 481                this.subscribe_to_workspace(workspace, window, cx);
 482            }
 483            this.update_entries(cx);
 484        });
 485
 486        Self {
 487            multi_workspace: multi_workspace.downgrade(),
 488            width: DEFAULT_WIDTH,
 489            focus_handle,
 490            filter_editor,
 491            list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
 492            contents: SidebarContents::default(),
 493            selection: None,
 494            active_entry: None,
 495            hovered_thread_index: None,
 496            collapsed_groups: HashSet::new(),
 497            expanded_groups: HashMap::new(),
 498            thread_last_accessed: HashMap::new(),
 499            thread_last_message_sent_or_queued: HashMap::new(),
 500            thread_switcher: None,
 501            _thread_switcher_subscriptions: Vec::new(),
 502            view: SidebarView::default(),
 503            recent_projects_popover_handle: PopoverMenuHandle::default(),
 504            project_header_menu_ix: None,
 505            _subscriptions: Vec::new(),
 506            _draft_observation: None,
 507            pending_worktree_archives: HashMap::default(),
 508        }
 509    }
 510
 511    fn serialize(&mut self, cx: &mut Context<Self>) {
 512        cx.emit(workspace::SidebarEvent::SerializeNeeded);
 513    }
 514
 515    fn active_entry_workspace(&self) -> Option<&Entity<Workspace>> {
 516        self.active_entry.as_ref().map(|entry| entry.workspace())
 517    }
 518
 519    fn is_active_workspace(&self, workspace: &Entity<Workspace>, cx: &App) -> bool {
 520        self.multi_workspace
 521            .upgrade()
 522            .map_or(false, |mw| mw.read(cx).workspace() == workspace)
 523    }
 524
 525    fn subscribe_to_workspace(
 526        &mut self,
 527        workspace: &Entity<Workspace>,
 528        window: &mut Window,
 529        cx: &mut Context<Self>,
 530    ) {
 531        let project = workspace.read(cx).project().clone();
 532        cx.subscribe_in(
 533            &project,
 534            window,
 535            |this, _project, event, _window, cx| match event {
 536                ProjectEvent::WorktreeAdded(_)
 537                | ProjectEvent::WorktreeRemoved(_)
 538                | ProjectEvent::WorktreeOrderChanged => {
 539                    this.update_entries(cx);
 540                }
 541                _ => {}
 542            },
 543        )
 544        .detach();
 545
 546        let git_store = workspace.read(cx).project().read(cx).git_store().clone();
 547        cx.subscribe_in(
 548            &git_store,
 549            window,
 550            |this, _, event: &project::git_store::GitStoreEvent, _window, cx| {
 551                if matches!(
 552                    event,
 553                    project::git_store::GitStoreEvent::RepositoryUpdated(
 554                        _,
 555                        project::git_store::RepositoryEvent::GitWorktreeListChanged,
 556                        _,
 557                    )
 558                ) {
 559                    this.update_entries(cx);
 560                }
 561            },
 562        )
 563        .detach();
 564
 565        cx.subscribe_in(
 566            workspace,
 567            window,
 568            |this, _workspace, event: &workspace::Event, window, cx| {
 569                if let workspace::Event::PanelAdded(view) = event {
 570                    if let Ok(agent_panel) = view.clone().downcast::<AgentPanel>() {
 571                        this.subscribe_to_agent_panel(&agent_panel, window, cx);
 572                    }
 573                }
 574            },
 575        )
 576        .detach();
 577
 578        self.observe_docks(workspace, cx);
 579
 580        if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
 581            self.subscribe_to_agent_panel(&agent_panel, window, cx);
 582            self.observe_draft_editor(cx);
 583        }
 584    }
 585
 586    fn subscribe_to_agent_panel(
 587        &mut self,
 588        agent_panel: &Entity<AgentPanel>,
 589        window: &mut Window,
 590        cx: &mut Context<Self>,
 591    ) {
 592        cx.subscribe_in(
 593            agent_panel,
 594            window,
 595            |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event {
 596                AgentPanelEvent::ActiveViewChanged => {
 597                    let is_new_draft = agent_panel
 598                        .read(cx)
 599                        .active_conversation_view()
 600                        .is_some_and(|cv| cv.read(cx).parent_id(cx).is_none());
 601                    if is_new_draft {
 602                        if let Some(active_workspace) = this
 603                            .multi_workspace
 604                            .upgrade()
 605                            .map(|mw| mw.read(cx).workspace().clone())
 606                        {
 607                            this.active_entry = Some(ActiveEntry::Draft(active_workspace));
 608                        }
 609                    }
 610                    this.observe_draft_editor(cx);
 611                    this.update_entries(cx);
 612                }
 613                AgentPanelEvent::ThreadFocused | AgentPanelEvent::BackgroundThreadChanged => {
 614                    this.update_entries(cx);
 615                }
 616                AgentPanelEvent::MessageSentOrQueued { session_id } => {
 617                    this.record_thread_message_sent(session_id);
 618                    this.update_entries(cx);
 619                }
 620            },
 621        )
 622        .detach();
 623    }
 624
 625    fn observe_docks(&mut self, workspace: &Entity<Workspace>, cx: &mut Context<Self>) {
 626        let docks: Vec<_> = workspace
 627            .read(cx)
 628            .all_docks()
 629            .into_iter()
 630            .cloned()
 631            .collect();
 632        let workspace = workspace.downgrade();
 633        for dock in docks {
 634            let workspace = workspace.clone();
 635            cx.observe(&dock, move |this, _dock, cx| {
 636                let Some(workspace) = workspace.upgrade() else {
 637                    return;
 638                };
 639                if !this.is_active_workspace(&workspace, cx) {
 640                    return;
 641                }
 642
 643                cx.notify();
 644            })
 645            .detach();
 646        }
 647    }
 648
 649    fn observe_draft_editor(&mut self, cx: &mut Context<Self>) {
 650        self._draft_observation = self
 651            .multi_workspace
 652            .upgrade()
 653            .and_then(|mw| {
 654                let ws = mw.read(cx).workspace();
 655                ws.read(cx).panel::<AgentPanel>(cx)
 656            })
 657            .and_then(|panel| {
 658                let cv = panel.read(cx).active_conversation_view()?;
 659                let tv = cv.read(cx).active_thread()?;
 660                Some(tv.read(cx).message_editor.clone())
 661            })
 662            .map(|editor| {
 663                cx.observe(&editor, |_this, _editor, cx| {
 664                    cx.notify();
 665                })
 666            });
 667    }
 668
 669    fn active_draft_text(&self, cx: &App) -> Option<SharedString> {
 670        let mw = self.multi_workspace.upgrade()?;
 671        let workspace = mw.read(cx).workspace();
 672        let panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
 673        let conversation_view = panel.read(cx).active_conversation_view()?;
 674        let thread_view = conversation_view.read(cx).active_thread()?;
 675        let raw = thread_view.read(cx).message_editor.read(cx).text(cx);
 676        let cleaned = Self::clean_mention_links(&raw);
 677        let mut text: String = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
 678        if text.is_empty() {
 679            None
 680        } else {
 681            const MAX_CHARS: usize = 250;
 682            if let Some((truncate_at, _)) = text.char_indices().nth(MAX_CHARS) {
 683                text.truncate(truncate_at);
 684            }
 685            Some(text.into())
 686        }
 687    }
 688
 689    fn clean_mention_links(input: &str) -> String {
 690        let mut result = String::with_capacity(input.len());
 691        let mut remaining = input;
 692
 693        while let Some(start) = remaining.find("[@") {
 694            result.push_str(&remaining[..start]);
 695            let after_bracket = &remaining[start + 1..]; // skip '['
 696            if let Some(close_bracket) = after_bracket.find("](") {
 697                let mention = &after_bracket[..close_bracket]; // "@something"
 698                let after_link_start = &after_bracket[close_bracket + 2..]; // after "]("
 699                if let Some(close_paren) = after_link_start.find(')') {
 700                    result.push_str(mention);
 701                    remaining = &after_link_start[close_paren + 1..];
 702                    continue;
 703                }
 704            }
 705            // Couldn't parse full link syntax — emit the literal "[@" and move on.
 706            result.push_str("[@");
 707            remaining = &remaining[start + 2..];
 708        }
 709        result.push_str(remaining);
 710        result
 711    }
 712
 713    /// Rebuilds the sidebar contents from current workspace and thread state.
 714    ///
 715    /// Uses [`ProjectGroupBuilder`] to group workspaces by their main git
 716    /// repository, then populates thread entries from the metadata store and
 717    /// merges live thread info from active agent panels.
 718    ///
 719    /// Aim for a single forward pass over workspaces and threads plus an
 720    /// O(T log T) sort. Avoid adding extra scans over the data.
 721    ///
 722    /// Properties:
 723    ///
 724    /// - Should always show every workspace in the multiworkspace
 725    ///     - If you have no threads, and two workspaces for the worktree and the main workspace, make sure at least one is shown
 726    /// - Should always show every thread, associated with each workspace in the multiworkspace
 727    /// - After every build_contents, our "active" state should exactly match the current workspace's, current agent panel's current thread.
 728    fn rebuild_contents(&mut self, cx: &App) {
 729        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
 730            return;
 731        };
 732        let mw = multi_workspace.read(cx);
 733        let workspaces = mw.workspaces().to_vec();
 734        let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned();
 735
 736        let agent_server_store = workspaces
 737            .first()
 738            .map(|ws| ws.read(cx).project().read(cx).agent_server_store().clone());
 739
 740        let query = self.filter_editor.read(cx).text(cx);
 741
 742        // Derive active_entry from the active workspace's agent panel.
 743        // Draft is checked first because a conversation can have a session_id
 744        // before any messages are sent. However, a thread that's still loading
 745        // also appears as a "draft" (no messages yet), so when we already have
 746        // an eager Thread write for this workspace we preserve it. A session_id
 747        // on a non-draft is a positive Thread signal. The remaining case
 748        // (conversation exists, not draft, no session_id) is a genuine
 749        // mid-load — keep the previous value.
 750        if let Some(active_ws) = &active_workspace {
 751            if let Some(panel) = active_ws.read(cx).panel::<AgentPanel>(cx) {
 752                if panel.read(cx).active_thread_is_draft(cx)
 753                    || panel.read(cx).active_conversation_view().is_none()
 754                {
 755                    let preserving_thread =
 756                        matches!(&self.active_entry, Some(ActiveEntry::Thread { .. }))
 757                            && self.active_entry_workspace() == Some(active_ws);
 758                    if !preserving_thread {
 759                        self.active_entry = Some(ActiveEntry::Draft(active_ws.clone()));
 760                    }
 761                } else if let Some(session_id) = panel
 762                    .read(cx)
 763                    .active_conversation_view()
 764                    .and_then(|cv| cv.read(cx).parent_id(cx))
 765                {
 766                    self.active_entry = Some(ActiveEntry::Thread {
 767                        session_id,
 768                        workspace: active_ws.clone(),
 769                    });
 770                }
 771                // else: conversation exists, not a draft, but no session_id
 772                // yet — thread is mid-load. Keep previous value.
 773            }
 774        }
 775
 776        let previous = mem::take(&mut self.contents);
 777
 778        let old_statuses: HashMap<acp::SessionId, AgentThreadStatus> = previous
 779            .entries
 780            .iter()
 781            .filter_map(|entry| match entry {
 782                ListEntry::Thread(thread) if thread.is_live => {
 783                    Some((thread.metadata.session_id.clone(), thread.status))
 784                }
 785                _ => None,
 786            })
 787            .collect();
 788
 789        let mut entries = Vec::new();
 790        let mut notified_threads = previous.notified_threads;
 791        let mut current_session_ids: HashSet<acp::SessionId> = HashSet::new();
 792        let mut project_header_indices: Vec<usize> = Vec::new();
 793
 794        // Use ProjectGroupBuilder to canonically group workspaces by their
 795        // main git repository. This replaces the manual absorbed-workspace
 796        // detection that was here before.
 797        let project_groups = ProjectGroupBuilder::from_multiworkspace(mw, cx);
 798
 799        let has_open_projects = workspaces
 800            .iter()
 801            .any(|ws| !workspace_path_list(ws, cx).paths().is_empty());
 802
 803        let resolve_agent_icon = |agent_id: &AgentId| -> (IconName, Option<SharedString>) {
 804            let agent = Agent::from(agent_id.clone());
 805            let icon = match agent {
 806                Agent::NativeAgent => IconName::ZedAgent,
 807                Agent::Custom { .. } => IconName::Terminal,
 808            };
 809            let icon_from_external_svg = agent_server_store
 810                .as_ref()
 811                .and_then(|store| store.read(cx).agent_icon(&agent_id));
 812            (icon, icon_from_external_svg)
 813        };
 814
 815        for (group_name, group) in project_groups.groups() {
 816            let path_list = group_name.path_list().clone();
 817            if path_list.paths().is_empty() {
 818                continue;
 819            }
 820
 821            let label = group_name.display_name();
 822
 823            let is_collapsed = self.collapsed_groups.contains(&path_list);
 824            let should_load_threads = !is_collapsed || !query.is_empty();
 825
 826            let is_active = active_workspace
 827                .as_ref()
 828                .is_some_and(|active| group.workspaces.contains(active));
 829
 830            // Pick a representative workspace for the group: prefer the active
 831            // workspace if it belongs to this group, otherwise use the main
 832            // repo workspace (not a linked worktree).
 833            let representative_workspace = active_workspace
 834                .as_ref()
 835                .filter(|_| is_active)
 836                .unwrap_or_else(|| group.main_workspace(cx));
 837
 838            // Collect live thread infos from all workspaces in this group.
 839            let live_infos: Vec<_> = group
 840                .workspaces
 841                .iter()
 842                .flat_map(|ws| all_thread_infos_for_workspace(ws, cx))
 843                .collect();
 844
 845            let mut threads: Vec<ThreadEntry> = Vec::new();
 846            let mut threadless_workspaces: Vec<(Entity<Workspace>, Vec<WorktreeInfo>)> = Vec::new();
 847            let mut has_running_threads = false;
 848            let mut waiting_thread_count: usize = 0;
 849
 850            if should_load_threads {
 851                let mut seen_session_ids: HashSet<acp::SessionId> = HashSet::new();
 852                let thread_store = ThreadMetadataStore::global(cx);
 853
 854                // Load threads from each workspace in the group.
 855                for workspace in &group.workspaces {
 856                    let ws_path_list = workspace_path_list(workspace, cx);
 857                    let mut workspace_rows = thread_store
 858                        .read(cx)
 859                        .entries_for_path(&ws_path_list)
 860                        .cloned()
 861                        .peekable();
 862                    if workspace_rows.peek().is_none() {
 863                        let worktrees =
 864                            worktree_info_from_thread_paths(&ws_path_list, &project_groups);
 865                        threadless_workspaces.push((workspace.clone(), worktrees));
 866                    }
 867                    for row in workspace_rows {
 868                        if !seen_session_ids.insert(row.session_id.clone()) {
 869                            continue;
 870                        }
 871                        let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id);
 872                        let worktrees =
 873                            worktree_info_from_thread_paths(&row.folder_paths, &project_groups);
 874                        threads.push(ThreadEntry {
 875                            metadata: row,
 876                            icon,
 877                            icon_from_external_svg,
 878                            status: AgentThreadStatus::default(),
 879                            workspace: ThreadEntryWorkspace::Open(workspace.clone()),
 880                            is_live: false,
 881                            is_background: false,
 882                            is_title_generating: false,
 883                            highlight_positions: Vec::new(),
 884                            worktrees,
 885                            diff_stats: DiffStats::default(),
 886                        });
 887                    }
 888                }
 889
 890                // Load threads from linked git worktrees whose
 891                // canonical paths belong to this group.
 892                let linked_worktree_queries = group
 893                    .workspaces
 894                    .iter()
 895                    .flat_map(|ws| root_repository_snapshots(ws, cx))
 896                    .filter(|snapshot| !snapshot.is_linked_worktree())
 897                    .flat_map(|snapshot| {
 898                        snapshot
 899                            .linked_worktrees()
 900                            .iter()
 901                            .filter(|wt| {
 902                                project_groups.group_owns_worktree(group, &path_list, &wt.path)
 903                            })
 904                            .map(|wt| PathList::new(std::slice::from_ref(&wt.path)))
 905                            .collect::<Vec<_>>()
 906                    });
 907
 908                for worktree_path_list in linked_worktree_queries {
 909                    for row in thread_store
 910                        .read(cx)
 911                        .entries_for_path(&worktree_path_list)
 912                        .cloned()
 913                    {
 914                        if !seen_session_ids.insert(row.session_id.clone()) {
 915                            continue;
 916                        }
 917                        let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id);
 918                        let worktrees =
 919                            worktree_info_from_thread_paths(&row.folder_paths, &project_groups);
 920                        threads.push(ThreadEntry {
 921                            metadata: row,
 922                            icon,
 923                            icon_from_external_svg,
 924                            status: AgentThreadStatus::default(),
 925                            workspace: ThreadEntryWorkspace::Closed(worktree_path_list.clone()),
 926                            is_live: false,
 927                            is_background: false,
 928                            is_title_generating: false,
 929                            highlight_positions: Vec::new(),
 930                            worktrees,
 931                            diff_stats: DiffStats::default(),
 932                        });
 933                    }
 934                }
 935
 936                // Build a lookup from live_infos and compute running/waiting
 937                // counts in a single pass.
 938                let mut live_info_by_session: HashMap<&acp::SessionId, &ActiveThreadInfo> =
 939                    HashMap::new();
 940                for info in &live_infos {
 941                    live_info_by_session.insert(&info.session_id, info);
 942                    if info.status == AgentThreadStatus::Running {
 943                        has_running_threads = true;
 944                    }
 945                    if info.status == AgentThreadStatus::WaitingForConfirmation {
 946                        waiting_thread_count += 1;
 947                    }
 948                }
 949
 950                // Merge live info into threads and update notification state
 951                // in a single pass.
 952                for thread in &mut threads {
 953                    if let Some(info) = live_info_by_session.get(&thread.metadata.session_id) {
 954                        thread.apply_active_info(info);
 955                    }
 956
 957                    let session_id = &thread.metadata.session_id;
 958
 959                    let is_active_thread = self.active_entry.as_ref().is_some_and(|entry| {
 960                        entry.is_active_thread(session_id)
 961                            && active_workspace
 962                                .as_ref()
 963                                .is_some_and(|active| active == entry.workspace())
 964                    });
 965
 966                    if thread.status == AgentThreadStatus::Completed
 967                        && !is_active_thread
 968                        && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running)
 969                    {
 970                        notified_threads.insert(session_id.clone());
 971                    }
 972
 973                    if is_active_thread && !thread.is_background {
 974                        notified_threads.remove(session_id);
 975                    }
 976                }
 977
 978                threads.sort_by(|a, b| {
 979                    let a_time = self
 980                        .thread_last_message_sent_or_queued
 981                        .get(&a.metadata.session_id)
 982                        .copied()
 983                        .or(a.metadata.created_at)
 984                        .or(Some(a.metadata.updated_at));
 985                    let b_time = self
 986                        .thread_last_message_sent_or_queued
 987                        .get(&b.metadata.session_id)
 988                        .copied()
 989                        .or(b.metadata.created_at)
 990                        .or(Some(b.metadata.updated_at));
 991                    b_time.cmp(&a_time)
 992                });
 993            } else {
 994                for info in live_infos {
 995                    if info.status == AgentThreadStatus::Running {
 996                        has_running_threads = true;
 997                    }
 998                    if info.status == AgentThreadStatus::WaitingForConfirmation {
 999                        waiting_thread_count += 1;
1000                    }
1001                }
1002            }
1003
1004            if !query.is_empty() {
1005                let workspace_highlight_positions =
1006                    fuzzy_match_positions(&query, &label).unwrap_or_default();
1007                let workspace_matched = !workspace_highlight_positions.is_empty();
1008
1009                let mut matched_threads: Vec<ThreadEntry> = Vec::new();
1010                for mut thread in threads {
1011                    let title: &str = &thread.metadata.title;
1012                    if let Some(positions) = fuzzy_match_positions(&query, title) {
1013                        thread.highlight_positions = positions;
1014                    }
1015                    let mut worktree_matched = false;
1016                    for worktree in &mut thread.worktrees {
1017                        if let Some(positions) = fuzzy_match_positions(&query, &worktree.name) {
1018                            worktree.highlight_positions = positions;
1019                            worktree_matched = true;
1020                        }
1021                    }
1022                    if workspace_matched
1023                        || !thread.highlight_positions.is_empty()
1024                        || worktree_matched
1025                    {
1026                        matched_threads.push(thread);
1027                    }
1028                }
1029
1030                if matched_threads.is_empty() && !workspace_matched {
1031                    continue;
1032                }
1033
1034                project_header_indices.push(entries.len());
1035                entries.push(ListEntry::ProjectHeader {
1036                    path_list: path_list.clone(),
1037                    label,
1038                    workspace: representative_workspace.clone(),
1039                    highlight_positions: workspace_highlight_positions,
1040                    has_running_threads,
1041                    waiting_thread_count,
1042                    is_active,
1043                });
1044
1045                for thread in matched_threads {
1046                    current_session_ids.insert(thread.metadata.session_id.clone());
1047                    entries.push(thread.into());
1048                }
1049            } else {
1050                let is_draft_for_workspace = is_active
1051                    && matches!(&self.active_entry, Some(ActiveEntry::Draft(_)))
1052                    && self.active_entry_workspace() == Some(representative_workspace);
1053
1054                project_header_indices.push(entries.len());
1055                entries.push(ListEntry::ProjectHeader {
1056                    path_list: path_list.clone(),
1057                    label,
1058                    workspace: representative_workspace.clone(),
1059                    highlight_positions: Vec::new(),
1060                    has_running_threads,
1061                    waiting_thread_count,
1062                    is_active,
1063                });
1064
1065                if is_collapsed {
1066                    continue;
1067                }
1068
1069                // Emit "New Thread" entries for threadless workspaces
1070                // and active drafts, right after the header.
1071                for (workspace, worktrees) in &threadless_workspaces {
1072                    entries.push(ListEntry::NewThread {
1073                        path_list: path_list.clone(),
1074                        workspace: workspace.clone(),
1075                        worktrees: worktrees.clone(),
1076                    });
1077                }
1078                if is_draft_for_workspace
1079                    && !threadless_workspaces
1080                        .iter()
1081                        .any(|(ws, _)| ws == representative_workspace)
1082                {
1083                    let ws_path_list = workspace_path_list(representative_workspace, cx);
1084                    let worktrees = worktree_info_from_thread_paths(&ws_path_list, &project_groups);
1085                    entries.push(ListEntry::NewThread {
1086                        path_list: path_list.clone(),
1087                        workspace: representative_workspace.clone(),
1088                        worktrees,
1089                    });
1090                }
1091
1092                let total = threads.len();
1093
1094                let extra_batches = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
1095                let threads_to_show =
1096                    DEFAULT_THREADS_SHOWN + (extra_batches * DEFAULT_THREADS_SHOWN);
1097                let count = threads_to_show.min(total);
1098
1099                let mut promoted_threads: HashSet<acp::SessionId> = HashSet::new();
1100
1101                // Build visible entries in a single pass. Threads within
1102                // the cutoff are always shown. Threads beyond it are shown
1103                // only if they should be promoted (running, waiting, or
1104                // focused)
1105                for (index, thread) in threads.into_iter().enumerate() {
1106                    let is_hidden = index >= count;
1107
1108                    let session_id = &thread.metadata.session_id;
1109                    if is_hidden {
1110                        let is_promoted = thread.status == AgentThreadStatus::Running
1111                            || thread.status == AgentThreadStatus::WaitingForConfirmation
1112                            || notified_threads.contains(session_id)
1113                            || self.active_entry.as_ref().is_some_and(|active| {
1114                                active.matches_entry(&ListEntry::Thread(thread.clone()))
1115                            });
1116                        if is_promoted {
1117                            promoted_threads.insert(session_id.clone());
1118                        }
1119                        if !promoted_threads.contains(session_id) {
1120                            continue;
1121                        }
1122                    }
1123
1124                    current_session_ids.insert(session_id.clone());
1125                    entries.push(thread.into());
1126                }
1127
1128                let visible = count + promoted_threads.len();
1129                let is_fully_expanded = visible >= total;
1130
1131                if total > DEFAULT_THREADS_SHOWN {
1132                    entries.push(ListEntry::ViewMore {
1133                        path_list: path_list.clone(),
1134                        is_fully_expanded,
1135                    });
1136                }
1137            }
1138        }
1139
1140        // Prune stale notifications using the session IDs we collected during
1141        // the build pass (no extra scan needed).
1142        notified_threads.retain(|id| current_session_ids.contains(id));
1143
1144        self.thread_last_accessed
1145            .retain(|id, _| current_session_ids.contains(id));
1146        self.thread_last_message_sent_or_queued
1147            .retain(|id, _| current_session_ids.contains(id));
1148
1149        self.contents = SidebarContents {
1150            entries,
1151            notified_threads,
1152            project_header_indices,
1153            has_open_projects,
1154        };
1155    }
1156
1157    /// Rebuilds the sidebar's visible entries from already-cached state.
1158    fn update_entries(&mut self, cx: &mut Context<Self>) {
1159        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1160            return;
1161        };
1162        if !multi_workspace.read(cx).multi_workspace_enabled(cx) {
1163            return;
1164        }
1165
1166        let had_notifications = self.has_notifications(cx);
1167        let scroll_position = self.list_state.logical_scroll_top();
1168
1169        self.rebuild_contents(cx);
1170
1171        self.list_state.reset(self.contents.entries.len());
1172        self.list_state.scroll_to(scroll_position);
1173
1174        if had_notifications != self.has_notifications(cx) {
1175            multi_workspace.update(cx, |_, cx| {
1176                cx.notify();
1177            });
1178        }
1179
1180        cx.notify();
1181    }
1182
1183    fn select_first_entry(&mut self) {
1184        self.selection = self
1185            .contents
1186            .entries
1187            .iter()
1188            .position(|entry| matches!(entry, ListEntry::Thread(_)))
1189            .or_else(|| {
1190                if self.contents.entries.is_empty() {
1191                    None
1192                } else {
1193                    Some(0)
1194                }
1195            });
1196    }
1197
1198    fn render_list_entry(
1199        &mut self,
1200        ix: usize,
1201        window: &mut Window,
1202        cx: &mut Context<Self>,
1203    ) -> AnyElement {
1204        let Some(entry) = self.contents.entries.get(ix) else {
1205            return div().into_any_element();
1206        };
1207        let is_focused = self.focus_handle.is_focused(window);
1208        // is_selected means the keyboard selector is here.
1209        let is_selected = is_focused && self.selection == Some(ix);
1210
1211        let is_group_header_after_first =
1212            ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. });
1213
1214        let is_active = self
1215            .active_entry
1216            .as_ref()
1217            .is_some_and(|active| active.matches_entry(entry));
1218
1219        let rendered = match entry {
1220            ListEntry::ProjectHeader {
1221                path_list,
1222                label,
1223                workspace,
1224                highlight_positions,
1225                has_running_threads,
1226                waiting_thread_count,
1227                is_active: is_active_group,
1228            } => self.render_project_header(
1229                ix,
1230                false,
1231                path_list,
1232                label,
1233                workspace,
1234                highlight_positions,
1235                *has_running_threads,
1236                *waiting_thread_count,
1237                *is_active_group,
1238                is_selected,
1239                cx,
1240            ),
1241            ListEntry::Thread(thread) => self.render_thread(ix, thread, is_active, is_selected, cx),
1242            ListEntry::ViewMore {
1243                path_list,
1244                is_fully_expanded,
1245            } => self.render_view_more(ix, path_list, *is_fully_expanded, is_selected, cx),
1246            ListEntry::NewThread {
1247                path_list,
1248                workspace,
1249                worktrees,
1250            } => self.render_new_thread(
1251                ix,
1252                path_list,
1253                workspace,
1254                is_active,
1255                worktrees,
1256                is_selected,
1257                cx,
1258            ),
1259        };
1260
1261        if is_group_header_after_first {
1262            v_flex()
1263                .w_full()
1264                .border_t_1()
1265                .border_color(cx.theme().colors().border)
1266                .child(rendered)
1267                .into_any_element()
1268        } else {
1269            rendered
1270        }
1271    }
1272
1273    fn render_remote_project_icon(
1274        &self,
1275        ix: usize,
1276        workspace: &Entity<Workspace>,
1277        cx: &mut Context<Self>,
1278    ) -> Option<AnyElement> {
1279        let project = workspace.read(cx).project().read(cx);
1280        let remote_connection_options = project.remote_connection_options(cx)?;
1281
1282        let remote_icon_per_type = match remote_connection_options {
1283            RemoteConnectionOptions::Wsl(_) => IconName::Linux,
1284            RemoteConnectionOptions::Docker(_) => IconName::Box,
1285            _ => IconName::Server,
1286        };
1287
1288        Some(
1289            div()
1290                .id(format!("remote-project-icon-{}", ix))
1291                .child(
1292                    Icon::new(remote_icon_per_type)
1293                        .size(IconSize::XSmall)
1294                        .color(Color::Muted),
1295                )
1296                .tooltip(Tooltip::text("Remote Project"))
1297                .into_any_element(),
1298        )
1299    }
1300
1301    fn render_project_header(
1302        &self,
1303        ix: usize,
1304        is_sticky: bool,
1305        path_list: &PathList,
1306        label: &SharedString,
1307        workspace: &Entity<Workspace>,
1308        highlight_positions: &[usize],
1309        has_running_threads: bool,
1310        waiting_thread_count: usize,
1311        is_active: bool,
1312        is_focused: bool,
1313        cx: &mut Context<Self>,
1314    ) -> AnyElement {
1315        let id_prefix = if is_sticky { "sticky-" } else { "" };
1316        let id = SharedString::from(format!("{id_prefix}project-header-{ix}"));
1317        let disclosure_id = SharedString::from(format!("disclosure-{ix}"));
1318        let group_name = SharedString::from(format!("{id_prefix}header-group-{ix}"));
1319
1320        let is_collapsed = self.collapsed_groups.contains(path_list);
1321        let (disclosure_icon, disclosure_tooltip) = if is_collapsed {
1322            (IconName::ChevronRight, "Expand Project")
1323        } else {
1324            (IconName::ChevronDown, "Collapse Project")
1325        };
1326
1327        let has_new_thread_entry = self
1328            .contents
1329            .entries
1330            .get(ix + 1)
1331            .is_some_and(|entry| matches!(entry, ListEntry::NewThread { .. }));
1332        let show_new_thread_button = !has_new_thread_entry && !self.has_filter_query(cx);
1333
1334        let workspace_for_remove = workspace.clone();
1335        let workspace_for_menu = workspace.clone();
1336        let workspace_for_open = workspace.clone();
1337
1338        let path_list_for_toggle = path_list.clone();
1339        let path_list_for_collapse = path_list.clone();
1340        let view_more_expanded = self.expanded_groups.contains_key(path_list);
1341
1342        let label = if highlight_positions.is_empty() {
1343            Label::new(label.clone())
1344                .when(!is_active, |this| this.color(Color::Muted))
1345                .into_any_element()
1346        } else {
1347            HighlightedLabel::new(label.clone(), highlight_positions.to_vec())
1348                .when(!is_active, |this| this.color(Color::Muted))
1349                .into_any_element()
1350        };
1351
1352        let color = cx.theme().colors();
1353        let hover_color = color
1354            .element_active
1355            .blend(color.element_background.opacity(0.2));
1356
1357        h_flex()
1358            .id(id)
1359            .group(&group_name)
1360            .h(Tab::content_height(cx))
1361            .w_full()
1362            .pl(px(5.))
1363            .pr_1p5()
1364            .border_1()
1365            .map(|this| {
1366                if is_focused {
1367                    this.border_color(color.border_focused)
1368                } else {
1369                    this.border_color(gpui::transparent_black())
1370                }
1371            })
1372            .justify_between()
1373            .child(
1374                h_flex()
1375                    .when(!is_active, |this| this.cursor_pointer())
1376                    .relative()
1377                    .min_w_0()
1378                    .w_full()
1379                    .gap(px(5.))
1380                    .child(
1381                        IconButton::new(disclosure_id, disclosure_icon)
1382                            .shape(ui::IconButtonShape::Square)
1383                            .icon_size(IconSize::Small)
1384                            .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.5)))
1385                            .tooltip(Tooltip::text(disclosure_tooltip))
1386                            .on_click(cx.listener(move |this, _, window, cx| {
1387                                this.selection = None;
1388                                this.toggle_collapse(&path_list_for_toggle, window, cx);
1389                            })),
1390                    )
1391                    .child(label)
1392                    .when_some(
1393                        self.render_remote_project_icon(ix, workspace, cx),
1394                        |this, icon| this.child(icon),
1395                    )
1396                    .when(is_collapsed, |this| {
1397                        this.when(has_running_threads, |this| {
1398                            this.child(
1399                                Icon::new(IconName::LoadCircle)
1400                                    .size(IconSize::XSmall)
1401                                    .color(Color::Muted)
1402                                    .with_rotate_animation(2),
1403                            )
1404                        })
1405                        .when(waiting_thread_count > 0, |this| {
1406                            let tooltip_text = if waiting_thread_count == 1 {
1407                                "1 thread is waiting for confirmation".to_string()
1408                            } else {
1409                                format!(
1410                                    "{waiting_thread_count} threads are waiting for confirmation",
1411                                )
1412                            };
1413                            this.child(
1414                                div()
1415                                    .id(format!("{id_prefix}waiting-indicator-{ix}"))
1416                                    .child(
1417                                        Icon::new(IconName::Warning)
1418                                            .size(IconSize::XSmall)
1419                                            .color(Color::Warning),
1420                                    )
1421                                    .tooltip(Tooltip::text(tooltip_text)),
1422                            )
1423                        })
1424                    }),
1425            )
1426            .child({
1427                let workspace_for_new_thread = workspace.clone();
1428                let path_list_for_new_thread = path_list.clone();
1429
1430                h_flex()
1431                    .when(self.project_header_menu_ix != Some(ix), |this| {
1432                        this.visible_on_hover(group_name)
1433                    })
1434                    .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
1435                        cx.stop_propagation();
1436                    })
1437                    .child(self.render_project_header_menu(
1438                        ix,
1439                        id_prefix,
1440                        &workspace_for_menu,
1441                        &workspace_for_remove,
1442                        cx,
1443                    ))
1444                    .when(view_more_expanded && !is_collapsed, |this| {
1445                        this.child(
1446                            IconButton::new(
1447                                SharedString::from(format!(
1448                                    "{id_prefix}project-header-collapse-{ix}",
1449                                )),
1450                                IconName::ListCollapse,
1451                            )
1452                            .icon_size(IconSize::Small)
1453                            .tooltip(Tooltip::text("Collapse Displayed Threads"))
1454                            .on_click(cx.listener({
1455                                let path_list_for_collapse = path_list_for_collapse.clone();
1456                                move |this, _, _window, cx| {
1457                                    this.selection = None;
1458                                    this.expanded_groups.remove(&path_list_for_collapse);
1459                                    this.serialize(cx);
1460                                    this.update_entries(cx);
1461                                }
1462                            })),
1463                        )
1464                    })
1465                    .when(show_new_thread_button, |this| {
1466                        this.child(
1467                            IconButton::new(
1468                                SharedString::from(format!(
1469                                    "{id_prefix}project-header-new-thread-{ix}",
1470                                )),
1471                                IconName::Plus,
1472                            )
1473                            .icon_size(IconSize::Small)
1474                            .tooltip(Tooltip::text("New Thread"))
1475                            .on_click(cx.listener({
1476                                let workspace_for_new_thread = workspace_for_new_thread.clone();
1477                                let path_list_for_new_thread = path_list_for_new_thread.clone();
1478                                move |this, _, window, cx| {
1479                                    // Uncollapse the group if collapsed so
1480                                    // the new-thread entry becomes visible.
1481                                    this.collapsed_groups.remove(&path_list_for_new_thread);
1482                                    this.selection = None;
1483                                    this.create_new_thread(&workspace_for_new_thread, window, cx);
1484                                }
1485                            })),
1486                        )
1487                    })
1488            })
1489            .when(!is_active, |this| {
1490                this.cursor_pointer()
1491                    .hover(|s| s.bg(hover_color))
1492                    .tooltip(Tooltip::text("Activate Workspace"))
1493                    .on_click(cx.listener({
1494                        move |this, _, window, cx| {
1495                            this.active_entry =
1496                                Some(ActiveEntry::Draft(workspace_for_open.clone()));
1497                            if let Some(multi_workspace) = this.multi_workspace.upgrade() {
1498                                multi_workspace.update(cx, |multi_workspace, cx| {
1499                                    multi_workspace.activate(
1500                                        workspace_for_open.clone(),
1501                                        window,
1502                                        cx,
1503                                    );
1504                                });
1505                            }
1506                            if AgentPanel::is_visible(&workspace_for_open, cx) {
1507                                workspace_for_open.update(cx, |workspace, cx| {
1508                                    workspace.focus_panel::<AgentPanel>(window, cx);
1509                                });
1510                            }
1511                        }
1512                    }))
1513            })
1514            .into_any_element()
1515    }
1516
1517    fn render_project_header_menu(
1518        &self,
1519        ix: usize,
1520        id_prefix: &str,
1521        workspace: &Entity<Workspace>,
1522        workspace_for_remove: &Entity<Workspace>,
1523        cx: &mut Context<Self>,
1524    ) -> impl IntoElement {
1525        let workspace_for_menu = workspace.clone();
1526        let workspace_for_remove = workspace_for_remove.clone();
1527        let multi_workspace = self.multi_workspace.clone();
1528        let this = cx.weak_entity();
1529
1530        PopoverMenu::new(format!("{id_prefix}project-header-menu-{ix}"))
1531            .on_open(Rc::new({
1532                let this = this.clone();
1533                move |_window, cx| {
1534                    this.update(cx, |sidebar, cx| {
1535                        sidebar.project_header_menu_ix = Some(ix);
1536                        cx.notify();
1537                    })
1538                    .ok();
1539                }
1540            }))
1541            .menu(move |window, cx| {
1542                let workspace = workspace_for_menu.clone();
1543                let workspace_for_remove = workspace_for_remove.clone();
1544                let multi_workspace = multi_workspace.clone();
1545
1546                let menu = ContextMenu::build_persistent(window, cx, move |menu, _window, cx| {
1547                    let worktrees: Vec<_> = workspace
1548                        .read(cx)
1549                        .visible_worktrees(cx)
1550                        .map(|worktree| {
1551                            let worktree_read = worktree.read(cx);
1552                            let id = worktree_read.id();
1553                            let name: SharedString =
1554                                worktree_read.root_name().as_unix_str().to_string().into();
1555                            (id, name)
1556                        })
1557                        .collect();
1558
1559                    let worktree_count = worktrees.len();
1560
1561                    let mut menu = menu
1562                        .header("Project Folders")
1563                        .end_slot_action(Box::new(menu::EndSlot));
1564
1565                    for (worktree_id, name) in &worktrees {
1566                        let worktree_id = *worktree_id;
1567                        let workspace_for_worktree = workspace.clone();
1568                        let workspace_for_remove_worktree = workspace_for_remove.clone();
1569                        let multi_workspace_for_worktree = multi_workspace.clone();
1570
1571                        let remove_handler = move |window: &mut Window, cx: &mut App| {
1572                            if worktree_count <= 1 {
1573                                if let Some(mw) = multi_workspace_for_worktree.upgrade() {
1574                                    let ws = workspace_for_remove_worktree.clone();
1575                                    mw.update(cx, |multi_workspace, cx| {
1576                                        multi_workspace.remove(&ws, window, cx);
1577                                    });
1578                                }
1579                            } else {
1580                                workspace_for_worktree.update(cx, |workspace, cx| {
1581                                    workspace.project().update(cx, |project, cx| {
1582                                        project.remove_worktree(worktree_id, cx);
1583                                    });
1584                                });
1585                            }
1586                        };
1587
1588                        menu = menu.entry_with_end_slot_on_hover(
1589                            name.clone(),
1590                            None,
1591                            |_, _| {},
1592                            IconName::Close,
1593                            "Remove Folder".into(),
1594                            remove_handler,
1595                        );
1596                    }
1597
1598                    let workspace_for_add = workspace.clone();
1599                    let multi_workspace_for_add = multi_workspace.clone();
1600                    let menu = menu.separator().entry(
1601                        "Add Folder to Project",
1602                        Some(Box::new(AddFolderToProject)),
1603                        move |window, cx| {
1604                            if let Some(mw) = multi_workspace_for_add.upgrade() {
1605                                mw.update(cx, |mw, cx| {
1606                                    mw.activate(workspace_for_add.clone(), window, cx);
1607                                });
1608                            }
1609                            workspace_for_add.update(cx, |workspace, cx| {
1610                                workspace.add_folder_to_project(&AddFolderToProject, window, cx);
1611                            });
1612                        },
1613                    );
1614
1615                    let workspace_count = multi_workspace
1616                        .upgrade()
1617                        .map_or(0, |mw| mw.read(cx).workspaces().len());
1618                    let menu = if workspace_count > 1 {
1619                        let workspace_for_move = workspace.clone();
1620                        let multi_workspace_for_move = multi_workspace.clone();
1621                        menu.entry(
1622                            "Move to New Window",
1623                            Some(Box::new(
1624                                zed_actions::agents_sidebar::MoveWorkspaceToNewWindow,
1625                            )),
1626                            move |window, cx| {
1627                                if let Some(mw) = multi_workspace_for_move.upgrade() {
1628                                    mw.update(cx, |multi_workspace, cx| {
1629                                        multi_workspace.move_workspace_to_new_window(
1630                                            &workspace_for_move,
1631                                            window,
1632                                            cx,
1633                                        );
1634                                    });
1635                                }
1636                            },
1637                        )
1638                    } else {
1639                        menu
1640                    };
1641
1642                    let workspace_for_remove = workspace_for_remove.clone();
1643                    let multi_workspace_for_remove = multi_workspace.clone();
1644                    menu.separator()
1645                        .entry("Remove Project", None, move |window, cx| {
1646                            if let Some(mw) = multi_workspace_for_remove.upgrade() {
1647                                let ws = workspace_for_remove.clone();
1648                                mw.update(cx, |multi_workspace, cx| {
1649                                    multi_workspace.remove(&ws, window, cx);
1650                                });
1651                            }
1652                        })
1653                });
1654
1655                let this = this.clone();
1656                window
1657                    .subscribe(&menu, cx, move |_, _: &gpui::DismissEvent, _window, cx| {
1658                        this.update(cx, |sidebar, cx| {
1659                            sidebar.project_header_menu_ix = None;
1660                            cx.notify();
1661                        })
1662                        .ok();
1663                    })
1664                    .detach();
1665
1666                Some(menu)
1667            })
1668            .trigger(
1669                IconButton::new(
1670                    SharedString::from(format!("{id_prefix}-ellipsis-menu-{ix}")),
1671                    IconName::Ellipsis,
1672                )
1673                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1674                .icon_size(IconSize::Small),
1675            )
1676            .anchor(gpui::Corner::TopRight)
1677            .offset(gpui::Point {
1678                x: px(0.),
1679                y: px(1.),
1680            })
1681    }
1682
1683    fn render_sticky_header(
1684        &self,
1685        window: &mut Window,
1686        cx: &mut Context<Self>,
1687    ) -> Option<AnyElement> {
1688        let scroll_top = self.list_state.logical_scroll_top();
1689
1690        let &header_idx = self
1691            .contents
1692            .project_header_indices
1693            .iter()
1694            .rev()
1695            .find(|&&idx| idx <= scroll_top.item_ix)?;
1696
1697        let needs_sticky = header_idx < scroll_top.item_ix
1698            || (header_idx == scroll_top.item_ix && scroll_top.offset_in_item > px(0.));
1699
1700        if !needs_sticky {
1701            return None;
1702        }
1703
1704        let ListEntry::ProjectHeader {
1705            path_list,
1706            label,
1707            workspace,
1708            highlight_positions,
1709            has_running_threads,
1710            waiting_thread_count,
1711            is_active,
1712        } = self.contents.entries.get(header_idx)?
1713        else {
1714            return None;
1715        };
1716
1717        let is_focused = self.focus_handle.is_focused(window);
1718        let is_selected = is_focused && self.selection == Some(header_idx);
1719
1720        let header_element = self.render_project_header(
1721            header_idx,
1722            true,
1723            &path_list,
1724            &label,
1725            workspace,
1726            &highlight_positions,
1727            *has_running_threads,
1728            *waiting_thread_count,
1729            *is_active,
1730            is_selected,
1731            cx,
1732        );
1733
1734        let top_offset = self
1735            .contents
1736            .project_header_indices
1737            .iter()
1738            .find(|&&idx| idx > header_idx)
1739            .and_then(|&next_idx| {
1740                let bounds = self.list_state.bounds_for_item(next_idx)?;
1741                let viewport = self.list_state.viewport_bounds();
1742                let y_in_viewport = bounds.origin.y - viewport.origin.y;
1743                let header_height = bounds.size.height;
1744                (y_in_viewport < header_height).then_some(y_in_viewport - header_height)
1745            })
1746            .unwrap_or(px(0.));
1747
1748        let color = cx.theme().colors();
1749        let background = color
1750            .title_bar_background
1751            .blend(color.panel_background.opacity(0.2));
1752
1753        let element = v_flex()
1754            .absolute()
1755            .top(top_offset)
1756            .left_0()
1757            .w_full()
1758            .bg(background)
1759            .border_b_1()
1760            .border_color(color.border.opacity(0.5))
1761            .child(header_element)
1762            .shadow_xs()
1763            .into_any_element();
1764
1765        Some(element)
1766    }
1767
1768    fn toggle_collapse(
1769        &mut self,
1770        path_list: &PathList,
1771        _window: &mut Window,
1772        cx: &mut Context<Self>,
1773    ) {
1774        if self.collapsed_groups.contains(path_list) {
1775            self.collapsed_groups.remove(path_list);
1776        } else {
1777            self.collapsed_groups.insert(path_list.clone());
1778        }
1779        self.serialize(cx);
1780        self.update_entries(cx);
1781    }
1782
1783    fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
1784        let mut dispatch_context = KeyContext::new_with_defaults();
1785        dispatch_context.add("ThreadsSidebar");
1786        dispatch_context.add("menu");
1787
1788        let identifier = if self.filter_editor.focus_handle(cx).is_focused(window) {
1789            "searching"
1790        } else {
1791            "not_searching"
1792        };
1793
1794        dispatch_context.add(identifier);
1795        dispatch_context
1796    }
1797
1798    fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1799        if !self.focus_handle.is_focused(window) {
1800            return;
1801        }
1802
1803        if let SidebarView::Archive(archive) = &self.view {
1804            let has_selection = archive.read(cx).has_selection();
1805            if !has_selection {
1806                archive.update(cx, |view, cx| view.focus_filter_editor(window, cx));
1807            }
1808        } else if self.selection.is_none() {
1809            self.filter_editor.focus_handle(cx).focus(window, cx);
1810        }
1811    }
1812
1813    fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
1814        if self.reset_filter_editor_text(window, cx) {
1815            self.update_entries(cx);
1816        } else {
1817            self.selection = None;
1818            self.filter_editor.focus_handle(cx).focus(window, cx);
1819            cx.notify();
1820        }
1821    }
1822
1823    fn focus_sidebar_filter(
1824        &mut self,
1825        _: &FocusSidebarFilter,
1826        window: &mut Window,
1827        cx: &mut Context<Self>,
1828    ) {
1829        self.selection = None;
1830        if let SidebarView::Archive(archive) = &self.view {
1831            archive.update(cx, |view, cx| {
1832                view.clear_selection();
1833                view.focus_filter_editor(window, cx);
1834            });
1835        } else {
1836            self.filter_editor.focus_handle(cx).focus(window, cx);
1837        }
1838
1839        // When vim mode is active, the editor defaults to normal mode which
1840        // blocks text input. Switch to insert mode so the user can type
1841        // immediately.
1842        if vim_mode_setting::VimModeSetting::get_global(cx).0 {
1843            if let Ok(action) = cx.build_action("vim::SwitchToInsertMode", None) {
1844                window.dispatch_action(action, cx);
1845            }
1846        }
1847
1848        cx.notify();
1849    }
1850
1851    fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1852        self.filter_editor.update(cx, |editor, cx| {
1853            if editor.buffer().read(cx).len(cx).0 > 0 {
1854                editor.set_text("", window, cx);
1855                true
1856            } else {
1857                false
1858            }
1859        })
1860    }
1861
1862    fn has_filter_query(&self, cx: &App) -> bool {
1863        !self.filter_editor.read(cx).text(cx).is_empty()
1864    }
1865
1866    fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
1867        self.select_next(&SelectNext, window, cx);
1868        if self.selection.is_some() {
1869            self.focus_handle.focus(window, cx);
1870        }
1871    }
1872
1873    fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
1874        self.select_previous(&SelectPrevious, window, cx);
1875        if self.selection.is_some() {
1876            self.focus_handle.focus(window, cx);
1877        }
1878    }
1879
1880    fn editor_confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1881        if self.selection.is_none() {
1882            self.select_next(&SelectNext, window, cx);
1883        }
1884        if self.selection.is_some() {
1885            self.focus_handle.focus(window, cx);
1886        }
1887    }
1888
1889    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
1890        let next = match self.selection {
1891            Some(ix) if ix + 1 < self.contents.entries.len() => ix + 1,
1892            Some(_) if !self.contents.entries.is_empty() => 0,
1893            None if !self.contents.entries.is_empty() => 0,
1894            _ => return,
1895        };
1896        self.selection = Some(next);
1897        self.list_state.scroll_to_reveal_item(next);
1898        cx.notify();
1899    }
1900
1901    fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
1902        match self.selection {
1903            Some(0) => {
1904                self.selection = None;
1905                self.filter_editor.focus_handle(cx).focus(window, cx);
1906                cx.notify();
1907            }
1908            Some(ix) => {
1909                self.selection = Some(ix - 1);
1910                self.list_state.scroll_to_reveal_item(ix - 1);
1911                cx.notify();
1912            }
1913            None if !self.contents.entries.is_empty() => {
1914                let last = self.contents.entries.len() - 1;
1915                self.selection = Some(last);
1916                self.list_state.scroll_to_reveal_item(last);
1917                cx.notify();
1918            }
1919            None => {}
1920        }
1921    }
1922
1923    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
1924        if !self.contents.entries.is_empty() {
1925            self.selection = Some(0);
1926            self.list_state.scroll_to_reveal_item(0);
1927            cx.notify();
1928        }
1929    }
1930
1931    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
1932        if let Some(last) = self.contents.entries.len().checked_sub(1) {
1933            self.selection = Some(last);
1934            self.list_state.scroll_to_reveal_item(last);
1935            cx.notify();
1936        }
1937    }
1938
1939    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1940        let Some(ix) = self.selection else { return };
1941        let Some(entry) = self.contents.entries.get(ix) else {
1942            return;
1943        };
1944
1945        match entry {
1946            ListEntry::ProjectHeader { path_list, .. } => {
1947                let path_list = path_list.clone();
1948                self.toggle_collapse(&path_list, window, cx);
1949            }
1950            ListEntry::Thread(thread) => {
1951                let metadata = thread.metadata.clone();
1952                match &thread.workspace {
1953                    ThreadEntryWorkspace::Open(workspace) => {
1954                        let workspace = workspace.clone();
1955                        self.activate_thread(metadata, &workspace, window, cx);
1956                    }
1957                    ThreadEntryWorkspace::Closed(path_list) => {
1958                        self.open_workspace_and_activate_thread(
1959                            metadata,
1960                            path_list.clone(),
1961                            window,
1962                            cx,
1963                        );
1964                    }
1965                }
1966            }
1967            ListEntry::ViewMore {
1968                path_list,
1969                is_fully_expanded,
1970                ..
1971            } => {
1972                let path_list = path_list.clone();
1973                if *is_fully_expanded {
1974                    self.expanded_groups.remove(&path_list);
1975                } else {
1976                    let current = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
1977                    self.expanded_groups.insert(path_list, current + 1);
1978                }
1979                self.serialize(cx);
1980                self.update_entries(cx);
1981            }
1982            ListEntry::NewThread { workspace, .. } => {
1983                let workspace = workspace.clone();
1984                self.create_new_thread(&workspace, window, cx);
1985            }
1986        }
1987    }
1988
1989    fn find_workspace_across_windows(
1990        &self,
1991        cx: &App,
1992        predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
1993    ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
1994        cx.windows()
1995            .into_iter()
1996            .filter_map(|window| window.downcast::<MultiWorkspace>())
1997            .find_map(|window| {
1998                let workspace = window.read(cx).ok().and_then(|multi_workspace| {
1999                    multi_workspace
2000                        .workspaces()
2001                        .iter()
2002                        .find(|workspace| predicate(workspace, cx))
2003                        .cloned()
2004                })?;
2005                Some((window, workspace))
2006            })
2007    }
2008
2009    fn find_workspace_in_current_window(
2010        &self,
2011        cx: &App,
2012        predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
2013    ) -> Option<Entity<Workspace>> {
2014        self.multi_workspace.upgrade().and_then(|multi_workspace| {
2015            multi_workspace
2016                .read(cx)
2017                .workspaces()
2018                .iter()
2019                .find(|workspace| predicate(workspace, cx))
2020                .cloned()
2021        })
2022    }
2023
2024    fn load_agent_thread_in_workspace(
2025        workspace: &Entity<Workspace>,
2026        metadata: &ThreadMetadata,
2027        focus: bool,
2028        window: &mut Window,
2029        cx: &mut App,
2030    ) {
2031        workspace.update(cx, |workspace, cx| {
2032            workspace.reveal_panel::<AgentPanel>(window, cx);
2033        });
2034
2035        if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2036            agent_panel.update(cx, |panel, cx| {
2037                panel.load_agent_thread(
2038                    Agent::from(metadata.agent_id.clone()),
2039                    metadata.session_id.clone(),
2040                    Some(metadata.folder_paths.clone()),
2041                    Some(metadata.title.clone()),
2042                    focus,
2043                    window,
2044                    cx,
2045                );
2046            });
2047        }
2048    }
2049
2050    fn activate_thread_locally(
2051        &mut self,
2052        metadata: &ThreadMetadata,
2053        workspace: &Entity<Workspace>,
2054        window: &mut Window,
2055        cx: &mut Context<Self>,
2056    ) {
2057        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2058            return;
2059        };
2060
2061        // Set active_entry eagerly so the sidebar highlight updates
2062        // immediately, rather than waiting for a deferred AgentPanel
2063        // event which can race with ActiveWorkspaceChanged clearing it.
2064        self.active_entry = Some(ActiveEntry::Thread {
2065            session_id: metadata.session_id.clone(),
2066            workspace: workspace.clone(),
2067        });
2068        self.record_thread_access(&metadata.session_id);
2069
2070        multi_workspace.update(cx, |multi_workspace, cx| {
2071            multi_workspace.activate(workspace.clone(), window, cx);
2072        });
2073
2074        Self::load_agent_thread_in_workspace(workspace, metadata, true, window, cx);
2075
2076        self.update_entries(cx);
2077    }
2078
2079    fn activate_thread_in_other_window(
2080        &self,
2081        metadata: ThreadMetadata,
2082        workspace: Entity<Workspace>,
2083        target_window: WindowHandle<MultiWorkspace>,
2084        cx: &mut Context<Self>,
2085    ) {
2086        let target_session_id = metadata.session_id.clone();
2087        let workspace_for_entry = workspace.clone();
2088
2089        let activated = target_window
2090            .update(cx, |multi_workspace, window, cx| {
2091                window.activate_window();
2092                multi_workspace.activate(workspace.clone(), window, cx);
2093                Self::load_agent_thread_in_workspace(&workspace, &metadata, true, window, cx);
2094            })
2095            .log_err()
2096            .is_some();
2097
2098        if activated {
2099            if let Some(target_sidebar) = target_window
2100                .read(cx)
2101                .ok()
2102                .and_then(|multi_workspace| {
2103                    multi_workspace.sidebar().map(|sidebar| sidebar.to_any())
2104                })
2105                .and_then(|sidebar| sidebar.downcast::<Self>().ok())
2106            {
2107                target_sidebar.update(cx, |sidebar, cx| {
2108                    sidebar.active_entry = Some(ActiveEntry::Thread {
2109                        session_id: target_session_id.clone(),
2110                        workspace: workspace_for_entry.clone(),
2111                    });
2112                    sidebar.record_thread_access(&target_session_id);
2113                    sidebar.update_entries(cx);
2114                });
2115            }
2116        }
2117    }
2118
2119    fn activate_thread(
2120        &mut self,
2121        metadata: ThreadMetadata,
2122        workspace: &Entity<Workspace>,
2123        window: &mut Window,
2124        cx: &mut Context<Self>,
2125    ) {
2126        if self
2127            .find_workspace_in_current_window(cx, |candidate, _| candidate == workspace)
2128            .is_some()
2129        {
2130            self.activate_thread_locally(&metadata, &workspace, window, cx);
2131            return;
2132        }
2133
2134        let Some((target_window, workspace)) =
2135            self.find_workspace_across_windows(cx, |candidate, _| candidate == workspace)
2136        else {
2137            return;
2138        };
2139
2140        self.activate_thread_in_other_window(metadata, workspace, target_window, cx);
2141    }
2142
2143    fn open_workspace_and_activate_thread(
2144        &mut self,
2145        metadata: ThreadMetadata,
2146        path_list: PathList,
2147        window: &mut Window,
2148        cx: &mut Context<Self>,
2149    ) {
2150        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2151            return;
2152        };
2153
2154        let paths: Vec<std::path::PathBuf> =
2155            path_list.paths().iter().map(|p| p.to_path_buf()).collect();
2156
2157        let open_task = multi_workspace.update(cx, |mw, cx| {
2158            mw.open_project(paths, workspace::OpenMode::Activate, window, cx)
2159        });
2160
2161        cx.spawn_in(window, async move |this, cx| {
2162            let workspace = open_task.await?;
2163
2164            this.update_in(cx, |this, window, cx| {
2165                this.activate_thread(metadata, &workspace, window, cx);
2166            })?;
2167            anyhow::Ok(())
2168        })
2169        .detach_and_log_err(cx);
2170    }
2171
2172    fn find_current_workspace_for_path_list(
2173        &self,
2174        path_list: &PathList,
2175        cx: &App,
2176    ) -> Option<Entity<Workspace>> {
2177        self.find_workspace_in_current_window(cx, |workspace, cx| {
2178            workspace_path_list(workspace, cx).paths() == path_list.paths()
2179        })
2180    }
2181
2182    fn find_open_workspace_for_path_list(
2183        &self,
2184        path_list: &PathList,
2185        cx: &App,
2186    ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
2187        self.find_workspace_across_windows(cx, |workspace, cx| {
2188            workspace_path_list(workspace, cx).paths() == path_list.paths()
2189        })
2190    }
2191
2192    fn activate_archived_thread(
2193        &mut self,
2194        metadata: ThreadMetadata,
2195        window: &mut Window,
2196        cx: &mut Context<Self>,
2197    ) {
2198        ThreadMetadataStore::global(cx)
2199            .update(cx, |store, cx| store.unarchive(&metadata.session_id, cx));
2200
2201        if metadata.folder_paths.paths().is_empty() {
2202            self.activate_unarchived_thread_in_workspace(&metadata, window, cx);
2203            return;
2204        }
2205
2206        // Cancel any in-flight archive tasks for the paths we're about to restore.
2207        for path in metadata.folder_paths.paths() {
2208            self.pending_worktree_archives.remove(path);
2209        }
2210
2211        let session_id = metadata.session_id.clone();
2212        let store = ThreadMetadataStore::global(cx);
2213
2214        let task = store.update(cx, |store, cx| {
2215            store.get_archived_worktrees_for_thread(session_id.0.to_string(), cx)
2216        });
2217
2218        cx.spawn_in(window, async move |this, cx| {
2219            let archived_worktrees = task.await.unwrap_or_default();
2220
2221            if archived_worktrees.is_empty() {
2222                this.update_in(cx, |this, window, cx| {
2223                    this.activate_unarchived_thread_in_workspace(&metadata, window, cx);
2224                })?;
2225                return anyhow::Ok(());
2226            }
2227
2228            // Use the first archived worktree's main repo path to show the
2229            // thread immediately while restoration proceeds in the background.
2230            let first_archived = &archived_worktrees[0];
2231            let main_repo_path = first_archived.main_repo_path.clone();
2232
2233            // Step 1: Immediately associate thread with main repo and show it.
2234            let store = cx.update(|_window, cx| ThreadMetadataStore::global(cx))?;
2235            store.update(cx, |store, cx| {
2236                store.update_working_directories(
2237                    &metadata.session_id,
2238                    PathList::new(std::slice::from_ref(&main_repo_path)),
2239                    cx,
2240                );
2241                store.set_pending_worktree_restore(
2242                    &metadata.session_id,
2243                    Some(main_repo_path.clone()),
2244                    cx,
2245                );
2246            });
2247
2248            let temp_metadata = store
2249                .update(cx, |store, _cx| store.entry(&metadata.session_id).cloned())
2250                .unwrap_or(metadata.clone());
2251
2252            // Open the main repo workspace explicitly so we can capture the
2253            // project entity for git operations.
2254            let main_workspace = this.update_in(cx, |this, window, cx| {
2255                let multi_workspace = this.multi_workspace.upgrade()?;
2256                Some(multi_workspace.update(cx, |mw, cx| {
2257                    mw.open_project(
2258                        vec![main_repo_path.to_path_buf()],
2259                        workspace::OpenMode::Activate,
2260                        window,
2261                        cx,
2262                    )
2263                }))
2264            })?;
2265
2266            let main_workspace = match main_workspace {
2267                Some(task) => task.await?,
2268                None => {
2269                    this.update_in(cx, |this, window, cx| {
2270                        this.activate_unarchived_thread_in_workspace(&temp_metadata, window, cx);
2271                    })?;
2272                    return anyhow::Ok(());
2273                }
2274            };
2275
2276            this.update_in(cx, |this, window, cx| {
2277                this.activate_thread_locally(&temp_metadata, &main_workspace, window, cx);
2278            })?;
2279
2280            let project = cx.update(|_window, cx| main_workspace.read(cx).project().clone())?;
2281
2282            // Step 2: Background git restoration.
2283            for row in &archived_worktrees {
2284                match Self::restore_worktree_via_git(row, &project, cx).await {
2285                    Ok(restored_path) => {
2286                        Self::cleanup_archived_worktree_record(row, &project, &store, cx).await;
2287
2288                        // Step 3: Reassociate thread with the restored worktree path.
2289                        let new_paths = PathList::new(std::slice::from_ref(&restored_path));
2290                        store.update(cx, |store, cx| {
2291                            store.complete_worktree_restore(
2292                                &metadata.session_id,
2293                                new_paths.clone(),
2294                                cx,
2295                            );
2296                        });
2297
2298                        // Open the restored worktree workspace and activate
2299                        // the thread there.
2300                        let paths_vec = vec![restored_path];
2301                        let open_result = this.update_in(cx, |this, window, cx| {
2302                            let Some(multi_workspace) = this.multi_workspace.upgrade() else {
2303                                return None;
2304                            };
2305                            Some(multi_workspace.update(cx, |mw, cx| {
2306                                mw.open_project(paths_vec, workspace::OpenMode::Add, window, cx)
2307                            }))
2308                        })?;
2309
2310                        if let Some(open_task) = open_result {
2311                            if let Ok(workspace) = open_task.await {
2312                                let final_metadata = store
2313                                    .update(cx, |store, _cx| {
2314                                        store.entry(&metadata.session_id).cloned()
2315                                    })
2316                                    .unwrap_or_else(|| {
2317                                        let mut m = metadata.clone();
2318                                        m.folder_paths = new_paths;
2319                                        m.pending_worktree_restore = None;
2320                                        m
2321                                    });
2322                                this.update_in(cx, |this, window, cx| {
2323                                    this.activate_thread_locally(
2324                                        &final_metadata,
2325                                        &workspace,
2326                                        window,
2327                                        cx,
2328                                    );
2329                                })
2330                                .ok();
2331                            }
2332                        }
2333                    }
2334                    Err(err) => {
2335                        let worktree_dir = row
2336                            .worktree_path
2337                            .file_name()
2338                            .map(|n| n.to_string_lossy().to_string())
2339                            .unwrap_or_else(|| row.worktree_path.to_string_lossy().to_string());
2340                        log::error!(
2341                            "Failed to restore worktree: {err}\n\
2342                             worktree path: {}\n\
2343                             main repo path: {}\n\
2344                             step: git worktree restoration",
2345                            row.worktree_path.display(),
2346                            main_repo_path.display(),
2347                        );
2348
2349                        // Clear pending state — leave thread on main repo.
2350                        store.update(cx, |store, cx| {
2351                            store.set_pending_worktree_restore(&metadata.session_id, None, cx);
2352                        });
2353
2354                        this.update_in(cx, |this, _window, cx| {
2355                            let Some(multi_workspace) = this.multi_workspace.upgrade() else {
2356                                return;
2357                            };
2358                            let workspace = multi_workspace.read(cx).workspace().clone();
2359                            workspace.update(cx, |workspace, cx| {
2360                                let toast = StatusToast::new(
2361                                    format!("Failed to restore worktree \"{worktree_dir}\""),
2362                                    cx,
2363                                    |this, _cx| {
2364                                        this.icon(
2365                                            ToastIcon::new(IconName::XCircle).color(Color::Error),
2366                                        )
2367                                    },
2368                                );
2369                                workspace.toggle_status_toast(toast, cx);
2370                            });
2371                        })
2372                        .ok();
2373                    }
2374                }
2375            }
2376
2377            anyhow::Ok(())
2378        })
2379        .detach_and_log_err(cx);
2380    }
2381
2382    fn activate_unarchived_thread_in_workspace(
2383        &mut self,
2384        metadata: &ThreadMetadata,
2385        window: &mut Window,
2386        cx: &mut Context<Self>,
2387    ) {
2388        if !metadata.folder_paths.paths().is_empty() {
2389            let path_list = metadata.folder_paths.clone();
2390            if let Some(workspace) = self.find_current_workspace_for_path_list(&path_list, cx) {
2391                self.activate_thread_locally(metadata, &workspace, window, cx);
2392            } else if let Some((target_window, workspace)) =
2393                self.find_open_workspace_for_path_list(&path_list, cx)
2394            {
2395                self.activate_thread_in_other_window(
2396                    metadata.clone(),
2397                    workspace,
2398                    target_window,
2399                    cx,
2400                );
2401            } else {
2402                self.open_workspace_and_activate_thread(metadata.clone(), path_list, window, cx);
2403            }
2404            return;
2405        }
2406
2407        let active_workspace = self.multi_workspace.upgrade().and_then(|w| {
2408            w.read(cx)
2409                .workspaces()
2410                .get(w.read(cx).active_workspace_index())
2411                .cloned()
2412        });
2413
2414        if let Some(workspace) = active_workspace {
2415            self.activate_thread_locally(metadata, &workspace, window, cx);
2416        }
2417    }
2418
2419    async fn restore_worktree_via_git(
2420        row: &ArchivedGitWorktree,
2421        project: &Entity<project::Project>,
2422        cx: &mut AsyncWindowContext,
2423    ) -> anyhow::Result<PathBuf> {
2424        let main_repo_path = row.main_repo_path.clone();
2425        let worktree_path = row.worktree_path.clone();
2426        let commit_hash = row.commit_hash.clone();
2427
2428        // Find the main repository entity from the project.
2429        let main_repo = cx.update(|_window, cx| {
2430            project
2431                .read(cx)
2432                .repositories(cx)
2433                .values()
2434                .find_map(|repo_entity| {
2435                    let repo = repo_entity.read(cx);
2436                    (repo.is_main_worktree() && *repo.work_directory_abs_path == *main_repo_path)
2437                        .then(|| repo_entity.clone())
2438                })
2439        })?;
2440        let Some(main_repo) = main_repo else {
2441            anyhow::bail!("Main repository not found in project");
2442        };
2443
2444        // Check whether the target path already exists and pick a
2445        // unique alternative if so.
2446        let fs = cx.update(|_window, cx| <dyn fs::Fs>::global(cx))?;
2447        let already_exists = fs.metadata(&worktree_path).await?.is_some();
2448
2449        let final_path = if already_exists {
2450            let worktree_directory = git_store::worktrees_directory_for_repo(
2451                &main_repo_path,
2452                git::repository::DEFAULT_WORKTREE_DIRECTORY,
2453            )?;
2454            let new_name = format!(
2455                "{}-restored-{}",
2456                row.branch_name.as_deref().unwrap_or("worktree"),
2457                row.id
2458            );
2459            let project_name = main_repo_path
2460                .file_name()
2461                .ok_or_else(|| anyhow::anyhow!("git repo must have a directory name"))?;
2462            worktree_directory.join(&new_name).join(project_name)
2463        } else {
2464            worktree_path.clone()
2465        };
2466
2467        // Clean up any stale worktree registration at this path (e.g. from
2468        // a previous archive that deleted the directory but left the git
2469        // metadata behind).
2470        let cleanup_result = main_repo.update(cx, |repo, _cx| {
2471            repo.remove_worktree(final_path.clone(), true)
2472        });
2473        match cleanup_result.await {
2474            Ok(Ok(())) => log::info!(
2475                "Cleaned up stale worktree registration at {}",
2476                final_path.display()
2477            ),
2478            _ => {} // Not registered or already clean — that's fine
2479        }
2480
2481        // Create the worktree via the Repository entity (detached, since
2482        // the commit is a WIP snapshot, not a real branch tip).
2483        let create_result = main_repo.update(cx, |repo, _cx| {
2484            repo.create_worktree_detached(final_path.clone(), commit_hash.clone())
2485        });
2486        match create_result.await {
2487            Ok(Ok(())) => {}
2488            Ok(Err(err)) => anyhow::bail!("Failed to create worktree: {err}"),
2489            Err(_) => anyhow::bail!("Worktree creation was canceled"),
2490        }
2491
2492        // Register the new worktree directory with the project so it
2493        // scans the directory and discovers the git repository.
2494        let (worktree, _) = project
2495            .update(cx, |project, cx| {
2496                project.find_or_create_worktree(final_path.clone(), true, cx)
2497            })
2498            .await?;
2499
2500        let scan_complete = cx.update(|_window, cx| {
2501            worktree
2502                .read(cx)
2503                .as_local()
2504                .map(|local| local.scan_complete())
2505        })?;
2506        if let Some(scan) = scan_complete {
2507            scan.await;
2508        }
2509
2510        // Find the worktree's repository entity. The git scan runs
2511        // asynchronously after the file scan, so the entity may not
2512        // exist yet. If it's missing we skip the reset/branch steps
2513        // — the worktree still exists on disk with the WIP commit.
2514        let wt_repo = cx.update(|_window, cx| {
2515            project
2516                .read(cx)
2517                .repositories(cx)
2518                .values()
2519                .find_map(|repo_entity| {
2520                    let snap = repo_entity.read(cx).snapshot();
2521                    (*snap.work_directory_abs_path == *final_path).then(|| repo_entity.clone())
2522                })
2523        })?;
2524
2525        if let Some(wt_repo) = wt_repo {
2526            // Mixed reset HEAD~ — undoes the second WIP commit, putting
2527            // previously-unstaged/untracked files back as unstaged.
2528            let mixed_reset = wt_repo.update(cx, |repo, cx| {
2529                repo.reset("HEAD~".to_string(), ResetMode::Mixed, cx)
2530            });
2531            match mixed_reset.await {
2532                Ok(Ok(())) => {}
2533                Ok(Err(err)) => {
2534                    log::warn!("Failed to mixed-reset: {err}");
2535                    let undo = wt_repo.update(cx, |repo, cx| {
2536                        repo.reset(commit_hash.clone(), ResetMode::Mixed, cx)
2537                    });
2538                    let _ = undo.await;
2539                    anyhow::bail!("Failed to restore staging state: {err}");
2540                }
2541                Err(_) => anyhow::bail!("Mixed reset was canceled"),
2542            }
2543
2544            // Soft reset HEAD~ — undoes the first WIP commit, leaving
2545            // originally-staged files in the index.
2546            let soft_reset = wt_repo.update(cx, |repo, cx| {
2547                repo.reset("HEAD~".to_string(), ResetMode::Soft, cx)
2548            });
2549            match soft_reset.await {
2550                Ok(Ok(())) => {}
2551                Ok(Err(err)) => {
2552                    log::warn!("Failed to soft-reset: {err}");
2553                    let undo = wt_repo.update(cx, |repo, cx| {
2554                        repo.reset(commit_hash.clone(), ResetMode::Mixed, cx)
2555                    });
2556                    let _ = undo.await;
2557                    anyhow::bail!("Failed to restore staging state: {err}");
2558                }
2559                Err(_) => anyhow::bail!("Soft reset was canceled"),
2560            }
2561
2562            // Switch to the original branch if one was recorded.
2563            if let Some(branch_name) = &row.branch_name {
2564                let switch =
2565                    wt_repo.update(cx, |repo, _cx| repo.change_branch(branch_name.clone()));
2566                match switch.await {
2567                    Ok(Ok(())) => {}
2568                    _ => {
2569                        let create = wt_repo.update(cx, |repo, _cx| {
2570                            repo.create_branch(branch_name.clone(), None)
2571                        });
2572                        if let Ok(Err(_)) | Err(_) = create.await {
2573                            log::warn!(
2574                                "Could not switch to branch '{branch_name}' — \
2575                                 the restored worktree is in detached HEAD state."
2576                            );
2577                        }
2578                    }
2579                }
2580            }
2581        } else {
2582            log::warn!(
2583                "Worktree repository entity not found after scan — \
2584                 skipping staging restoration for {}",
2585                final_path.display()
2586            );
2587        }
2588
2589        Ok(final_path)
2590    }
2591
2592    async fn cleanup_archived_worktree_record(
2593        row: &ArchivedGitWorktree,
2594        project: &Entity<project::Project>,
2595        store: &Entity<ThreadMetadataStore>,
2596        cx: &mut AsyncWindowContext,
2597    ) {
2598        let main_repo_path = &row.main_repo_path;
2599        let main_repo = cx.update(|_window, cx| {
2600            project
2601                .read(cx)
2602                .repositories(cx)
2603                .values()
2604                .find_map(|repo_entity| {
2605                    let repo = repo_entity.read(cx);
2606                    (repo.is_main_worktree() && *repo.work_directory_abs_path == *main_repo_path)
2607                        .then(|| repo_entity.clone())
2608                })
2609        });
2610        if let Ok(Some(main_repo)) = main_repo {
2611            let ref_name = archived_worktree_ref_name(row.id);
2612            let receiver = main_repo.update(cx, |repo, _cx| repo.delete_ref(ref_name));
2613            match receiver.await {
2614                Ok(Ok(())) => {}
2615                Ok(Err(err)) => log::warn!("Failed to delete archive ref: {err}"),
2616                Err(_) => log::warn!("Archive ref deletion was canceled"),
2617            }
2618        }
2619
2620        store
2621            .update(cx, |store, cx| store.delete_archived_worktree(row.id, cx))
2622            .await
2623            .log_err();
2624    }
2625
2626    fn expand_selected_entry(
2627        &mut self,
2628        _: &SelectChild,
2629        _window: &mut Window,
2630        cx: &mut Context<Self>,
2631    ) {
2632        let Some(ix) = self.selection else { return };
2633
2634        match self.contents.entries.get(ix) {
2635            Some(ListEntry::ProjectHeader { path_list, .. }) => {
2636                if self.collapsed_groups.contains(path_list) {
2637                    let path_list = path_list.clone();
2638                    self.collapsed_groups.remove(&path_list);
2639                    self.update_entries(cx);
2640                } else if ix + 1 < self.contents.entries.len() {
2641                    self.selection = Some(ix + 1);
2642                    self.list_state.scroll_to_reveal_item(ix + 1);
2643                    cx.notify();
2644                }
2645            }
2646            _ => {}
2647        }
2648    }
2649
2650    fn collapse_selected_entry(
2651        &mut self,
2652        _: &SelectParent,
2653        _window: &mut Window,
2654        cx: &mut Context<Self>,
2655    ) {
2656        let Some(ix) = self.selection else { return };
2657
2658        match self.contents.entries.get(ix) {
2659            Some(ListEntry::ProjectHeader { path_list, .. }) => {
2660                if !self.collapsed_groups.contains(path_list) {
2661                    let path_list = path_list.clone();
2662                    self.collapsed_groups.insert(path_list);
2663                    self.update_entries(cx);
2664                }
2665            }
2666            Some(
2667                ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
2668            ) => {
2669                for i in (0..ix).rev() {
2670                    if let Some(ListEntry::ProjectHeader { path_list, .. }) =
2671                        self.contents.entries.get(i)
2672                    {
2673                        let path_list = path_list.clone();
2674                        self.selection = Some(i);
2675                        self.collapsed_groups.insert(path_list);
2676                        self.update_entries(cx);
2677                        break;
2678                    }
2679                }
2680            }
2681            None => {}
2682        }
2683    }
2684
2685    fn toggle_selected_fold(
2686        &mut self,
2687        _: &editor::actions::ToggleFold,
2688        _window: &mut Window,
2689        cx: &mut Context<Self>,
2690    ) {
2691        let Some(ix) = self.selection else { return };
2692
2693        // Find the group header for the current selection.
2694        let header_ix = match self.contents.entries.get(ix) {
2695            Some(ListEntry::ProjectHeader { .. }) => Some(ix),
2696            Some(
2697                ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
2698            ) => (0..ix).rev().find(|&i| {
2699                matches!(
2700                    self.contents.entries.get(i),
2701                    Some(ListEntry::ProjectHeader { .. })
2702                )
2703            }),
2704            None => None,
2705        };
2706
2707        if let Some(header_ix) = header_ix {
2708            if let Some(ListEntry::ProjectHeader { path_list, .. }) =
2709                self.contents.entries.get(header_ix)
2710            {
2711                let path_list = path_list.clone();
2712                if self.collapsed_groups.contains(&path_list) {
2713                    self.collapsed_groups.remove(&path_list);
2714                } else {
2715                    self.selection = Some(header_ix);
2716                    self.collapsed_groups.insert(path_list);
2717                }
2718                self.update_entries(cx);
2719            }
2720        }
2721    }
2722
2723    fn fold_all(
2724        &mut self,
2725        _: &editor::actions::FoldAll,
2726        _window: &mut Window,
2727        cx: &mut Context<Self>,
2728    ) {
2729        for entry in &self.contents.entries {
2730            if let ListEntry::ProjectHeader { path_list, .. } = entry {
2731                self.collapsed_groups.insert(path_list.clone());
2732            }
2733        }
2734        self.update_entries(cx);
2735    }
2736
2737    fn unfold_all(
2738        &mut self,
2739        _: &editor::actions::UnfoldAll,
2740        _window: &mut Window,
2741        cx: &mut Context<Self>,
2742    ) {
2743        self.collapsed_groups.clear();
2744        self.update_entries(cx);
2745    }
2746
2747    fn stop_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
2748        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2749            return;
2750        };
2751
2752        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
2753        for workspace in workspaces {
2754            if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2755                let cancelled =
2756                    agent_panel.update(cx, |panel, cx| panel.cancel_thread(session_id, cx));
2757                if cancelled {
2758                    return;
2759                }
2760            }
2761        }
2762    }
2763
2764    fn archive_thread(
2765        &mut self,
2766        session_id: &acp::SessionId,
2767        window: &mut Window,
2768        cx: &mut Context<Self>,
2769    ) {
2770        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.archive(session_id, cx));
2771
2772        // If we're archiving the currently focused thread, move focus to the
2773        // nearest thread within the same project group. We never cross group
2774        // boundaries — if the group has no other threads, clear focus and open
2775        // a blank new thread in the panel instead.
2776        if self
2777            .active_entry
2778            .as_ref()
2779            .is_some_and(|e| e.is_active_thread(session_id))
2780        {
2781            let current_pos = self.contents.entries.iter().position(|entry| {
2782                matches!(entry, ListEntry::Thread(t) if &t.metadata.session_id == session_id)
2783            });
2784
2785            // Find the workspace that owns this thread's project group by
2786            // walking backwards to the nearest ProjectHeader. We must use
2787            // *this* workspace (not the active workspace) because the user
2788            // might be archiving a thread in a non-active group.
2789            let group_workspace = current_pos.and_then(|pos| {
2790                self.contents.entries[..pos]
2791                    .iter()
2792                    .rev()
2793                    .find_map(|e| match e {
2794                        ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
2795                        _ => None,
2796                    })
2797            });
2798
2799            let next_thread = current_pos.and_then(|pos| {
2800                let group_start = self.contents.entries[..pos]
2801                    .iter()
2802                    .rposition(|e| matches!(e, ListEntry::ProjectHeader { .. }))
2803                    .map_or(0, |i| i + 1);
2804                let group_end = self.contents.entries[pos + 1..]
2805                    .iter()
2806                    .position(|e| matches!(e, ListEntry::ProjectHeader { .. }))
2807                    .map_or(self.contents.entries.len(), |i| pos + 1 + i);
2808
2809                let above = self.contents.entries[group_start..pos]
2810                    .iter()
2811                    .rev()
2812                    .find_map(|entry| {
2813                        if let ListEntry::Thread(t) = entry {
2814                            Some(t)
2815                        } else {
2816                            None
2817                        }
2818                    });
2819
2820                above.or_else(|| {
2821                    self.contents.entries[pos + 1..group_end]
2822                        .iter()
2823                        .find_map(|entry| {
2824                            if let ListEntry::Thread(t) = entry {
2825                                Some(t)
2826                            } else {
2827                                None
2828                            }
2829                        })
2830                })
2831            });
2832
2833            if let Some(next) = next_thread {
2834                let next_metadata = next.metadata.clone();
2835                // Use the thread's own workspace when it has one open (e.g. an absorbed
2836                // linked worktree thread that appears under the main workspace's header
2837                // but belongs to its own workspace). Loading into the wrong panel binds
2838                // the thread to the wrong project, which corrupts its stored folder_paths
2839                // when metadata is saved via ThreadMetadata::from_thread.
2840                let target_workspace = match &next.workspace {
2841                    ThreadEntryWorkspace::Open(ws) => Some(ws.clone()),
2842                    ThreadEntryWorkspace::Closed(_) => group_workspace,
2843                };
2844                if let Some(ref ws) = target_workspace {
2845                    self.active_entry = Some(ActiveEntry::Thread {
2846                        session_id: next_metadata.session_id.clone(),
2847                        workspace: ws.clone(),
2848                    });
2849                }
2850                self.record_thread_access(&next_metadata.session_id);
2851
2852                if let Some(workspace) = target_workspace {
2853                    if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2854                        agent_panel.update(cx, |panel, cx| {
2855                            panel.load_agent_thread(
2856                                Agent::from(next_metadata.agent_id.clone()),
2857                                next_metadata.session_id.clone(),
2858                                Some(next_metadata.folder_paths.clone()),
2859                                Some(next_metadata.title.clone()),
2860                                true,
2861                                window,
2862                                cx,
2863                            );
2864                        });
2865                    }
2866                }
2867            } else {
2868                if let Some(workspace) = &group_workspace {
2869                    self.active_entry = Some(ActiveEntry::Draft(workspace.clone()));
2870                    if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2871                        agent_panel.update(cx, |panel, cx| {
2872                            panel.new_thread(&NewThread, window, cx);
2873                        });
2874                    }
2875                }
2876            }
2877        }
2878
2879        self.maybe_delete_git_worktree_for_archived_thread(session_id, window, cx);
2880    }
2881
2882    /// If the thread being archived is associated with a linked git worktree,
2883    /// link it to an archived worktree record. If this is the last thread on
2884    /// that worktree, create a WIP commit, anchor it with a git ref, and
2885    /// delete the worktree.
2886    fn maybe_delete_git_worktree_for_archived_thread(
2887        &mut self,
2888        session_id: &acp::SessionId,
2889        window: &mut Window,
2890        cx: &mut Context<Self>,
2891    ) {
2892        let folder_paths = self.contents.entries.iter().find_map(|entry| {
2893            if let ListEntry::Thread(t) = entry {
2894                if &t.metadata.session_id == session_id {
2895                    return Some(match &t.workspace {
2896                        ThreadEntryWorkspace::Open(ws) => workspace_path_list(ws, cx),
2897                        ThreadEntryWorkspace::Closed(path_list) => path_list.clone(),
2898                    });
2899                }
2900            }
2901            None
2902        });
2903
2904        let Some(folder_paths) = folder_paths else {
2905            return;
2906        };
2907
2908        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2909            return;
2910        };
2911        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
2912
2913        let store_entity = ThreadMetadataStore::global(cx);
2914        let is_last_thread = !store_entity
2915            .read(cx)
2916            .entries_for_path(&folder_paths)
2917            .any(|entry| &entry.session_id != session_id);
2918
2919        // Collect info for each path that is a linked git worktree.
2920        #[allow(clippy::type_complexity)]
2921        let mut linked_worktrees: Vec<(
2922            Entity<git_store::Repository>,
2923            PathBuf,
2924            Option<String>,
2925            std::sync::Arc<std::path::Path>,
2926            Option<Entity<git_store::Repository>>,
2927            Option<Entity<Workspace>>,
2928        )> = Vec::new();
2929        for worktree_path in folder_paths.paths() {
2930            if let Some(info) = workspaces.iter().find_map(|workspace| {
2931                let project = workspace.read(cx).project().clone();
2932                project
2933                    .read(cx)
2934                    .repositories(cx)
2935                    .values()
2936                    .find_map(|repo_entity| {
2937                        let snapshot = repo_entity.read(cx).snapshot();
2938                        if snapshot.is_linked_worktree()
2939                            && *snapshot.work_directory_abs_path == *worktree_path
2940                        {
2941                            let branch_name =
2942                                snapshot.branch.as_ref().map(|b| b.name().to_string());
2943                            let main_repo_path = snapshot.original_repo_abs_path;
2944                            let main_repo =
2945                                find_main_repo_in_workspaces(&workspaces, &main_repo_path, cx);
2946                            Some((
2947                                repo_entity.clone(),
2948                                worktree_path.clone(),
2949                                branch_name,
2950                                main_repo_path,
2951                                main_repo,
2952                                Some(workspace.clone()),
2953                            ))
2954                        } else {
2955                            None
2956                        }
2957                    })
2958            }) {
2959                linked_worktrees.push(info);
2960            }
2961        }
2962
2963        if linked_worktrees.is_empty() {
2964            return;
2965        }
2966
2967        let fs = <dyn fs::Fs>::global(cx);
2968
2969        for (
2970            worktree_repo,
2971            worktree_path,
2972            branch_name,
2973            main_repo_path,
2974            main_repo,
2975            worktree_workspace,
2976        ) in linked_worktrees
2977        {
2978            let session_id = session_id.clone();
2979            let folder_paths = folder_paths.clone();
2980            let fs = fs.clone();
2981            let worktree_path_key = worktree_path.clone();
2982            let cleanup_key = worktree_path_key.clone();
2983
2984            let task = cx.spawn_in(window, async move |this, cx| {
2985                let result = Self::archive_single_worktree(
2986                    worktree_repo,
2987                    worktree_path,
2988                    branch_name,
2989                    main_repo_path,
2990                    main_repo,
2991                    worktree_workspace,
2992                    is_last_thread,
2993                    session_id,
2994                    folder_paths,
2995                    fs,
2996                    this.clone(),
2997                    cx,
2998                )
2999                .await;
3000                this.update_in(cx, |sidebar, _window, _cx| {
3001                    sidebar.pending_worktree_archives.remove(&cleanup_key);
3002                })
3003                .log_err();
3004                result
3005            });
3006            self.pending_worktree_archives
3007                .insert(worktree_path_key, task);
3008        }
3009    }
3010
3011    async fn archive_single_worktree(
3012        worktree_repo: Entity<git_store::Repository>,
3013        worktree_path: PathBuf,
3014        branch_name: Option<String>,
3015        main_repo_path: std::sync::Arc<std::path::Path>,
3016        main_repo: Option<Entity<git_store::Repository>>,
3017        worktree_workspace: Option<Entity<Workspace>>,
3018        is_last_thread: bool,
3019        session_id: acp::SessionId,
3020        folder_paths: PathList,
3021        fs: std::sync::Arc<dyn fs::Fs>,
3022        sidebar: WeakEntity<Sidebar>,
3023        cx: &mut AsyncWindowContext,
3024    ) -> anyhow::Result<()> {
3025        if !is_last_thread {
3026            return anyhow::Ok(());
3027        }
3028
3029        let store = cx.update(|_window, cx| ThreadMetadataStore::global(cx))?;
3030
3031        // Re-check inside the async block to narrow the TOCTOU window:
3032        // another thread on the same worktree may have been un-archived
3033        // (or a new one created) between the synchronous check and here.
3034        // Note: this does not fully close the race — state can still change
3035        // at subsequent await points during the commit/archive sequence.
3036        let still_last_thread = store.update(cx, |store, _cx| {
3037            !store
3038                .entries_for_path(&folder_paths)
3039                .any(|entry| &entry.session_id != &session_id)
3040        });
3041        if !still_last_thread {
3042            return anyhow::Ok(());
3043        }
3044
3045        // Helper: unarchive the thread so it reappears in the sidebar.
3046        let unarchive = |cx: &mut AsyncWindowContext| {
3047            store.update(cx, |store, cx| {
3048                store.unarchive(&session_id, cx);
3049            });
3050        };
3051
3052        // Helper: undo both WIP commits on the worktree.
3053        let undo_wip_commits = |cx: &mut AsyncWindowContext| {
3054            let reset_receiver = worktree_repo.update(cx, |repo, cx| {
3055                repo.reset("HEAD~2".to_string(), ResetMode::Mixed, cx)
3056            });
3057            async move {
3058                match reset_receiver.await {
3059                    Ok(Ok(())) => true,
3060                    Ok(Err(err)) => {
3061                        log::error!("Failed to undo WIP commits: {err}");
3062                        false
3063                    }
3064                    Err(_) => {
3065                        log::error!("WIP commit undo was canceled");
3066                        false
3067                    }
3068                }
3069            }
3070        };
3071
3072        // We create two commits to preserve the original staging state:
3073        //   1. Commit whatever is currently staged (allow-empty).
3074        //   2. Stage everything (including untracked), commit again (allow-empty).
3075        //
3076        // On restore, two resets undo this:
3077        //   1. `git reset --mixed HEAD~`  — undoes commit 2, puts
3078        //      previously-unstaged/untracked files back as unstaged.
3079        //   2. `git reset --soft HEAD~`   — undoes commit 1, leaves
3080        //      the index as-is so originally-staged files stay staged.
3081        //
3082        // If any step in this sequence fails, we undo everything and
3083        // bail out.
3084
3085        // Step 1: commit whatever is currently staged.
3086        let askpass = AskPassDelegate::new(cx, |_, _, _| {});
3087        let first_commit_result = worktree_repo.update(cx, |repo, cx| {
3088            repo.commit(
3089                "WIP staged".into(),
3090                None,
3091                CommitOptions {
3092                    allow_empty: true,
3093                    ..Default::default()
3094                },
3095                askpass,
3096                cx,
3097            )
3098        });
3099        let first_commit_ok = match first_commit_result.await {
3100            Ok(Ok(())) => true,
3101            Ok(Err(err)) => {
3102                log::error!("Failed to create first WIP commit (staged): {err}");
3103                false
3104            }
3105            Err(_) => {
3106                log::error!("First WIP commit was canceled");
3107                false
3108            }
3109        };
3110
3111        // Step 2: stage everything including untracked, then commit.
3112        // If anything fails after the first commit, undo it and bail.
3113        let commit_ok = if first_commit_ok {
3114            let stage_result =
3115                worktree_repo.update(cx, |repo, _cx| repo.stage_all_including_untracked());
3116            let stage_ok = match stage_result.await {
3117                Ok(Ok(())) => true,
3118                Ok(Err(err)) => {
3119                    log::error!("Failed to stage worktree files: {err}");
3120                    false
3121                }
3122                Err(_) => {
3123                    log::error!("Stage operation was canceled");
3124                    false
3125                }
3126            };
3127
3128            if !stage_ok {
3129                let undo = worktree_repo.update(cx, |repo, cx| {
3130                    repo.reset("HEAD~".to_string(), ResetMode::Mixed, cx)
3131                });
3132                match undo.await {
3133                    Ok(Ok(())) => {}
3134                    Ok(Err(err)) => log::error!("Failed to undo first WIP commit: {err}"),
3135                    Err(_) => log::error!("Undo of first WIP commit was canceled"),
3136                }
3137                false
3138            } else {
3139                let askpass = AskPassDelegate::new(cx, |_, _, _| {});
3140                let second_commit_result = worktree_repo.update(cx, |repo, cx| {
3141                    repo.commit(
3142                        "WIP unstaged".into(),
3143                        None,
3144                        CommitOptions {
3145                            allow_empty: true,
3146                            ..Default::default()
3147                        },
3148                        askpass,
3149                        cx,
3150                    )
3151                });
3152                match second_commit_result.await {
3153                    Ok(Ok(())) => true,
3154                    Ok(Err(err)) => {
3155                        log::error!("Failed to create second WIP commit (unstaged): {err}");
3156                        let undo = worktree_repo.update(cx, |repo, cx| {
3157                            repo.reset("HEAD~".to_string(), ResetMode::Mixed, cx)
3158                        });
3159                        match undo.await {
3160                            Ok(Ok(())) => {}
3161                            Ok(Err(err)) => {
3162                                log::error!("Failed to undo first WIP commit: {err}")
3163                            }
3164                            Err(_) => {
3165                                log::error!("Undo of first WIP commit was canceled")
3166                            }
3167                        }
3168                        false
3169                    }
3170                    Err(_) => {
3171                        log::error!("Second WIP commit was canceled");
3172                        let undo = worktree_repo.update(cx, |repo, cx| {
3173                            repo.reset("HEAD~".to_string(), ResetMode::Mixed, cx)
3174                        });
3175                        match undo.await {
3176                            Ok(Ok(())) => {}
3177                            Ok(Err(err)) => {
3178                                log::error!("Failed to undo first WIP commit: {err}")
3179                            }
3180                            Err(_) => {
3181                                log::error!("Undo of first WIP commit was canceled")
3182                            }
3183                        }
3184                        false
3185                    }
3186                }
3187            }
3188        } else {
3189            false
3190        };
3191
3192        let worktree_path_str = worktree_path.to_string_lossy().to_string();
3193        let main_repo_path_str = main_repo_path.to_string_lossy().to_string();
3194        let worktree_dir_name = worktree_path
3195            .file_name()
3196            .map(|n| n.to_string_lossy().to_string())
3197            .unwrap_or_else(|| worktree_path_str.clone());
3198
3199        let mut archived_worktree_id: Option<i64> = None;
3200
3201        if !commit_ok {
3202            // Show a prompt asking the user what to do.
3203            let answer = cx.prompt(
3204                PromptLevel::Warning,
3205                "Failed to save worktree state",
3206                Some(
3207                    "Could not create a WIP commit for this worktree. \
3208                     If you proceed, the worktree will be deleted and \
3209                     unarchiving this thread later will not restore the \
3210                     filesystem to its previous state.\n\n\
3211                     Cancel to keep the worktree on disk so you can \
3212                     resolve the issue manually.",
3213                ),
3214                &["Delete Anyway", "Cancel"],
3215            );
3216
3217            match answer.await {
3218                Ok(0) => {
3219                    // "Delete Anyway" — proceed to worktree deletion
3220                    // without a WIP commit or DB record.
3221                }
3222                _ => {
3223                    // "Cancel" — undo the archive so the thread
3224                    // reappears in the sidebar.
3225                    unarchive(cx);
3226                    return anyhow::Ok(());
3227                }
3228            }
3229        } else {
3230            // Commit succeeded — get hash, create archived worktree row, create ref.
3231            let head_sha_result = worktree_repo.update(cx, |repo, _cx| repo.head_sha());
3232            let commit_hash = match head_sha_result.await {
3233                Ok(Ok(Some(sha))) => sha,
3234                sha_result => {
3235                    let reason = match &sha_result {
3236                        Ok(Ok(None)) => "HEAD SHA is None".into(),
3237                        Ok(Err(err)) => format!("Failed to get HEAD SHA: {err}"),
3238                        Err(_) => "HEAD SHA operation was canceled".into(),
3239                        Ok(Ok(Some(_))) => unreachable!(),
3240                    };
3241                    log::error!(
3242                        "{reason} after WIP commits; attempting to undo\n\
3243                         worktree path: {worktree_path_str}\n\
3244                         main repo path: {main_repo_path_str}\n\
3245                         step: reading HEAD SHA after WIP commits",
3246                    );
3247                    let undo_ok = undo_wip_commits(cx).await;
3248                    unarchive(cx);
3249                    if !undo_ok {
3250                        log::error!(
3251                            "Failed to undo WIP commits during rollback for worktree: {worktree_path_str}"
3252                        );
3253                    }
3254                    let worktree_dir_name = worktree_dir_name.clone();
3255                    cx.update(|window, cx| {
3256                        if let Some(workspace) = Workspace::for_window(window, cx) {
3257                            workspace.update(cx, |workspace, cx| {
3258                                let toast = StatusToast::new(
3259                                    format!("Failed to archive worktree \"{worktree_dir_name}\""),
3260                                    cx,
3261                                    |this, _cx| {
3262                                        this.icon(
3263                                            ToastIcon::new(IconName::XCircle).color(Color::Error),
3264                                        )
3265                                    },
3266                                );
3267                                workspace.toggle_status_toast(toast, cx);
3268                            });
3269                        }
3270                    })
3271                    .ok();
3272                    return anyhow::Ok(());
3273                }
3274            };
3275
3276            let create_result = store
3277                .update(cx, |store, cx| {
3278                    store.create_archived_worktree(
3279                        worktree_path_str.clone(),
3280                        main_repo_path_str.clone(),
3281                        branch_name,
3282                        commit_hash.clone(),
3283                        cx,
3284                    )
3285                })
3286                .await;
3287
3288            match create_result {
3289                Ok(id) => {
3290                    // Link all threads on this worktree (including
3291                    // previously archived ones) to the archived
3292                    // worktree record so any of them can trigger a
3293                    // restore later.
3294                    let session_ids: Vec<String> = store.update(cx, |store, _cx| {
3295                        store
3296                            .all_session_ids_for_path(&folder_paths)
3297                            .map(|s| s.0.to_string())
3298                            .collect()
3299                    });
3300                    let mut link_result: anyhow::Result<()> = Ok(());
3301                    for sid in session_ids {
3302                        let result = store
3303                            .update(cx, |store, cx| {
3304                                store.link_thread_to_archived_worktree(sid, id, cx)
3305                            })
3306                            .await;
3307                        if let Err(err) = result {
3308                            link_result = Err(err);
3309                            break;
3310                        }
3311                    }
3312
3313                    if let Err(err) = link_result {
3314                        log::error!(
3315                            "Failed to link thread to archived worktree: {err}\n\
3316                             worktree path: {worktree_path_str}\n\
3317                             main repo path: {main_repo_path_str}\n\
3318                             step: linking thread to archived worktree record",
3319                        );
3320                        store
3321                            .update(cx, |store, cx| store.delete_archived_worktree(id, cx))
3322                            .await
3323                            .log_err();
3324                        let undo_ok = undo_wip_commits(cx).await;
3325                        unarchive(cx);
3326                        if !undo_ok {
3327                            log::error!(
3328                                "Failed to undo WIP commits during rollback for worktree: {worktree_path_str}"
3329                            );
3330                        }
3331                        let worktree_dir_name = worktree_dir_name.clone();
3332                        cx.update(|window, cx| {
3333                            if let Some(workspace) = Workspace::for_window(window, cx) {
3334                                workspace.update(cx, |workspace, cx| {
3335                                    let toast = StatusToast::new(
3336                                        format!(
3337                                            "Failed to archive worktree \"{worktree_dir_name}\""
3338                                        ),
3339                                        cx,
3340                                        |this, _cx| {
3341                                            this.icon(
3342                                                ToastIcon::new(IconName::XCircle)
3343                                                    .color(Color::Error),
3344                                            )
3345                                        },
3346                                    );
3347                                    workspace.toggle_status_toast(toast, cx);
3348                                });
3349                            }
3350                        })
3351                        .ok();
3352                        return anyhow::Ok(());
3353                    }
3354
3355                    archived_worktree_id = Some(id);
3356
3357                    // Create a git ref on the main repo (non-fatal if
3358                    // this fails — the commit hash is in the DB).
3359                    if let Some(main_repo) = &main_repo {
3360                        let ref_name = archived_worktree_ref_name(id);
3361                        let ref_result = main_repo
3362                            .update(cx, |repo, _cx| repo.update_ref(ref_name, commit_hash));
3363                        match ref_result.await {
3364                            Ok(Ok(())) => {}
3365                            Ok(Err(err)) => {
3366                                log::warn!("Failed to create archive ref: {err}")
3367                            }
3368                            Err(_) => log::warn!("Archive ref creation was canceled"),
3369                        }
3370                    }
3371                }
3372                Err(err) => {
3373                    log::error!(
3374                        "Failed to create archived worktree record: {err}\n\
3375                         worktree path: {worktree_path_str}\n\
3376                         main repo path: {main_repo_path_str}\n\
3377                         step: creating archived worktree database record",
3378                    );
3379                    let undo_ok = undo_wip_commits(cx).await;
3380                    unarchive(cx);
3381                    if !undo_ok {
3382                        log::error!(
3383                            "Failed to undo WIP commits during rollback for worktree: {worktree_path_str}"
3384                        );
3385                    }
3386                    let worktree_dir_name = worktree_dir_name.clone();
3387                    cx.update(|window, cx| {
3388                        if let Some(workspace) = Workspace::for_window(window, cx) {
3389                            workspace.update(cx, |workspace, cx| {
3390                                let toast = StatusToast::new(
3391                                    format!("Failed to archive worktree \"{worktree_dir_name}\""),
3392                                    cx,
3393                                    |this, _cx| {
3394                                        this.icon(
3395                                            ToastIcon::new(IconName::XCircle).color(Color::Error),
3396                                        )
3397                                    },
3398                                );
3399                                workspace.toggle_status_toast(toast, cx);
3400                            });
3401                        }
3402                    })
3403                    .ok();
3404                    return anyhow::Ok(());
3405                }
3406            }
3407        }
3408
3409        // Remove the worktree's workspace from the MultiWorkspace before
3410        // deleting the directory, so the file watcher stops scanning it.
3411        if let Some(worktree_workspace) = &worktree_workspace {
3412            sidebar
3413                .update_in(cx, |sidebar, window, cx| {
3414                    if let Some(multi_workspace) = sidebar.multi_workspace.upgrade() {
3415                        multi_workspace.update(cx, |mw, cx| {
3416                            mw.remove(worktree_workspace, window, cx);
3417                        });
3418                    }
3419                })
3420                .ok();
3421        }
3422
3423        // Delete the directory first (it may contain uncommitted files that
3424        // prevent `git worktree remove` from succeeding even with --force),
3425        // then clean up git's worktree registration.
3426        let dir_removed = fs
3427            .remove_dir(
3428                &worktree_path,
3429                fs::RemoveOptions {
3430                    recursive: true,
3431                    ignore_if_not_exists: true,
3432                },
3433            )
3434            .await
3435            .is_ok();
3436
3437        if dir_removed {
3438            if let Some(main_repo) = &main_repo {
3439                let receiver = main_repo.update(cx, |repo, _cx| {
3440                    repo.remove_worktree(worktree_path.clone(), true)
3441                });
3442                match receiver.await {
3443                    Ok(Ok(())) => {}
3444                    Ok(Err(err)) => {
3445                        log::warn!("git worktree remove failed after directory deletion: {err}");
3446                    }
3447                    Err(_) => {
3448                        log::warn!("git worktree remove was canceled after directory deletion");
3449                    }
3450                }
3451            }
3452        }
3453
3454        if !dir_removed {
3455            let undo_ok = if commit_ok {
3456                undo_wip_commits(cx).await
3457            } else {
3458                true
3459            };
3460            if let Some(id) = archived_worktree_id {
3461                if let Some(main_repo) = &main_repo {
3462                    let ref_name = archived_worktree_ref_name(id);
3463                    let receiver = main_repo.update(cx, |repo, _cx| repo.delete_ref(ref_name));
3464                    if let Ok(result) = receiver.await {
3465                        result.log_err();
3466                    }
3467                }
3468                store
3469                    .update(cx, |store, cx| store.delete_archived_worktree(id, cx))
3470                    .await
3471                    .log_err();
3472            }
3473            unarchive(cx);
3474            if !undo_ok {
3475                log::error!(
3476                    "Failed to undo WIP commits during rollback for worktree: {worktree_path_str}"
3477                );
3478            }
3479            log::error!(
3480                "Failed to delete worktree directory from disk\n\
3481                 worktree path: {worktree_path_str}\n\
3482                 main repo path: {main_repo_path_str}\n\
3483                 step: removing worktree directory",
3484            );
3485            cx.update(|window, cx| {
3486                if let Some(workspace) = Workspace::for_window(window, cx) {
3487                    workspace.update(cx, |workspace, cx| {
3488                        let toast = StatusToast::new(
3489                            format!("Failed to delete worktree \"{worktree_dir_name}\""),
3490                            cx,
3491                            |this, _cx| {
3492                                this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
3493                            },
3494                        );
3495                        workspace.toggle_status_toast(toast, cx);
3496                    });
3497                }
3498            })
3499            .ok();
3500        }
3501
3502        anyhow::Ok(())
3503    }
3504
3505    fn remove_selected_thread(
3506        &mut self,
3507        _: &RemoveSelectedThread,
3508        window: &mut Window,
3509        cx: &mut Context<Self>,
3510    ) {
3511        let Some(ix) = self.selection else {
3512            return;
3513        };
3514        let Some(ListEntry::Thread(thread)) = self.contents.entries.get(ix) else {
3515            return;
3516        };
3517        match thread.status {
3518            AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation => return,
3519            AgentThreadStatus::Completed | AgentThreadStatus::Error => {}
3520        }
3521
3522        let session_id = thread.metadata.session_id.clone();
3523        self.archive_thread(&session_id, window, cx)
3524    }
3525
3526    fn record_thread_access(&mut self, session_id: &acp::SessionId) {
3527        self.thread_last_accessed
3528            .insert(session_id.clone(), Utc::now());
3529    }
3530
3531    fn record_thread_message_sent(&mut self, session_id: &acp::SessionId) {
3532        self.thread_last_message_sent_or_queued
3533            .insert(session_id.clone(), Utc::now());
3534    }
3535
3536    fn mru_threads_for_switcher(&self, _cx: &App) -> Vec<ThreadSwitcherEntry> {
3537        let mut current_header_label: Option<SharedString> = None;
3538        let mut current_header_workspace: Option<Entity<Workspace>> = None;
3539        let mut entries: Vec<ThreadSwitcherEntry> = self
3540            .contents
3541            .entries
3542            .iter()
3543            .filter_map(|entry| match entry {
3544                ListEntry::ProjectHeader {
3545                    label, workspace, ..
3546                } => {
3547                    current_header_label = Some(label.clone());
3548                    current_header_workspace = Some(workspace.clone());
3549                    None
3550                }
3551                ListEntry::Thread(thread) => {
3552                    let workspace = match &thread.workspace {
3553                        ThreadEntryWorkspace::Open(workspace) => workspace.clone(),
3554                        ThreadEntryWorkspace::Closed(_) => {
3555                            current_header_workspace.as_ref()?.clone()
3556                        }
3557                    };
3558                    let notified = self
3559                        .contents
3560                        .is_thread_notified(&thread.metadata.session_id);
3561                    let timestamp: SharedString = format_history_entry_timestamp(
3562                        self.thread_last_message_sent_or_queued
3563                            .get(&thread.metadata.session_id)
3564                            .copied()
3565                            .or(thread.metadata.created_at)
3566                            .unwrap_or(thread.metadata.updated_at),
3567                    )
3568                    .into();
3569                    Some(ThreadSwitcherEntry {
3570                        session_id: thread.metadata.session_id.clone(),
3571                        title: thread.metadata.title.clone(),
3572                        icon: thread.icon,
3573                        icon_from_external_svg: thread.icon_from_external_svg.clone(),
3574                        status: thread.status,
3575                        metadata: thread.metadata.clone(),
3576                        workspace,
3577                        project_name: current_header_label.clone(),
3578                        worktrees: thread
3579                            .worktrees
3580                            .iter()
3581                            .map(|wt| ThreadItemWorktreeInfo {
3582                                name: wt.name.clone(),
3583                                full_path: wt.full_path.clone(),
3584                                highlight_positions: Vec::new(),
3585                            })
3586                            .collect(),
3587                        diff_stats: thread.diff_stats,
3588                        is_title_generating: thread.is_title_generating,
3589                        notified,
3590                        timestamp,
3591                    })
3592                }
3593                _ => None,
3594            })
3595            .collect();
3596
3597        entries.sort_by(|a, b| {
3598            let a_accessed = self.thread_last_accessed.get(&a.session_id);
3599            let b_accessed = self.thread_last_accessed.get(&b.session_id);
3600
3601            match (a_accessed, b_accessed) {
3602                (Some(a_time), Some(b_time)) => b_time.cmp(a_time),
3603                (Some(_), None) => std::cmp::Ordering::Less,
3604                (None, Some(_)) => std::cmp::Ordering::Greater,
3605                (None, None) => {
3606                    let a_sent = self.thread_last_message_sent_or_queued.get(&a.session_id);
3607                    let b_sent = self.thread_last_message_sent_or_queued.get(&b.session_id);
3608
3609                    match (a_sent, b_sent) {
3610                        (Some(a_time), Some(b_time)) => b_time.cmp(a_time),
3611                        (Some(_), None) => std::cmp::Ordering::Less,
3612                        (None, Some(_)) => std::cmp::Ordering::Greater,
3613                        (None, None) => {
3614                            let a_time = a.metadata.created_at.or(Some(a.metadata.updated_at));
3615                            let b_time = b.metadata.created_at.or(Some(b.metadata.updated_at));
3616                            b_time.cmp(&a_time)
3617                        }
3618                    }
3619                }
3620            }
3621        });
3622
3623        entries
3624    }
3625
3626    fn dismiss_thread_switcher(&mut self, cx: &mut Context<Self>) {
3627        self.thread_switcher = None;
3628        self._thread_switcher_subscriptions.clear();
3629        if let Some(mw) = self.multi_workspace.upgrade() {
3630            mw.update(cx, |mw, cx| {
3631                mw.set_sidebar_overlay(None, cx);
3632            });
3633        }
3634    }
3635
3636    fn on_toggle_thread_switcher(
3637        &mut self,
3638        action: &ToggleThreadSwitcher,
3639        window: &mut Window,
3640        cx: &mut Context<Self>,
3641    ) {
3642        self.toggle_thread_switcher_impl(action.select_last, window, cx);
3643    }
3644
3645    fn toggle_thread_switcher_impl(
3646        &mut self,
3647        select_last: bool,
3648        window: &mut Window,
3649        cx: &mut Context<Self>,
3650    ) {
3651        if let Some(thread_switcher) = &self.thread_switcher {
3652            thread_switcher.update(cx, |switcher, cx| {
3653                if select_last {
3654                    switcher.select_last(cx);
3655                } else {
3656                    switcher.cycle_selection(cx);
3657                }
3658            });
3659            return;
3660        }
3661
3662        let entries = self.mru_threads_for_switcher(cx);
3663        if entries.len() < 2 {
3664            return;
3665        }
3666
3667        let weak_multi_workspace = self.multi_workspace.clone();
3668
3669        let original_metadata = match &self.active_entry {
3670            Some(ActiveEntry::Thread { session_id, .. }) => entries
3671                .iter()
3672                .find(|e| &e.session_id == session_id)
3673                .map(|e| e.metadata.clone()),
3674            _ => None,
3675        };
3676        let original_workspace = self
3677            .multi_workspace
3678            .upgrade()
3679            .map(|mw| mw.read(cx).workspace().clone());
3680
3681        let thread_switcher = cx.new(|cx| ThreadSwitcher::new(entries, select_last, window, cx));
3682
3683        let mut subscriptions = Vec::new();
3684
3685        subscriptions.push(cx.subscribe_in(&thread_switcher, window, {
3686            let thread_switcher = thread_switcher.clone();
3687            move |this, _emitter, event: &ThreadSwitcherEvent, window, cx| match event {
3688                ThreadSwitcherEvent::Preview {
3689                    metadata,
3690                    workspace,
3691                } => {
3692                    if let Some(mw) = weak_multi_workspace.upgrade() {
3693                        mw.update(cx, |mw, cx| {
3694                            mw.activate(workspace.clone(), window, cx);
3695                        });
3696                    }
3697                    this.active_entry = Some(ActiveEntry::Thread {
3698                        session_id: metadata.session_id.clone(),
3699                        workspace: workspace.clone(),
3700                    });
3701                    this.update_entries(cx);
3702                    Self::load_agent_thread_in_workspace(workspace, metadata, false, window, cx);
3703                    let focus = thread_switcher.focus_handle(cx);
3704                    window.focus(&focus, cx);
3705                }
3706                ThreadSwitcherEvent::Confirmed {
3707                    metadata,
3708                    workspace,
3709                } => {
3710                    if let Some(mw) = weak_multi_workspace.upgrade() {
3711                        mw.update(cx, |mw, cx| {
3712                            mw.activate(workspace.clone(), window, cx);
3713                        });
3714                    }
3715                    this.record_thread_access(&metadata.session_id);
3716                    this.active_entry = Some(ActiveEntry::Thread {
3717                        session_id: metadata.session_id.clone(),
3718                        workspace: workspace.clone(),
3719                    });
3720                    this.update_entries(cx);
3721                    Self::load_agent_thread_in_workspace(workspace, metadata, false, window, cx);
3722                    this.dismiss_thread_switcher(cx);
3723                    workspace.update(cx, |workspace, cx| {
3724                        workspace.focus_panel::<AgentPanel>(window, cx);
3725                    });
3726                }
3727                ThreadSwitcherEvent::Dismissed => {
3728                    if let Some(mw) = weak_multi_workspace.upgrade() {
3729                        if let Some(original_ws) = &original_workspace {
3730                            mw.update(cx, |mw, cx| {
3731                                mw.activate(original_ws.clone(), window, cx);
3732                            });
3733                        }
3734                    }
3735                    if let Some(metadata) = &original_metadata {
3736                        if let Some(original_ws) = &original_workspace {
3737                            this.active_entry = Some(ActiveEntry::Thread {
3738                                session_id: metadata.session_id.clone(),
3739                                workspace: original_ws.clone(),
3740                            });
3741                        }
3742                        this.update_entries(cx);
3743                        if let Some(original_ws) = &original_workspace {
3744                            Self::load_agent_thread_in_workspace(
3745                                original_ws,
3746                                metadata,
3747                                false,
3748                                window,
3749                                cx,
3750                            );
3751                        }
3752                    }
3753                    this.dismiss_thread_switcher(cx);
3754                }
3755            }
3756        }));
3757
3758        subscriptions.push(cx.subscribe_in(
3759            &thread_switcher,
3760            window,
3761            |this, _emitter, _event: &gpui::DismissEvent, _window, cx| {
3762                this.dismiss_thread_switcher(cx);
3763            },
3764        ));
3765
3766        let focus = thread_switcher.focus_handle(cx);
3767        let overlay_view = gpui::AnyView::from(thread_switcher.clone());
3768
3769        // Replay the initial preview that was emitted during construction
3770        // before subscriptions were wired up.
3771        let initial_preview = thread_switcher
3772            .read(cx)
3773            .selected_entry()
3774            .map(|entry| (entry.metadata.clone(), entry.workspace.clone()));
3775
3776        self.thread_switcher = Some(thread_switcher);
3777        self._thread_switcher_subscriptions = subscriptions;
3778        if let Some(mw) = self.multi_workspace.upgrade() {
3779            mw.update(cx, |mw, cx| {
3780                mw.set_sidebar_overlay(Some(overlay_view), cx);
3781            });
3782        }
3783
3784        if let Some((metadata, workspace)) = initial_preview {
3785            if let Some(mw) = self.multi_workspace.upgrade() {
3786                mw.update(cx, |mw, cx| {
3787                    mw.activate(workspace.clone(), window, cx);
3788                });
3789            }
3790            self.active_entry = Some(ActiveEntry::Thread {
3791                session_id: metadata.session_id.clone(),
3792                workspace: workspace.clone(),
3793            });
3794            self.update_entries(cx);
3795            Self::load_agent_thread_in_workspace(&workspace, &metadata, false, window, cx);
3796        }
3797
3798        window.focus(&focus, cx);
3799    }
3800
3801    fn render_thread(
3802        &self,
3803        ix: usize,
3804        thread: &ThreadEntry,
3805        is_active: bool,
3806        is_focused: bool,
3807        cx: &mut Context<Self>,
3808    ) -> AnyElement {
3809        let has_notification = self
3810            .contents
3811            .is_thread_notified(&thread.metadata.session_id);
3812
3813        let title: SharedString = thread.metadata.title.clone();
3814        let metadata = thread.metadata.clone();
3815        let thread_workspace = thread.workspace.clone();
3816
3817        let is_hovered = self.hovered_thread_index == Some(ix);
3818        let is_selected = is_active;
3819        let is_running = matches!(
3820            thread.status,
3821            AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
3822        );
3823
3824        let session_id_for_delete = thread.metadata.session_id.clone();
3825        let focus_handle = self.focus_handle.clone();
3826
3827        let id = SharedString::from(format!("thread-entry-{}", ix));
3828
3829        let color = cx.theme().colors();
3830        let sidebar_bg = color
3831            .title_bar_background
3832            .blend(color.panel_background.opacity(0.25));
3833
3834        let timestamp = format_history_entry_timestamp(
3835            self.thread_last_message_sent_or_queued
3836                .get(&thread.metadata.session_id)
3837                .copied()
3838                .or(thread.metadata.created_at)
3839                .unwrap_or(thread.metadata.updated_at),
3840        );
3841
3842        ThreadItem::new(id, title)
3843            .base_bg(sidebar_bg)
3844            .icon(thread.icon)
3845            .status(thread.status)
3846            .when_some(thread.icon_from_external_svg.clone(), |this, svg| {
3847                this.custom_icon_from_external_svg(svg)
3848            })
3849            .worktrees(
3850                thread
3851                    .worktrees
3852                    .iter()
3853                    .map(|wt| ThreadItemWorktreeInfo {
3854                        name: wt.name.clone(),
3855                        full_path: wt.full_path.clone(),
3856                        highlight_positions: wt.highlight_positions.clone(),
3857                    })
3858                    .collect(),
3859            )
3860            .timestamp(timestamp)
3861            .highlight_positions(thread.highlight_positions.to_vec())
3862            .title_generating(thread.is_title_generating)
3863            .pending_worktree_restore(thread.metadata.pending_worktree_restore.is_some())
3864            .notified(has_notification)
3865            .when(thread.diff_stats.lines_added > 0, |this| {
3866                this.added(thread.diff_stats.lines_added as usize)
3867            })
3868            .when(thread.diff_stats.lines_removed > 0, |this| {
3869                this.removed(thread.diff_stats.lines_removed as usize)
3870            })
3871            .selected(is_selected)
3872            .focused(is_focused)
3873            .hovered(is_hovered)
3874            .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| {
3875                if *is_hovered {
3876                    this.hovered_thread_index = Some(ix);
3877                } else if this.hovered_thread_index == Some(ix) {
3878                    this.hovered_thread_index = None;
3879                }
3880                cx.notify();
3881            }))
3882            .when(is_hovered && is_running, |this| {
3883                this.action_slot(
3884                    IconButton::new("stop-thread", IconName::Stop)
3885                        .icon_size(IconSize::Small)
3886                        .icon_color(Color::Error)
3887                        .style(ButtonStyle::Tinted(TintColor::Error))
3888                        .tooltip(Tooltip::text("Stop Generation"))
3889                        .on_click({
3890                            let session_id = session_id_for_delete.clone();
3891                            cx.listener(move |this, _, _window, cx| {
3892                                this.stop_thread(&session_id, cx);
3893                            })
3894                        }),
3895                )
3896            })
3897            .when(is_hovered && !is_running, |this| {
3898                this.action_slot(
3899                    IconButton::new("archive-thread", IconName::Archive)
3900                        .icon_size(IconSize::Small)
3901                        .icon_color(Color::Muted)
3902                        .tooltip({
3903                            let focus_handle = focus_handle.clone();
3904                            move |_window, cx| {
3905                                Tooltip::for_action_in(
3906                                    "Archive Thread",
3907                                    &RemoveSelectedThread,
3908                                    &focus_handle,
3909                                    cx,
3910                                )
3911                            }
3912                        })
3913                        .on_click({
3914                            let session_id = session_id_for_delete.clone();
3915                            cx.listener(move |this, _, window, cx| {
3916                                this.archive_thread(&session_id, window, cx);
3917                            })
3918                        }),
3919                )
3920            })
3921            .on_click({
3922                cx.listener(move |this, _, window, cx| {
3923                    this.selection = None;
3924                    match &thread_workspace {
3925                        ThreadEntryWorkspace::Open(workspace) => {
3926                            this.activate_thread(metadata.clone(), workspace, window, cx);
3927                        }
3928                        ThreadEntryWorkspace::Closed(path_list) => {
3929                            this.open_workspace_and_activate_thread(
3930                                metadata.clone(),
3931                                path_list.clone(),
3932                                window,
3933                                cx,
3934                            );
3935                        }
3936                    }
3937                })
3938            })
3939            .into_any_element()
3940    }
3941
3942    fn render_filter_input(&self, cx: &mut Context<Self>) -> impl IntoElement {
3943        div()
3944            .min_w_0()
3945            .flex_1()
3946            .capture_action(
3947                cx.listener(|this, _: &editor::actions::Newline, window, cx| {
3948                    this.editor_confirm(window, cx);
3949                }),
3950            )
3951            .child(self.filter_editor.clone())
3952    }
3953
3954    fn render_recent_projects_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
3955        let multi_workspace = self.multi_workspace.upgrade();
3956
3957        let workspace = multi_workspace
3958            .as_ref()
3959            .map(|mw| mw.read(cx).workspace().downgrade());
3960
3961        let focus_handle = workspace
3962            .as_ref()
3963            .and_then(|ws| ws.upgrade())
3964            .map(|w| w.read(cx).focus_handle(cx))
3965            .unwrap_or_else(|| cx.focus_handle());
3966
3967        let sibling_workspace_ids: HashSet<WorkspaceId> = multi_workspace
3968            .as_ref()
3969            .map(|mw| {
3970                mw.read(cx)
3971                    .workspaces()
3972                    .iter()
3973                    .filter_map(|ws| ws.read(cx).database_id())
3974                    .collect()
3975            })
3976            .unwrap_or_default();
3977
3978        let popover_handle = self.recent_projects_popover_handle.clone();
3979
3980        PopoverMenu::new("sidebar-recent-projects-menu")
3981            .with_handle(popover_handle)
3982            .menu(move |window, cx| {
3983                workspace.as_ref().map(|ws| {
3984                    SidebarRecentProjects::popover(
3985                        ws.clone(),
3986                        sibling_workspace_ids.clone(),
3987                        focus_handle.clone(),
3988                        window,
3989                        cx,
3990                    )
3991                })
3992            })
3993            .trigger_with_tooltip(
3994                IconButton::new("open-project", IconName::OpenFolder)
3995                    .icon_size(IconSize::Small)
3996                    .selected_style(ButtonStyle::Tinted(TintColor::Accent)),
3997                |_window, cx| {
3998                    Tooltip::for_action(
3999                        "Add Project",
4000                        &OpenRecent {
4001                            create_new_window: false,
4002                        },
4003                        cx,
4004                    )
4005                },
4006            )
4007            .offset(gpui::Point {
4008                x: px(-2.0),
4009                y: px(-2.0),
4010            })
4011            .anchor(gpui::Corner::BottomRight)
4012    }
4013
4014    fn render_view_more(
4015        &self,
4016        ix: usize,
4017        path_list: &PathList,
4018        is_fully_expanded: bool,
4019        is_selected: bool,
4020        cx: &mut Context<Self>,
4021    ) -> AnyElement {
4022        let path_list = path_list.clone();
4023        let id = SharedString::from(format!("view-more-{}", ix));
4024
4025        let label: SharedString = if is_fully_expanded {
4026            "Collapse".into()
4027        } else {
4028            "View More".into()
4029        };
4030
4031        ThreadItem::new(id, label)
4032            .focused(is_selected)
4033            .icon_visible(false)
4034            .title_label_color(Color::Muted)
4035            .on_click(cx.listener(move |this, _, _window, cx| {
4036                this.selection = None;
4037                if is_fully_expanded {
4038                    this.expanded_groups.remove(&path_list);
4039                } else {
4040                    let current = this.expanded_groups.get(&path_list).copied().unwrap_or(0);
4041                    this.expanded_groups.insert(path_list.clone(), current + 1);
4042                }
4043                this.serialize(cx);
4044                this.update_entries(cx);
4045            }))
4046            .into_any_element()
4047    }
4048
4049    fn new_thread_in_group(
4050        &mut self,
4051        _: &NewThreadInGroup,
4052        window: &mut Window,
4053        cx: &mut Context<Self>,
4054    ) {
4055        // If there is a keyboard selection, walk backwards through
4056        // `project_header_indices` to find the header that owns the selected
4057        // row. Otherwise fall back to the active workspace.
4058        let workspace = if let Some(selected_ix) = self.selection {
4059            self.contents
4060                .project_header_indices
4061                .iter()
4062                .rev()
4063                .find(|&&header_ix| header_ix <= selected_ix)
4064                .and_then(|&header_ix| match &self.contents.entries[header_ix] {
4065                    ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
4066                    _ => None,
4067                })
4068        } else {
4069            // Use the currently active workspace.
4070            self.multi_workspace
4071                .upgrade()
4072                .map(|mw| mw.read(cx).workspace().clone())
4073        };
4074
4075        let Some(workspace) = workspace else {
4076            return;
4077        };
4078
4079        self.create_new_thread(&workspace, window, cx);
4080    }
4081
4082    fn create_new_thread(
4083        &mut self,
4084        workspace: &Entity<Workspace>,
4085        window: &mut Window,
4086        cx: &mut Context<Self>,
4087    ) {
4088        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
4089            return;
4090        };
4091
4092        self.active_entry = Some(ActiveEntry::Draft(workspace.clone()));
4093
4094        multi_workspace.update(cx, |multi_workspace, cx| {
4095            multi_workspace.activate(workspace.clone(), window, cx);
4096        });
4097
4098        workspace.update(cx, |workspace, cx| {
4099            if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx) {
4100                agent_panel.update(cx, |panel, cx| {
4101                    panel.new_thread(&NewThread, window, cx);
4102                });
4103            }
4104            workspace.focus_panel::<AgentPanel>(window, cx);
4105        });
4106    }
4107
4108    fn render_new_thread(
4109        &self,
4110        ix: usize,
4111        _path_list: &PathList,
4112        workspace: &Entity<Workspace>,
4113        is_active: bool,
4114        worktrees: &[WorktreeInfo],
4115        is_selected: bool,
4116        cx: &mut Context<Self>,
4117    ) -> AnyElement {
4118        let label: SharedString = if is_active {
4119            self.active_draft_text(cx)
4120                .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into())
4121        } else {
4122            DEFAULT_THREAD_TITLE.into()
4123        };
4124
4125        let workspace = workspace.clone();
4126        let id = SharedString::from(format!("new-thread-btn-{}", ix));
4127
4128        let thread_item = ThreadItem::new(id, label)
4129            .icon(IconName::Plus)
4130            .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.8)))
4131            .worktrees(
4132                worktrees
4133                    .iter()
4134                    .map(|wt| ThreadItemWorktreeInfo {
4135                        name: wt.name.clone(),
4136                        full_path: wt.full_path.clone(),
4137                        highlight_positions: wt.highlight_positions.clone(),
4138                    })
4139                    .collect(),
4140            )
4141            .selected(is_active)
4142            .focused(is_selected)
4143            .when(!is_active, |this| {
4144                this.on_click(cx.listener(move |this, _, window, cx| {
4145                    this.selection = None;
4146                    this.create_new_thread(&workspace, window, cx);
4147                }))
4148            });
4149
4150        if is_active {
4151            div()
4152                .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
4153                    cx.stop_propagation();
4154                })
4155                .child(thread_item)
4156                .into_any_element()
4157        } else {
4158            thread_item.into_any_element()
4159        }
4160    }
4161
4162    fn render_no_results(&self, cx: &mut Context<Self>) -> impl IntoElement {
4163        let has_query = self.has_filter_query(cx);
4164        let message = if has_query {
4165            "No threads match your search."
4166        } else {
4167            "No threads yet"
4168        };
4169
4170        v_flex()
4171            .id("sidebar-no-results")
4172            .p_4()
4173            .size_full()
4174            .items_center()
4175            .justify_center()
4176            .child(
4177                Label::new(message)
4178                    .size(LabelSize::Small)
4179                    .color(Color::Muted),
4180            )
4181    }
4182
4183    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
4184        v_flex()
4185            .id("sidebar-empty-state")
4186            .p_4()
4187            .size_full()
4188            .items_center()
4189            .justify_center()
4190            .gap_1()
4191            .track_focus(&self.focus_handle(cx))
4192            .child(
4193                Button::new("open_project", "Open Project")
4194                    .full_width()
4195                    .key_binding(KeyBinding::for_action(&workspace::Open::default(), cx))
4196                    .on_click(|_, window, cx| {
4197                        window.dispatch_action(
4198                            Open {
4199                                create_new_window: false,
4200                            }
4201                            .boxed_clone(),
4202                            cx,
4203                        );
4204                    }),
4205            )
4206            .child(
4207                h_flex()
4208                    .w_1_2()
4209                    .gap_2()
4210                    .child(Divider::horizontal().color(ui::DividerColor::Border))
4211                    .child(Label::new("or").size(LabelSize::XSmall).color(Color::Muted))
4212                    .child(Divider::horizontal().color(ui::DividerColor::Border)),
4213            )
4214            .child(
4215                Button::new("clone_repo", "Clone Repository")
4216                    .full_width()
4217                    .on_click(|_, window, cx| {
4218                        window.dispatch_action(git::Clone.boxed_clone(), cx);
4219                    }),
4220            )
4221    }
4222
4223    fn render_sidebar_header(
4224        &self,
4225        no_open_projects: bool,
4226        window: &Window,
4227        cx: &mut Context<Self>,
4228    ) -> impl IntoElement {
4229        let has_query = self.has_filter_query(cx);
4230        let sidebar_on_left = self.side(cx) == SidebarSide::Left;
4231        let sidebar_on_right = self.side(cx) == SidebarSide::Right;
4232        let not_fullscreen = !window.is_fullscreen();
4233        let traffic_lights = cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left;
4234        let left_window_controls = !cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left;
4235        let right_window_controls =
4236            !cfg!(target_os = "macos") && not_fullscreen && sidebar_on_right;
4237        let header_height = platform_title_bar_height(window);
4238
4239        h_flex()
4240            .h(header_height)
4241            .mt_px()
4242            .pb_px()
4243            .when(left_window_controls, |this| {
4244                this.children(Self::render_left_window_controls(window, cx))
4245            })
4246            .map(|this| {
4247                if traffic_lights {
4248                    this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
4249                } else if !left_window_controls {
4250                    this.pl_1p5()
4251                } else {
4252                    this
4253                }
4254            })
4255            .when(!right_window_controls, |this| this.pr_1p5())
4256            .gap_1()
4257            .when(!no_open_projects, |this| {
4258                this.border_b_1()
4259                    .border_color(cx.theme().colors().border)
4260                    .when(traffic_lights, |this| {
4261                        this.child(Divider::vertical().color(ui::DividerColor::Border))
4262                    })
4263                    .child(
4264                        div().ml_1().child(
4265                            Icon::new(IconName::MagnifyingGlass)
4266                                .size(IconSize::Small)
4267                                .color(Color::Muted),
4268                        ),
4269                    )
4270                    .child(self.render_filter_input(cx))
4271                    .child(
4272                        h_flex()
4273                            .gap_1()
4274                            .when(
4275                                self.selection.is_some()
4276                                    && !self.filter_editor.focus_handle(cx).is_focused(window),
4277                                |this| this.child(KeyBinding::for_action(&FocusSidebarFilter, cx)),
4278                            )
4279                            .when(has_query, |this| {
4280                                this.child(
4281                                    IconButton::new("clear_filter", IconName::Close)
4282                                        .icon_size(IconSize::Small)
4283                                        .tooltip(Tooltip::text("Clear Search"))
4284                                        .on_click(cx.listener(|this, _, window, cx| {
4285                                            this.reset_filter_editor_text(window, cx);
4286                                            this.update_entries(cx);
4287                                        })),
4288                                )
4289                            }),
4290                    )
4291            })
4292            .when(right_window_controls, |this| {
4293                this.children(Self::render_right_window_controls(window, cx))
4294            })
4295    }
4296
4297    fn render_left_window_controls(window: &Window, cx: &mut App) -> Option<AnyElement> {
4298        platform_title_bar::render_left_window_controls(
4299            cx.button_layout(),
4300            Box::new(CloseWindow),
4301            window,
4302        )
4303    }
4304
4305    fn render_right_window_controls(window: &Window, cx: &mut App) -> Option<AnyElement> {
4306        platform_title_bar::render_right_window_controls(
4307            cx.button_layout(),
4308            Box::new(CloseWindow),
4309            window,
4310        )
4311    }
4312
4313    fn render_sidebar_toggle_button(&self, _cx: &mut Context<Self>) -> impl IntoElement {
4314        let on_right = AgentSettings::get_global(_cx).sidebar_side() == SidebarSide::Right;
4315
4316        sidebar_side_context_menu("sidebar-toggle-menu", _cx)
4317            .anchor(if on_right {
4318                gpui::Corner::BottomRight
4319            } else {
4320                gpui::Corner::BottomLeft
4321            })
4322            .attach(if on_right {
4323                gpui::Corner::TopRight
4324            } else {
4325                gpui::Corner::TopLeft
4326            })
4327            .trigger(move |_is_active, _window, _cx| {
4328                let icon = if on_right {
4329                    IconName::ThreadsSidebarRightOpen
4330                } else {
4331                    IconName::ThreadsSidebarLeftOpen
4332                };
4333                IconButton::new("sidebar-close-toggle", icon)
4334                    .icon_size(IconSize::Small)
4335                    .tooltip(Tooltip::element(move |_window, cx| {
4336                        v_flex()
4337                            .gap_1()
4338                            .child(
4339                                h_flex()
4340                                    .gap_2()
4341                                    .justify_between()
4342                                    .child(Label::new("Toggle Sidebar"))
4343                                    .child(KeyBinding::for_action(&ToggleWorkspaceSidebar, cx)),
4344                            )
4345                            .child(
4346                                h_flex()
4347                                    .pt_1()
4348                                    .gap_2()
4349                                    .border_t_1()
4350                                    .border_color(cx.theme().colors().border_variant)
4351                                    .justify_between()
4352                                    .child(Label::new("Focus Sidebar"))
4353                                    .child(KeyBinding::for_action(&FocusWorkspaceSidebar, cx)),
4354                            )
4355                            .into_any_element()
4356                    }))
4357                    .on_click(|_, window, cx| {
4358                        if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
4359                            multi_workspace.update(cx, |multi_workspace, cx| {
4360                                multi_workspace.close_sidebar(window, cx);
4361                            });
4362                        }
4363                    })
4364            })
4365    }
4366
4367    fn render_sidebar_bottom_bar(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
4368        let is_archive = matches!(self.view, SidebarView::Archive(..));
4369        let show_import_button = is_archive && !self.should_render_acp_import_onboarding(cx);
4370        let on_right = self.side(cx) == SidebarSide::Right;
4371
4372        let action_buttons = h_flex()
4373            .gap_1()
4374            .when(on_right, |this| this.flex_row_reverse())
4375            .when(show_import_button, |this| {
4376                this.child(
4377                    IconButton::new("thread-import", IconName::ThreadImport)
4378                        .icon_size(IconSize::Small)
4379                        .tooltip(Tooltip::text("Import ACP Threads"))
4380                        .on_click(cx.listener(|this, _, window, cx| {
4381                            this.show_archive(window, cx);
4382                            this.show_thread_import_modal(window, cx);
4383                        })),
4384                )
4385            })
4386            .child(
4387                IconButton::new("archive", IconName::Archive)
4388                    .icon_size(IconSize::Small)
4389                    .toggle_state(is_archive)
4390                    .tooltip(move |_, cx| {
4391                        Tooltip::for_action("Toggle Archived Threads", &ToggleArchive, cx)
4392                    })
4393                    .on_click(cx.listener(|this, _, window, cx| {
4394                        this.toggle_archive(&ToggleArchive, window, cx);
4395                    })),
4396            )
4397            .child(self.render_recent_projects_button(cx));
4398
4399        h_flex()
4400            .p_1()
4401            .gap_1()
4402            .when(on_right, |this| this.flex_row_reverse())
4403            .justify_between()
4404            .border_t_1()
4405            .border_color(cx.theme().colors().border)
4406            .child(self.render_sidebar_toggle_button(cx))
4407            .child(action_buttons)
4408    }
4409
4410    fn active_workspace(&self, cx: &App) -> Option<Entity<Workspace>> {
4411        self.multi_workspace.upgrade().and_then(|w| {
4412            w.read(cx)
4413                .workspaces()
4414                .get(w.read(cx).active_workspace_index())
4415                .cloned()
4416        })
4417    }
4418
4419    fn show_thread_import_modal(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4420        let Some(active_workspace) = self.active_workspace(cx) else {
4421            return;
4422        };
4423
4424        let Some(agent_registry_store) = AgentRegistryStore::try_global(cx) else {
4425            return;
4426        };
4427
4428        let agent_server_store = active_workspace
4429            .read(cx)
4430            .project()
4431            .read(cx)
4432            .agent_server_store()
4433            .clone();
4434
4435        let workspace_handle = active_workspace.downgrade();
4436        let multi_workspace = self.multi_workspace.clone();
4437
4438        active_workspace.update(cx, |workspace, cx| {
4439            workspace.toggle_modal(window, cx, |window, cx| {
4440                ThreadImportModal::new(
4441                    agent_server_store,
4442                    agent_registry_store,
4443                    workspace_handle.clone(),
4444                    multi_workspace.clone(),
4445                    window,
4446                    cx,
4447                )
4448            });
4449        });
4450    }
4451
4452    fn should_render_acp_import_onboarding(&self, cx: &App) -> bool {
4453        let has_external_agents = self
4454            .active_workspace(cx)
4455            .map(|ws| {
4456                ws.read(cx)
4457                    .project()
4458                    .read(cx)
4459                    .agent_server_store()
4460                    .read(cx)
4461                    .has_external_agents()
4462            })
4463            .unwrap_or(false);
4464
4465        has_external_agents && !AcpThreadImportOnboarding::dismissed(cx)
4466    }
4467
4468    fn render_acp_import_onboarding(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
4469        let description =
4470            "Import threads from your ACP agents — whether started in Zed or another client.";
4471
4472        let bg = cx.theme().colors().text_accent;
4473
4474        v_flex()
4475            .min_w_0()
4476            .w_full()
4477            .p_2()
4478            .border_t_1()
4479            .border_color(cx.theme().colors().border)
4480            .bg(linear_gradient(
4481                360.,
4482                linear_color_stop(bg.opacity(0.06), 1.),
4483                linear_color_stop(bg.opacity(0.), 0.),
4484            ))
4485            .child(
4486                h_flex()
4487                    .min_w_0()
4488                    .w_full()
4489                    .gap_1()
4490                    .justify_between()
4491                    .child(Label::new("Looking for ACP threads?"))
4492                    .child(
4493                        IconButton::new("close-onboarding", IconName::Close)
4494                            .icon_size(IconSize::Small)
4495                            .on_click(|_, _window, cx| AcpThreadImportOnboarding::dismiss(cx)),
4496                    ),
4497            )
4498            .child(Label::new(description).color(Color::Muted).mb_2())
4499            .child(
4500                Button::new("import-acp", "Import ACP Threads")
4501                    .full_width()
4502                    .style(ButtonStyle::OutlinedCustom(cx.theme().colors().border))
4503                    .label_size(LabelSize::Small)
4504                    .start_icon(
4505                        Icon::new(IconName::ThreadImport)
4506                            .size(IconSize::Small)
4507                            .color(Color::Muted),
4508                    )
4509                    .on_click(cx.listener(|this, _, window, cx| {
4510                        this.show_archive(window, cx);
4511                        this.show_thread_import_modal(window, cx);
4512                    })),
4513            )
4514    }
4515
4516    fn toggle_archive(&mut self, _: &ToggleArchive, window: &mut Window, cx: &mut Context<Self>) {
4517        match &self.view {
4518            SidebarView::ThreadList => self.show_archive(window, cx),
4519            SidebarView::Archive(_) => self.show_thread_list(window, cx),
4520        }
4521    }
4522
4523    fn show_archive(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4524        let Some(active_workspace) = self.multi_workspace.upgrade().and_then(|w| {
4525            w.read(cx)
4526                .workspaces()
4527                .get(w.read(cx).active_workspace_index())
4528                .cloned()
4529        }) else {
4530            return;
4531        };
4532        let Some(agent_panel) = active_workspace.read(cx).panel::<AgentPanel>(cx) else {
4533            return;
4534        };
4535
4536        let agent_server_store = active_workspace
4537            .read(cx)
4538            .project()
4539            .read(cx)
4540            .agent_server_store()
4541            .downgrade();
4542
4543        let agent_connection_store = agent_panel.read(cx).connection_store().downgrade();
4544
4545        let archive_view = cx.new(|cx| {
4546            ThreadsArchiveView::new(
4547                active_workspace.downgrade(),
4548                agent_connection_store.clone(),
4549                agent_server_store.clone(),
4550                window,
4551                cx,
4552            )
4553        });
4554
4555        let subscription = cx.subscribe_in(
4556            &archive_view,
4557            window,
4558            |this, _, event: &ThreadsArchiveViewEvent, window, cx| match event {
4559                ThreadsArchiveViewEvent::Close => {
4560                    this.show_thread_list(window, cx);
4561                }
4562                ThreadsArchiveViewEvent::Unarchive { thread } => {
4563                    this.show_thread_list(window, cx);
4564                    this.activate_archived_thread(thread.clone(), window, cx);
4565                }
4566            },
4567        );
4568
4569        self._subscriptions.push(subscription);
4570        self.view = SidebarView::Archive(archive_view.clone());
4571        archive_view.update(cx, |view, cx| view.focus_filter_editor(window, cx));
4572        self.serialize(cx);
4573        cx.notify();
4574    }
4575
4576    fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4577        self.view = SidebarView::ThreadList;
4578        self._subscriptions.clear();
4579        let handle = self.filter_editor.read(cx).focus_handle(cx);
4580        handle.focus(window, cx);
4581        self.serialize(cx);
4582        cx.notify();
4583    }
4584}
4585
4586impl WorkspaceSidebar for Sidebar {
4587    fn width(&self, _cx: &App) -> Pixels {
4588        self.width
4589    }
4590
4591    fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
4592        self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
4593        cx.notify();
4594    }
4595
4596    fn has_notifications(&self, _cx: &App) -> bool {
4597        !self.contents.notified_threads.is_empty()
4598    }
4599
4600    fn is_threads_list_view_active(&self) -> bool {
4601        matches!(self.view, SidebarView::ThreadList)
4602    }
4603
4604    fn side(&self, cx: &App) -> SidebarSide {
4605        AgentSettings::get_global(cx).sidebar_side()
4606    }
4607
4608    fn prepare_for_focus(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
4609        self.selection = None;
4610        cx.notify();
4611    }
4612
4613    fn toggle_thread_switcher(
4614        &mut self,
4615        select_last: bool,
4616        window: &mut Window,
4617        cx: &mut Context<Self>,
4618    ) {
4619        self.toggle_thread_switcher_impl(select_last, window, cx);
4620    }
4621
4622    fn serialized_state(&self, _cx: &App) -> Option<String> {
4623        let serialized = SerializedSidebar {
4624            width: Some(f32::from(self.width)),
4625            collapsed_groups: self
4626                .collapsed_groups
4627                .iter()
4628                .map(|pl| pl.serialize())
4629                .collect(),
4630            expanded_groups: self
4631                .expanded_groups
4632                .iter()
4633                .map(|(pl, count)| (pl.serialize(), *count))
4634                .collect(),
4635            active_view: match self.view {
4636                SidebarView::ThreadList => SerializedSidebarView::ThreadList,
4637                SidebarView::Archive(_) => SerializedSidebarView::Archive,
4638            },
4639        };
4640        serde_json::to_string(&serialized).ok()
4641    }
4642
4643    fn restore_serialized_state(
4644        &mut self,
4645        state: &str,
4646        window: &mut Window,
4647        cx: &mut Context<Self>,
4648    ) {
4649        if let Some(serialized) = serde_json::from_str::<SerializedSidebar>(state).log_err() {
4650            if let Some(width) = serialized.width {
4651                self.width = px(width).clamp(MIN_WIDTH, MAX_WIDTH);
4652            }
4653            self.collapsed_groups = serialized
4654                .collapsed_groups
4655                .into_iter()
4656                .map(|s| PathList::deserialize(&s))
4657                .collect();
4658            self.expanded_groups = serialized
4659                .expanded_groups
4660                .into_iter()
4661                .map(|(s, count)| (PathList::deserialize(&s), count))
4662                .collect();
4663            if serialized.active_view == SerializedSidebarView::Archive {
4664                cx.defer_in(window, |this, window, cx| {
4665                    this.show_archive(window, cx);
4666                });
4667            }
4668        }
4669        cx.notify();
4670    }
4671}
4672
4673impl gpui::EventEmitter<workspace::SidebarEvent> for Sidebar {}
4674
4675impl Focusable for Sidebar {
4676    fn focus_handle(&self, _cx: &App) -> FocusHandle {
4677        self.focus_handle.clone()
4678    }
4679}
4680
4681impl Render for Sidebar {
4682    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4683        let _titlebar_height = ui::utils::platform_title_bar_height(window);
4684        let ui_font = theme_settings::setup_ui_font(window, cx);
4685        let sticky_header = self.render_sticky_header(window, cx);
4686
4687        let color = cx.theme().colors();
4688        let bg = color
4689            .title_bar_background
4690            .blend(color.panel_background.opacity(0.25));
4691
4692        let no_open_projects = !self.contents.has_open_projects;
4693        let no_search_results = self.contents.entries.is_empty();
4694
4695        v_flex()
4696            .id("workspace-sidebar")
4697            .key_context(self.dispatch_context(window, cx))
4698            .track_focus(&self.focus_handle)
4699            .on_action(cx.listener(Self::select_next))
4700            .on_action(cx.listener(Self::select_previous))
4701            .on_action(cx.listener(Self::editor_move_down))
4702            .on_action(cx.listener(Self::editor_move_up))
4703            .on_action(cx.listener(Self::select_first))
4704            .on_action(cx.listener(Self::select_last))
4705            .on_action(cx.listener(Self::confirm))
4706            .on_action(cx.listener(Self::expand_selected_entry))
4707            .on_action(cx.listener(Self::collapse_selected_entry))
4708            .on_action(cx.listener(Self::toggle_selected_fold))
4709            .on_action(cx.listener(Self::fold_all))
4710            .on_action(cx.listener(Self::unfold_all))
4711            .on_action(cx.listener(Self::cancel))
4712            .on_action(cx.listener(Self::remove_selected_thread))
4713            .on_action(cx.listener(Self::new_thread_in_group))
4714            .on_action(cx.listener(Self::toggle_archive))
4715            .on_action(cx.listener(Self::focus_sidebar_filter))
4716            .on_action(cx.listener(Self::on_toggle_thread_switcher))
4717            .on_action(cx.listener(|this, _: &OpenRecent, window, cx| {
4718                this.recent_projects_popover_handle.toggle(window, cx);
4719            }))
4720            .font(ui_font)
4721            .h_full()
4722            .w(self.width)
4723            .bg(bg)
4724            .when(self.side(cx) == SidebarSide::Left, |el| el.border_r_1())
4725            .when(self.side(cx) == SidebarSide::Right, |el| el.border_l_1())
4726            .border_color(color.border)
4727            .map(|this| match &self.view {
4728                SidebarView::ThreadList => this
4729                    .child(self.render_sidebar_header(no_open_projects, window, cx))
4730                    .map(|this| {
4731                        if no_open_projects {
4732                            this.child(self.render_empty_state(cx))
4733                        } else {
4734                            this.child(
4735                                v_flex()
4736                                    .relative()
4737                                    .flex_1()
4738                                    .overflow_hidden()
4739                                    .child(
4740                                        list(
4741                                            self.list_state.clone(),
4742                                            cx.processor(Self::render_list_entry),
4743                                        )
4744                                        .flex_1()
4745                                        .size_full(),
4746                                    )
4747                                    .when(no_search_results, |this| {
4748                                        this.child(self.render_no_results(cx))
4749                                    })
4750                                    .when_some(sticky_header, |this, header| this.child(header))
4751                                    .vertical_scrollbar_for(&self.list_state, window, cx),
4752                            )
4753                        }
4754                    }),
4755                SidebarView::Archive(archive_view) => this.child(archive_view.clone()),
4756            })
4757            .when(self.should_render_acp_import_onboarding(cx), |this| {
4758                this.child(self.render_acp_import_onboarding(cx))
4759            })
4760            .child(self.render_sidebar_bottom_bar(cx))
4761    }
4762}
4763
4764fn all_thread_infos_for_workspace(
4765    workspace: &Entity<Workspace>,
4766    cx: &App,
4767) -> impl Iterator<Item = ActiveThreadInfo> {
4768    let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
4769        return None.into_iter().flatten();
4770    };
4771    let agent_panel = agent_panel.read(cx);
4772
4773    let threads = agent_panel
4774        .parent_threads(cx)
4775        .into_iter()
4776        .map(|thread_view| {
4777            let thread_view_ref = thread_view.read(cx);
4778            let thread = thread_view_ref.thread.read(cx);
4779
4780            let icon = thread_view_ref.agent_icon;
4781            let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone();
4782            let title = thread
4783                .title()
4784                .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into());
4785            let is_native = thread_view_ref.as_native_thread(cx).is_some();
4786            let is_title_generating = is_native && thread.has_provisional_title();
4787            let session_id = thread.session_id().clone();
4788            let is_background = agent_panel.is_background_thread(&session_id);
4789
4790            let status = if thread.is_waiting_for_confirmation() {
4791                AgentThreadStatus::WaitingForConfirmation
4792            } else if thread.had_error() {
4793                AgentThreadStatus::Error
4794            } else {
4795                match thread.status() {
4796                    ThreadStatus::Generating => AgentThreadStatus::Running,
4797                    ThreadStatus::Idle => AgentThreadStatus::Completed,
4798                }
4799            };
4800
4801            let diff_stats = thread.action_log().read(cx).diff_stats(cx);
4802
4803            ActiveThreadInfo {
4804                session_id,
4805                title,
4806                status,
4807                icon,
4808                icon_from_external_svg,
4809                is_background,
4810                is_title_generating,
4811                diff_stats,
4812            }
4813        });
4814
4815    Some(threads).into_iter().flatten()
4816}
4817
4818pub fn dump_workspace_info(
4819    workspace: &mut Workspace,
4820    _: &DumpWorkspaceInfo,
4821    window: &mut gpui::Window,
4822    cx: &mut gpui::Context<Workspace>,
4823) {
4824    use std::fmt::Write;
4825
4826    let mut output = String::new();
4827    let this_entity = cx.entity();
4828
4829    let multi_workspace = workspace.multi_workspace().and_then(|weak| weak.upgrade());
4830    let workspaces: Vec<gpui::Entity<Workspace>> = match &multi_workspace {
4831        Some(mw) => mw.read(cx).workspaces().to_vec(),
4832        None => vec![this_entity.clone()],
4833    };
4834    let active_index = multi_workspace
4835        .as_ref()
4836        .map(|mw| mw.read(cx).active_workspace_index());
4837
4838    writeln!(output, "MultiWorkspace: {} workspace(s)", workspaces.len()).ok();
4839    if let Some(index) = active_index {
4840        writeln!(output, "Active workspace index: {index}").ok();
4841    }
4842    writeln!(output).ok();
4843
4844    for (index, ws) in workspaces.iter().enumerate() {
4845        let is_active = active_index == Some(index);
4846        writeln!(
4847            output,
4848            "--- Workspace {index}{} ---",
4849            if is_active { " (active)" } else { "" }
4850        )
4851        .ok();
4852
4853        // The action handler is already inside an update on `this_entity`,
4854        // so we must avoid a nested read/update on that same entity.
4855        if *ws == this_entity {
4856            dump_single_workspace(workspace, &mut output, cx);
4857        } else {
4858            ws.read_with(cx, |ws, cx| {
4859                dump_single_workspace(ws, &mut output, cx);
4860            });
4861        }
4862    }
4863
4864    let project = workspace.project().clone();
4865    cx.spawn_in(window, async move |_this, cx| {
4866        let buffer = project
4867            .update(cx, |project, cx| project.create_buffer(None, false, cx))
4868            .await?;
4869
4870        buffer.update(cx, |buffer, cx| {
4871            buffer.set_text(output, cx);
4872        });
4873
4874        let buffer = cx.new(|cx| {
4875            editor::MultiBuffer::singleton(buffer, cx).with_title("Workspace Info".into())
4876        });
4877
4878        _this.update_in(cx, |workspace, window, cx| {
4879            workspace.add_item_to_active_pane(
4880                Box::new(cx.new(|cx| {
4881                    let mut editor =
4882                        editor::Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
4883                    editor.set_read_only(true);
4884                    editor.set_should_serialize(false, cx);
4885                    editor.set_breadcrumb_header("Workspace Info".into());
4886                    editor
4887                })),
4888                None,
4889                true,
4890                window,
4891                cx,
4892            );
4893        })
4894    })
4895    .detach_and_log_err(cx);
4896}
4897
4898fn dump_single_workspace(workspace: &Workspace, output: &mut String, cx: &gpui::App) {
4899    use std::fmt::Write;
4900
4901    let workspace_db_id = workspace.database_id();
4902    match workspace_db_id {
4903        Some(id) => writeln!(output, "Workspace DB ID: {id:?}").ok(),
4904        None => writeln!(output, "Workspace DB ID: (none)").ok(),
4905    };
4906
4907    let project = workspace.project().read(cx);
4908
4909    let repos: Vec<_> = project
4910        .repositories(cx)
4911        .values()
4912        .map(|repo| repo.read(cx).snapshot())
4913        .collect();
4914
4915    writeln!(output, "Worktrees:").ok();
4916    for worktree in project.worktrees(cx) {
4917        let worktree = worktree.read(cx);
4918        let abs_path = worktree.abs_path();
4919        let visible = worktree.is_visible();
4920
4921        let repo_info = repos
4922            .iter()
4923            .find(|snapshot| abs_path.starts_with(&*snapshot.work_directory_abs_path));
4924
4925        let is_linked = repo_info.map(|s| s.is_linked_worktree()).unwrap_or(false);
4926        let original_repo_path = repo_info.map(|s| &s.original_repo_abs_path);
4927        let branch = repo_info.and_then(|s| s.branch.as_ref().map(|b| b.ref_name.clone()));
4928
4929        write!(output, "  - {}", abs_path.display()).ok();
4930        if !visible {
4931            write!(output, " (hidden)").ok();
4932        }
4933        if let Some(branch) = &branch {
4934            write!(output, " [branch: {branch}]").ok();
4935        }
4936        if is_linked {
4937            if let Some(original) = original_repo_path {
4938                write!(output, " [linked worktree -> {}]", original.display()).ok();
4939            } else {
4940                write!(output, " [linked worktree]").ok();
4941            }
4942        }
4943        writeln!(output).ok();
4944    }
4945
4946    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
4947        let panel = panel.read(cx);
4948
4949        let panel_workspace_id = panel.workspace_id();
4950        if panel_workspace_id != workspace_db_id {
4951            writeln!(
4952                output,
4953                "  \u{26a0} workspace ID mismatch! panel has {panel_workspace_id:?}, workspace has {workspace_db_id:?}"
4954            )
4955            .ok();
4956        }
4957
4958        if let Some(thread) = panel.active_agent_thread(cx) {
4959            let thread = thread.read(cx);
4960            let title = thread.title().unwrap_or_else(|| "(untitled)".into());
4961            let session_id = thread.session_id();
4962            let status = match thread.status() {
4963                ThreadStatus::Idle => "idle",
4964                ThreadStatus::Generating => "generating",
4965            };
4966            let entry_count = thread.entries().len();
4967            write!(output, "Active thread: {title} (session: {session_id})").ok();
4968            write!(output, " [{status}, {entry_count} entries").ok();
4969            if thread.is_waiting_for_confirmation() {
4970                write!(output, ", awaiting confirmation").ok();
4971            }
4972            writeln!(output, "]").ok();
4973        } else {
4974            writeln!(output, "Active thread: (none)").ok();
4975        }
4976
4977        let background_threads = panel.background_threads();
4978        if !background_threads.is_empty() {
4979            writeln!(
4980                output,
4981                "Background threads ({}): ",
4982                background_threads.len()
4983            )
4984            .ok();
4985            for (session_id, conversation_view) in background_threads {
4986                if let Some(thread_view) = conversation_view.read(cx).root_thread(cx) {
4987                    let thread = thread_view.read(cx).thread.read(cx);
4988                    let title = thread.title().unwrap_or_else(|| "(untitled)".into());
4989                    let status = match thread.status() {
4990                        ThreadStatus::Idle => "idle",
4991                        ThreadStatus::Generating => "generating",
4992                    };
4993                    let entry_count = thread.entries().len();
4994                    write!(output, "  - {title} (session: {session_id})").ok();
4995                    write!(output, " [{status}, {entry_count} entries").ok();
4996                    if thread.is_waiting_for_confirmation() {
4997                        write!(output, ", awaiting confirmation").ok();
4998                    }
4999                    writeln!(output, "]").ok();
5000                } else {
5001                    writeln!(output, "  - (not connected) (session: {session_id})").ok();
5002                }
5003            }
5004        }
5005    } else {
5006        writeln!(output, "Agent panel: not loaded").ok();
5007    }
5008
5009    writeln!(output).ok();
5010}