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