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