sidebar.rs

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