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