sidebar.rs

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