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