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