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