sidebar.rs

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