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