sidebar.rs

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