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