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