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