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