sidebar.rs

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