sidebar.rs

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