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                // TODO DL: Maybe these can derived somewhere else? Maybe in update or rebuild?
1423                let group_has_threads = self
1424                    .contents
1425                    .entries
1426                    .iter()
1427                    .any(|e| matches!(e, ListEntry::ProjectHeader { key: hk, has_threads: true, .. } if hk == key));
1428                // Count drafts in the AgentPanel for this group's workspaces.
1429                let sibling_draft_count = workspace
1430                    .as_ref()
1431                    .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
1432                    .map(|p| p.read(cx).draft_ids().len())
1433                    .unwrap_or(0);
1434                let can_dismiss = group_has_threads || sibling_draft_count > 1;
1435                self.render_draft_thread(
1436                    ix,
1437                    *draft_id,
1438                    key,
1439                    workspace.as_ref(),
1440                    is_active,
1441                    worktrees,
1442                    is_selected,
1443                    can_dismiss,
1444                    cx,
1445                )
1446            }
1447        };
1448
1449        if is_group_header_after_first {
1450            v_flex()
1451                .w_full()
1452                .border_t_1()
1453                .border_color(cx.theme().colors().border)
1454                .child(rendered)
1455                .into_any_element()
1456        } else {
1457            rendered
1458        }
1459    }
1460
1461    fn render_remote_project_icon(
1462        &self,
1463        ix: usize,
1464        host: Option<&RemoteConnectionOptions>,
1465    ) -> Option<AnyElement> {
1466        let remote_icon_per_type = match host? {
1467            RemoteConnectionOptions::Wsl(_) => IconName::Linux,
1468            RemoteConnectionOptions::Docker(_) => IconName::Box,
1469            _ => IconName::Server,
1470        };
1471
1472        Some(
1473            div()
1474                .id(format!("remote-project-icon-{}", ix))
1475                .child(
1476                    Icon::new(remote_icon_per_type)
1477                        .size(IconSize::XSmall)
1478                        .color(Color::Muted),
1479                )
1480                .tooltip(Tooltip::text("Remote Project"))
1481                .into_any_element(),
1482        )
1483    }
1484
1485    fn render_project_header(
1486        &self,
1487        ix: usize,
1488        is_sticky: bool,
1489        key: &ProjectGroupKey,
1490        label: &SharedString,
1491        highlight_positions: &[usize],
1492        has_running_threads: bool,
1493        waiting_thread_count: usize,
1494        is_active: bool,
1495        is_focused: bool,
1496        has_threads: bool,
1497        cx: &mut Context<Self>,
1498    ) -> AnyElement {
1499        let host = key.host();
1500
1501        let id_prefix = if is_sticky { "sticky-" } else { "" };
1502        let id = SharedString::from(format!("{id_prefix}project-header-{ix}"));
1503        let disclosure_id = SharedString::from(format!("disclosure-{ix}"));
1504        let group_name = SharedString::from(format!("{id_prefix}header-group-{ix}"));
1505
1506        let is_collapsed = self.collapsed_groups.contains(key);
1507        let (disclosure_icon, disclosure_tooltip) = if is_collapsed {
1508            (IconName::ChevronRight, "Expand Project")
1509        } else {
1510            (IconName::ChevronDown, "Collapse Project")
1511        };
1512
1513        let key_for_toggle = key.clone();
1514        let key_for_collapse = key.clone();
1515        let view_more_expanded = self.expanded_groups.contains_key(key);
1516
1517        let label = if highlight_positions.is_empty() {
1518            Label::new(label.clone())
1519                .when(!is_active, |this| this.color(Color::Muted))
1520                .into_any_element()
1521        } else {
1522            HighlightedLabel::new(label.clone(), highlight_positions.to_vec())
1523                .when(!is_active, |this| this.color(Color::Muted))
1524                .into_any_element()
1525        };
1526
1527        let color = cx.theme().colors();
1528        let sidebar_base_bg = color
1529            .title_bar_background
1530            .blend(color.panel_background.opacity(0.25));
1531
1532        let base_bg = color.background.blend(sidebar_base_bg);
1533
1534        let hover_base = color
1535            .element_active
1536            .blend(color.element_background.opacity(0.2));
1537        let hover_solid = base_bg.blend(hover_base);
1538        let real_hover_color = if is_active { base_bg } else { hover_solid };
1539
1540        let group_name_for_gradient = group_name.clone();
1541        let gradient_overlay = move || {
1542            GradientFade::new(base_bg, real_hover_color, real_hover_color)
1543                .width(px(64.0))
1544                .right(px(-2.0))
1545                .gradient_stop(0.75)
1546                .group_name(group_name_for_gradient.clone())
1547        };
1548
1549        let is_ellipsis_menu_open = self.project_header_menu_ix == Some(ix);
1550
1551        h_flex()
1552            .id(id)
1553            .group(&group_name)
1554            .h(Tab::content_height(cx))
1555            .relative()
1556            .w_full()
1557            .pl(px(5.))
1558            .pr_1p5()
1559            .justify_between()
1560            .border_1()
1561            .map(|this| {
1562                if is_focused {
1563                    this.border_color(color.border_focused)
1564                } else {
1565                    this.border_color(gpui::transparent_black())
1566                }
1567            })
1568            .child(
1569                h_flex()
1570                    .relative()
1571                    .min_w_0()
1572                    .w_full()
1573                    .gap(px(5.))
1574                    .child(
1575                        IconButton::new(disclosure_id, disclosure_icon)
1576                            .shape(ui::IconButtonShape::Square)
1577                            .icon_size(IconSize::Small)
1578                            .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.5)))
1579                            .tooltip(Tooltip::text(disclosure_tooltip))
1580                            .on_click(cx.listener(move |this, _, window, cx| {
1581                                this.selection = None;
1582                                this.toggle_collapse(&key_for_toggle, window, cx);
1583                            })),
1584                    )
1585                    .child(label)
1586                    .when_some(
1587                        self.render_remote_project_icon(ix, host.as_ref()),
1588                        |this, icon| this.child(icon),
1589                    )
1590                    .when(is_collapsed, |this| {
1591                        this.when(has_running_threads, |this| {
1592                            this.child(
1593                                Icon::new(IconName::LoadCircle)
1594                                    .size(IconSize::XSmall)
1595                                    .color(Color::Muted)
1596                                    .with_rotate_animation(2),
1597                            )
1598                        })
1599                        .when(waiting_thread_count > 0, |this| {
1600                            let tooltip_text = if waiting_thread_count == 1 {
1601                                "1 thread is waiting for confirmation".to_string()
1602                            } else {
1603                                format!(
1604                                    "{waiting_thread_count} threads are waiting for confirmation",
1605                                )
1606                            };
1607                            this.child(
1608                                div()
1609                                    .id(format!("{id_prefix}waiting-indicator-{ix}"))
1610                                    .child(
1611                                        Icon::new(IconName::Warning)
1612                                            .size(IconSize::XSmall)
1613                                            .color(Color::Warning),
1614                                    )
1615                                    .tooltip(Tooltip::text(tooltip_text)),
1616                            )
1617                        })
1618                    }),
1619            )
1620            .child(gradient_overlay())
1621            .child(
1622                h_flex()
1623                    .when(!is_ellipsis_menu_open, |this| {
1624                        this.visible_on_hover(&group_name)
1625                    })
1626                    .child(gradient_overlay())
1627                    .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
1628                        cx.stop_propagation();
1629                    })
1630                    .child(self.render_project_header_ellipsis_menu(ix, id_prefix, key, cx))
1631                    .when(view_more_expanded && !is_collapsed, |this| {
1632                        this.child(
1633                            IconButton::new(
1634                                SharedString::from(format!(
1635                                    "{id_prefix}project-header-collapse-{ix}",
1636                                )),
1637                                IconName::ListCollapse,
1638                            )
1639                            .icon_size(IconSize::Small)
1640                            .tooltip(Tooltip::text("Collapse Displayed Threads"))
1641                            .on_click(cx.listener({
1642                                let key_for_collapse = key_for_collapse.clone();
1643                                move |this, _, _window, cx| {
1644                                    this.selection = None;
1645                                    this.expanded_groups.remove(&key_for_collapse);
1646                                    this.serialize(cx);
1647                                    this.update_entries(cx);
1648                                }
1649                            })),
1650                        )
1651                    })
1652                    .child({
1653                        let key = key.clone();
1654                        let focus_handle = self.focus_handle.clone();
1655
1656                        IconButton::new(
1657                            SharedString::from(format!(
1658                                "{id_prefix}project-header-new-thread-{ix}",
1659                            )),
1660                            IconName::Plus,
1661                        )
1662                        .icon_size(IconSize::Small)
1663                        .tooltip(move |_, cx| {
1664                            Tooltip::for_action_in(
1665                                "Start New Agent Thread",
1666                                &NewThread,
1667                                &focus_handle,
1668                                cx,
1669                            )
1670                        })
1671                        .on_click(cx.listener(
1672                            move |this, _, window, cx| {
1673                                this.collapsed_groups.remove(&key);
1674                                this.selection = None;
1675                                // If the active workspace belongs to this
1676                                // group, use it (preserves linked worktree
1677                                // context). Otherwise resolve from the key.
1678                                let workspace = this.multi_workspace.upgrade().and_then(|mw| {
1679                                    let mw = mw.read(cx);
1680                                    let active = mw.workspace().clone();
1681                                    let active_key = active.read(cx).project_group_key(cx);
1682                                    if active_key == key {
1683                                        Some(active)
1684                                    } else {
1685                                        mw.workspace_for_paths(
1686                                            key.path_list(),
1687                                            key.host().as_ref(),
1688                                            cx,
1689                                        )
1690                                    }
1691                                });
1692                                if let Some(workspace) = workspace {
1693                                    this.create_new_thread(&workspace, window, cx);
1694                                } else {
1695                                    this.open_workspace_and_create_draft(&key, window, cx);
1696                                }
1697                            },
1698                        ))
1699                    }),
1700            )
1701            .map(|this| {
1702                if !has_threads && is_active {
1703                    this
1704                } else {
1705                    let key = key.clone();
1706                    this.cursor_pointer()
1707                        .when(!is_active, |this| this.hover(|s| s.bg(hover_solid)))
1708                        .tooltip(Tooltip::text("Open Workspace"))
1709                        .on_click(cx.listener(move |this, _, window, cx| {
1710                            if let Some(workspace) = this.multi_workspace.upgrade().and_then(|mw| {
1711                                mw.read(cx).workspace_for_paths(
1712                                    key.path_list(),
1713                                    key.host().as_ref(),
1714                                    cx,
1715                                )
1716                            }) {
1717                                // Just activate the workspace. The
1718                                // AgentPanel remembers what was last
1719                                // shown, so the user returns to whatever
1720                                // thread/draft they were looking at.
1721                                this.activate_workspace(&workspace, window, cx);
1722                                if AgentPanel::is_visible(&workspace, cx) {
1723                                    workspace.update(cx, |workspace, cx| {
1724                                        workspace.focus_panel::<AgentPanel>(window, cx);
1725                                    });
1726                                }
1727                            } else {
1728                                this.open_workspace_for_group(&key, window, cx);
1729                            }
1730                        }))
1731                }
1732            })
1733            .into_any_element()
1734    }
1735
1736    fn render_project_header_ellipsis_menu(
1737        &self,
1738        ix: usize,
1739        id_prefix: &str,
1740        project_group_key: &ProjectGroupKey,
1741        cx: &mut Context<Self>,
1742    ) -> impl IntoElement {
1743        let multi_workspace = self.multi_workspace.clone();
1744        let this = cx.weak_entity();
1745        let project_group_key = project_group_key.clone();
1746
1747        PopoverMenu::new(format!("{id_prefix}project-header-menu-{ix}"))
1748            .on_open(Rc::new({
1749                let this = this.clone();
1750                move |_window, cx| {
1751                    this.update(cx, |sidebar, cx| {
1752                        sidebar.project_header_menu_ix = Some(ix);
1753                        cx.notify();
1754                    })
1755                    .ok();
1756                }
1757            }))
1758            .menu(move |window, cx| {
1759                let multi_workspace = multi_workspace.clone();
1760                let project_group_key = project_group_key.clone();
1761
1762                let menu =
1763                    ContextMenu::build_persistent(window, cx, move |menu, _window, menu_cx| {
1764                        let weak_menu = menu_cx.weak_entity();
1765                        let mut menu = menu
1766                            .header("Project Folders")
1767                            .end_slot_action(Box::new(menu::EndSlot));
1768
1769                        for path in project_group_key.path_list().paths() {
1770                            let Some(name) = path.file_name() else {
1771                                continue;
1772                            };
1773                            let name: SharedString = name.to_string_lossy().into_owned().into();
1774                            let path = path.clone();
1775                            let project_group_key = project_group_key.clone();
1776                            let multi_workspace = multi_workspace.clone();
1777                            let weak_menu = weak_menu.clone();
1778                            menu = menu.entry_with_end_slot_on_hover(
1779                                name.clone(),
1780                                None,
1781                                |_, _| {},
1782                                IconName::Close,
1783                                "Remove Folder".into(),
1784                                move |_window, cx| {
1785                                    multi_workspace
1786                                        .update(cx, |multi_workspace, cx| {
1787                                            multi_workspace.remove_folder_from_project_group(
1788                                                &project_group_key,
1789                                                &path,
1790                                                cx,
1791                                            );
1792                                        })
1793                                        .ok();
1794                                    weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
1795                                },
1796                            );
1797                        }
1798
1799                        let menu = menu.separator().entry(
1800                            "Add Folder to Project",
1801                            Some(Box::new(AddFolderToProject)),
1802                            {
1803                                let project_group_key = project_group_key.clone();
1804                                let multi_workspace = multi_workspace.clone();
1805                                let weak_menu = weak_menu.clone();
1806                                move |window, cx| {
1807                                    multi_workspace
1808                                        .update(cx, |multi_workspace, cx| {
1809                                            multi_workspace.prompt_to_add_folders_to_project_group(
1810                                                &project_group_key,
1811                                                window,
1812                                                cx,
1813                                            );
1814                                        })
1815                                        .ok();
1816                                    weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
1817                                }
1818                            },
1819                        );
1820
1821                        let project_group_key = project_group_key.clone();
1822                        let multi_workspace = multi_workspace.clone();
1823                        menu.separator()
1824                            .entry("Remove Project", None, move |window, cx| {
1825                                multi_workspace
1826                                    .update(cx, |multi_workspace, cx| {
1827                                        multi_workspace
1828                                            .remove_project_group(&project_group_key, window, cx)
1829                                            .detach_and_log_err(cx);
1830                                    })
1831                                    .ok();
1832                                weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
1833                            })
1834                    });
1835
1836                let this = this.clone();
1837                window
1838                    .subscribe(&menu, cx, move |_, _: &gpui::DismissEvent, _window, cx| {
1839                        this.update(cx, |sidebar, cx| {
1840                            sidebar.project_header_menu_ix = None;
1841                            cx.notify();
1842                        })
1843                        .ok();
1844                    })
1845                    .detach();
1846
1847                Some(menu)
1848            })
1849            .trigger(
1850                IconButton::new(
1851                    SharedString::from(format!("{id_prefix}-ellipsis-menu-{ix}")),
1852                    IconName::Ellipsis,
1853                )
1854                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1855                .icon_size(IconSize::Small),
1856            )
1857            .anchor(gpui::Corner::TopRight)
1858            .offset(gpui::Point {
1859                x: px(0.),
1860                y: px(1.),
1861            })
1862    }
1863
1864    fn render_sticky_header(
1865        &self,
1866        window: &mut Window,
1867        cx: &mut Context<Self>,
1868    ) -> Option<AnyElement> {
1869        let scroll_top = self.list_state.logical_scroll_top();
1870
1871        let &header_idx = self
1872            .contents
1873            .project_header_indices
1874            .iter()
1875            .rev()
1876            .find(|&&idx| idx <= scroll_top.item_ix)?;
1877
1878        let needs_sticky = header_idx < scroll_top.item_ix
1879            || (header_idx == scroll_top.item_ix && scroll_top.offset_in_item > px(0.));
1880
1881        if !needs_sticky {
1882            return None;
1883        }
1884
1885        let ListEntry::ProjectHeader {
1886            key,
1887            label,
1888            highlight_positions,
1889            has_running_threads,
1890            waiting_thread_count,
1891            is_active,
1892            has_threads,
1893        } = self.contents.entries.get(header_idx)?
1894        else {
1895            return None;
1896        };
1897
1898        let is_focused = self.focus_handle.is_focused(window);
1899        let is_selected = is_focused && self.selection == Some(header_idx);
1900
1901        let header_element = self.render_project_header(
1902            header_idx,
1903            true,
1904            key,
1905            &label,
1906            &highlight_positions,
1907            *has_running_threads,
1908            *waiting_thread_count,
1909            *is_active,
1910            *has_threads,
1911            is_selected,
1912            cx,
1913        );
1914
1915        let top_offset = self
1916            .contents
1917            .project_header_indices
1918            .iter()
1919            .find(|&&idx| idx > header_idx)
1920            .and_then(|&next_idx| {
1921                let bounds = self.list_state.bounds_for_item(next_idx)?;
1922                let viewport = self.list_state.viewport_bounds();
1923                let y_in_viewport = bounds.origin.y - viewport.origin.y;
1924                let header_height = bounds.size.height;
1925                (y_in_viewport < header_height).then_some(y_in_viewport - header_height)
1926            })
1927            .unwrap_or(px(0.));
1928
1929        let color = cx.theme().colors();
1930        let background = color
1931            .title_bar_background
1932            .blend(color.panel_background.opacity(0.2));
1933
1934        let element = v_flex()
1935            .absolute()
1936            .top(top_offset)
1937            .left_0()
1938            .w_full()
1939            .bg(background)
1940            .border_b_1()
1941            .border_color(color.border.opacity(0.5))
1942            .child(header_element)
1943            .shadow_xs()
1944            .into_any_element();
1945
1946        Some(element)
1947    }
1948
1949    fn toggle_collapse(
1950        &mut self,
1951        project_group_key: &ProjectGroupKey,
1952        _window: &mut Window,
1953        cx: &mut Context<Self>,
1954    ) {
1955        if self.collapsed_groups.contains(project_group_key) {
1956            self.collapsed_groups.remove(project_group_key);
1957        } else {
1958            self.collapsed_groups.insert(project_group_key.clone());
1959        }
1960        self.serialize(cx);
1961        self.update_entries(cx);
1962    }
1963
1964    fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
1965        let mut dispatch_context = KeyContext::new_with_defaults();
1966        dispatch_context.add("ThreadsSidebar");
1967        dispatch_context.add("menu");
1968
1969        let is_archived_search_focused = matches!(&self.view, SidebarView::Archive(archive) if archive.read(cx).is_filter_editor_focused(window, cx));
1970
1971        let identifier = if self.filter_editor.focus_handle(cx).is_focused(window)
1972            || is_archived_search_focused
1973        {
1974            "searching"
1975        } else {
1976            "not_searching"
1977        };
1978
1979        dispatch_context.add(identifier);
1980        dispatch_context
1981    }
1982
1983    fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1984        if !self.focus_handle.is_focused(window) {
1985            return;
1986        }
1987
1988        if let SidebarView::Archive(archive) = &self.view {
1989            let has_selection = archive.read(cx).has_selection();
1990            if !has_selection {
1991                archive.update(cx, |view, cx| view.focus_filter_editor(window, cx));
1992            }
1993        } else if self.selection.is_none() {
1994            self.filter_editor.focus_handle(cx).focus(window, cx);
1995        }
1996    }
1997
1998    fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
1999        if self.reset_filter_editor_text(window, cx) {
2000            self.update_entries(cx);
2001        } else {
2002            self.selection = None;
2003            self.filter_editor.focus_handle(cx).focus(window, cx);
2004            cx.notify();
2005        }
2006    }
2007
2008    fn focus_sidebar_filter(
2009        &mut self,
2010        _: &FocusSidebarFilter,
2011        window: &mut Window,
2012        cx: &mut Context<Self>,
2013    ) {
2014        self.selection = None;
2015        if let SidebarView::Archive(archive) = &self.view {
2016            archive.update(cx, |view, cx| {
2017                view.clear_selection();
2018                view.focus_filter_editor(window, cx);
2019            });
2020        } else {
2021            self.filter_editor.focus_handle(cx).focus(window, cx);
2022        }
2023
2024        // When vim mode is active, the editor defaults to normal mode which
2025        // blocks text input. Switch to insert mode so the user can type
2026        // immediately.
2027        if vim_mode_setting::VimModeSetting::get_global(cx).0 {
2028            if let Ok(action) = cx.build_action("vim::SwitchToInsertMode", None) {
2029                window.dispatch_action(action, cx);
2030            }
2031        }
2032
2033        cx.notify();
2034    }
2035
2036    fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
2037        self.filter_editor.update(cx, |editor, cx| {
2038            if editor.buffer().read(cx).len(cx).0 > 0 {
2039                editor.set_text("", window, cx);
2040                true
2041            } else {
2042                false
2043            }
2044        })
2045    }
2046
2047    fn has_filter_query(&self, cx: &App) -> bool {
2048        !self.filter_editor.read(cx).text(cx).is_empty()
2049    }
2050
2051    fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
2052        self.select_next(&SelectNext, window, cx);
2053        if self.selection.is_some() {
2054            self.focus_handle.focus(window, cx);
2055        }
2056    }
2057
2058    fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
2059        self.select_previous(&SelectPrevious, window, cx);
2060        if self.selection.is_some() {
2061            self.focus_handle.focus(window, cx);
2062        }
2063    }
2064
2065    fn editor_confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2066        if self.selection.is_none() {
2067            self.select_next(&SelectNext, window, cx);
2068        }
2069        if self.selection.is_some() {
2070            self.focus_handle.focus(window, cx);
2071        }
2072    }
2073
2074    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
2075        let next = match self.selection {
2076            Some(ix) if ix + 1 < self.contents.entries.len() => ix + 1,
2077            Some(_) if !self.contents.entries.is_empty() => 0,
2078            None if !self.contents.entries.is_empty() => 0,
2079            _ => return,
2080        };
2081        self.selection = Some(next);
2082        self.list_state.scroll_to_reveal_item(next);
2083        cx.notify();
2084    }
2085
2086    fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
2087        match self.selection {
2088            Some(0) => {
2089                self.selection = None;
2090                self.filter_editor.focus_handle(cx).focus(window, cx);
2091                cx.notify();
2092            }
2093            Some(ix) => {
2094                self.selection = Some(ix - 1);
2095                self.list_state.scroll_to_reveal_item(ix - 1);
2096                cx.notify();
2097            }
2098            None if !self.contents.entries.is_empty() => {
2099                let last = self.contents.entries.len() - 1;
2100                self.selection = Some(last);
2101                self.list_state.scroll_to_reveal_item(last);
2102                cx.notify();
2103            }
2104            None => {}
2105        }
2106    }
2107
2108    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
2109        if !self.contents.entries.is_empty() {
2110            self.selection = Some(0);
2111            self.list_state.scroll_to_reveal_item(0);
2112            cx.notify();
2113        }
2114    }
2115
2116    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
2117        if let Some(last) = self.contents.entries.len().checked_sub(1) {
2118            self.selection = Some(last);
2119            self.list_state.scroll_to_reveal_item(last);
2120            cx.notify();
2121        }
2122    }
2123
2124    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
2125        let Some(ix) = self.selection else { return };
2126        let Some(entry) = self.contents.entries.get(ix) else {
2127            return;
2128        };
2129
2130        match entry {
2131            ListEntry::ProjectHeader { key, .. } => {
2132                let key = key.clone();
2133                self.toggle_collapse(&key, window, cx);
2134            }
2135            ListEntry::Thread(thread) => {
2136                let metadata = thread.metadata.clone();
2137                match &thread.workspace {
2138                    ThreadEntryWorkspace::Open(workspace) => {
2139                        let workspace = workspace.clone();
2140                        self.activate_thread(metadata, &workspace, false, window, cx);
2141                    }
2142                    ThreadEntryWorkspace::Closed {
2143                        folder_paths,
2144                        project_group_key,
2145                    } => {
2146                        let folder_paths = folder_paths.clone();
2147                        let project_group_key = project_group_key.clone();
2148                        self.open_workspace_and_activate_thread(
2149                            metadata,
2150                            folder_paths,
2151                            &project_group_key,
2152                            window,
2153                            cx,
2154                        );
2155                    }
2156                }
2157            }
2158            ListEntry::ViewMore {
2159                key,
2160                is_fully_expanded,
2161                ..
2162            } => {
2163                let key = key.clone();
2164                if *is_fully_expanded {
2165                    self.reset_thread_group_expansion(&key, cx);
2166                } else {
2167                    self.expand_thread_group(&key, cx);
2168                }
2169            }
2170            ListEntry::DraftThread {
2171                draft_id,
2172                key,
2173                workspace,
2174                ..
2175            } => {
2176                let draft_id = *draft_id;
2177                let key = key.clone();
2178                let workspace = workspace.clone();
2179                if let Some(draft_id) = draft_id {
2180                    if let Some(workspace) = workspace {
2181                        self.activate_draft(draft_id, &workspace, window, cx);
2182                    }
2183                } else if let Some(workspace) = workspace {
2184                    self.activate_workspace(&workspace, window, cx);
2185                } else {
2186                    self.open_workspace_for_group(&key, window, cx);
2187                }
2188            }
2189        }
2190    }
2191
2192    fn find_workspace_across_windows(
2193        &self,
2194        cx: &App,
2195        predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
2196    ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
2197        cx.windows()
2198            .into_iter()
2199            .filter_map(|window| window.downcast::<MultiWorkspace>())
2200            .find_map(|window| {
2201                let workspace = window.read(cx).ok().and_then(|multi_workspace| {
2202                    multi_workspace
2203                        .workspaces()
2204                        .find(|workspace| predicate(workspace, cx))
2205                        .cloned()
2206                })?;
2207                Some((window, workspace))
2208            })
2209    }
2210
2211    fn find_workspace_in_current_window(
2212        &self,
2213        cx: &App,
2214        predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
2215    ) -> Option<Entity<Workspace>> {
2216        self.multi_workspace.upgrade().and_then(|multi_workspace| {
2217            multi_workspace
2218                .read(cx)
2219                .workspaces()
2220                .find(|workspace| predicate(workspace, cx))
2221                .cloned()
2222        })
2223    }
2224
2225    fn load_agent_thread_in_workspace(
2226        workspace: &Entity<Workspace>,
2227        metadata: &ThreadMetadata,
2228        focus: bool,
2229        window: &mut Window,
2230        cx: &mut App,
2231    ) {
2232        workspace.update(cx, |workspace, cx| {
2233            workspace.reveal_panel::<AgentPanel>(window, cx);
2234        });
2235
2236        if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2237            agent_panel.update(cx, |panel, cx| {
2238                panel.load_agent_thread(
2239                    Agent::from(metadata.agent_id.clone()),
2240                    metadata.session_id.clone(),
2241                    Some(metadata.folder_paths().clone()),
2242                    Some(metadata.title.clone()),
2243                    focus,
2244                    window,
2245                    cx,
2246                );
2247            });
2248        }
2249    }
2250
2251    fn activate_thread_locally(
2252        &mut self,
2253        metadata: &ThreadMetadata,
2254        workspace: &Entity<Workspace>,
2255        retain: bool,
2256        window: &mut Window,
2257        cx: &mut Context<Self>,
2258    ) {
2259        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2260            return;
2261        };
2262
2263        // Set active_entry eagerly so the sidebar highlight updates
2264        // immediately, rather than waiting for a deferred AgentPanel
2265        // event which can race with ActiveWorkspaceChanged clearing it.
2266        self.active_entry = Some(ActiveEntry::Thread {
2267            session_id: metadata.session_id.clone(),
2268            workspace: workspace.clone(),
2269        });
2270        self.record_thread_access(&metadata.session_id);
2271
2272        multi_workspace.update(cx, |multi_workspace, cx| {
2273            multi_workspace.activate(workspace.clone(), window, cx);
2274            if retain {
2275                multi_workspace.retain_active_workspace(cx);
2276            }
2277        });
2278
2279        Self::load_agent_thread_in_workspace(workspace, metadata, true, window, cx);
2280
2281        self.update_entries(cx);
2282    }
2283
2284    fn activate_thread_in_other_window(
2285        &self,
2286        metadata: ThreadMetadata,
2287        workspace: Entity<Workspace>,
2288        target_window: WindowHandle<MultiWorkspace>,
2289        cx: &mut Context<Self>,
2290    ) {
2291        let target_session_id = metadata.session_id.clone();
2292        let workspace_for_entry = workspace.clone();
2293
2294        let activated = target_window
2295            .update(cx, |multi_workspace, window, cx| {
2296                window.activate_window();
2297                multi_workspace.activate(workspace.clone(), window, cx);
2298                Self::load_agent_thread_in_workspace(&workspace, &metadata, true, window, cx);
2299            })
2300            .log_err()
2301            .is_some();
2302
2303        if activated {
2304            if let Some(target_sidebar) = target_window
2305                .read(cx)
2306                .ok()
2307                .and_then(|multi_workspace| {
2308                    multi_workspace.sidebar().map(|sidebar| sidebar.to_any())
2309                })
2310                .and_then(|sidebar| sidebar.downcast::<Self>().ok())
2311            {
2312                target_sidebar.update(cx, |sidebar, cx| {
2313                    sidebar.active_entry = Some(ActiveEntry::Thread {
2314                        session_id: target_session_id.clone(),
2315                        workspace: workspace_for_entry.clone(),
2316                    });
2317                    sidebar.record_thread_access(&target_session_id);
2318                    sidebar.update_entries(cx);
2319                });
2320            }
2321        }
2322    }
2323
2324    fn activate_thread(
2325        &mut self,
2326        metadata: ThreadMetadata,
2327        workspace: &Entity<Workspace>,
2328        retain: bool,
2329        window: &mut Window,
2330        cx: &mut Context<Self>,
2331    ) {
2332        if self
2333            .find_workspace_in_current_window(cx, |candidate, _| candidate == workspace)
2334            .is_some()
2335        {
2336            self.activate_thread_locally(&metadata, &workspace, retain, window, cx);
2337            return;
2338        }
2339
2340        let Some((target_window, workspace)) =
2341            self.find_workspace_across_windows(cx, |candidate, _| candidate == workspace)
2342        else {
2343            return;
2344        };
2345
2346        self.activate_thread_in_other_window(metadata, workspace, target_window, cx);
2347    }
2348
2349    fn open_workspace_and_activate_thread(
2350        &mut self,
2351        metadata: ThreadMetadata,
2352        folder_paths: PathList,
2353        project_group_key: &ProjectGroupKey,
2354        window: &mut Window,
2355        cx: &mut Context<Self>,
2356    ) {
2357        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2358            return;
2359        };
2360
2361        let pending_session_id = metadata.session_id.clone();
2362        // Mark the pending thread activation so rebuild_contents
2363        // preserves the Thread active_entry during loading (prevents
2364        // spurious draft flash).
2365        self.pending_remote_thread_activation = Some(pending_session_id.clone());
2366
2367        let host = project_group_key.host();
2368        let provisional_key = Some(project_group_key.clone());
2369        let active_workspace = multi_workspace.read(cx).workspace().clone();
2370        let modal_workspace = active_workspace.clone();
2371
2372        let open_task = multi_workspace.update(cx, |this, cx| {
2373            this.find_or_create_workspace(
2374                folder_paths,
2375                host,
2376                provisional_key,
2377                |options, window, cx| connect_remote(active_workspace, options, window, cx),
2378                window,
2379                cx,
2380            )
2381        });
2382
2383        cx.spawn_in(window, async move |this, cx| {
2384            let result = open_task.await;
2385            // Dismiss the modal as soon as the open attempt completes so
2386            // failures or cancellations do not leave a stale connection modal behind.
2387            remote_connection::dismiss_connection_modal(&modal_workspace, cx);
2388
2389            if result.is_err() {
2390                this.update(cx, |this, _cx| {
2391                    if this.pending_remote_thread_activation.as_ref() == Some(&pending_session_id) {
2392                        this.pending_remote_thread_activation = None;
2393                    }
2394                })
2395                .ok();
2396            }
2397
2398            let workspace = result?;
2399            this.update_in(cx, |this, window, cx| {
2400                this.activate_thread(metadata, &workspace, false, window, cx);
2401            })?;
2402            anyhow::Ok(())
2403        })
2404        .detach_and_log_err(cx);
2405    }
2406
2407    fn find_current_workspace_for_path_list(
2408        &self,
2409        path_list: &PathList,
2410        cx: &App,
2411    ) -> Option<Entity<Workspace>> {
2412        self.find_workspace_in_current_window(cx, |workspace, cx| {
2413            workspace_path_list(workspace, cx).paths() == path_list.paths()
2414        })
2415    }
2416
2417    fn find_open_workspace_for_path_list(
2418        &self,
2419        path_list: &PathList,
2420        cx: &App,
2421    ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
2422        self.find_workspace_across_windows(cx, |workspace, cx| {
2423            workspace_path_list(workspace, cx).paths() == path_list.paths()
2424        })
2425    }
2426
2427    fn activate_archived_thread(
2428        &mut self,
2429        metadata: ThreadMetadata,
2430        window: &mut Window,
2431        cx: &mut Context<Self>,
2432    ) {
2433        let session_id = metadata.session_id.clone();
2434        let weak_archive_view = match &self.view {
2435            SidebarView::Archive(view) => Some(view.downgrade()),
2436            _ => None,
2437        };
2438
2439        if metadata.folder_paths().paths().is_empty() {
2440            ThreadMetadataStore::global(cx)
2441                .update(cx, |store, cx| store.unarchive(&session_id, cx));
2442
2443            let active_workspace = self
2444                .multi_workspace
2445                .upgrade()
2446                .map(|w| w.read(cx).workspace().clone());
2447
2448            if let Some(workspace) = active_workspace {
2449                self.activate_thread_locally(&metadata, &workspace, false, window, cx);
2450            } else {
2451                let path_list = metadata.folder_paths().clone();
2452                if let Some((target_window, workspace)) =
2453                    self.find_open_workspace_for_path_list(&path_list, cx)
2454                {
2455                    self.activate_thread_in_other_window(metadata, workspace, target_window, cx);
2456                } else {
2457                    let key = ProjectGroupKey::new(None, path_list.clone());
2458                    self.open_workspace_and_activate_thread(metadata, path_list, &key, window, cx);
2459                }
2460            }
2461            self.show_thread_list(window, cx);
2462            return;
2463        }
2464
2465        let store = ThreadMetadataStore::global(cx);
2466        let task = store
2467            .read(cx)
2468            .get_archived_worktrees_for_thread(session_id.0.to_string(), cx);
2469        let path_list = metadata.folder_paths().clone();
2470
2471        let task_session_id = session_id.clone();
2472        let restore_task = cx.spawn_in(window, async move |this, cx| {
2473            let result: anyhow::Result<()> = async {
2474                let archived_worktrees = task.await?;
2475
2476                if archived_worktrees.is_empty() {
2477                    this.update_in(cx, |this, window, cx| {
2478                        this.restoring_tasks.remove(&session_id);
2479                        ThreadMetadataStore::global(cx)
2480                            .update(cx, |store, cx| store.unarchive(&session_id, cx));
2481
2482                        if let Some(workspace) =
2483                            this.find_current_workspace_for_path_list(&path_list, cx)
2484                        {
2485                            this.activate_thread_locally(&metadata, &workspace, false, window, cx);
2486                        } else if let Some((target_window, workspace)) =
2487                            this.find_open_workspace_for_path_list(&path_list, cx)
2488                        {
2489                            this.activate_thread_in_other_window(
2490                                metadata,
2491                                workspace,
2492                                target_window,
2493                                cx,
2494                            );
2495                        } else {
2496                            let key = ProjectGroupKey::new(None, path_list.clone());
2497                            this.open_workspace_and_activate_thread(
2498                                metadata, path_list, &key, window, cx,
2499                            );
2500                        }
2501                        this.show_thread_list(window, cx);
2502                    })?;
2503                    return anyhow::Ok(());
2504                }
2505
2506                let mut path_replacements: Vec<(PathBuf, PathBuf)> = Vec::new();
2507                for row in &archived_worktrees {
2508                    match thread_worktree_archive::restore_worktree_via_git(row, &mut *cx).await {
2509                        Ok(restored_path) => {
2510                            thread_worktree_archive::cleanup_archived_worktree_record(
2511                                row, &mut *cx,
2512                            )
2513                            .await;
2514                            path_replacements.push((row.worktree_path.clone(), restored_path));
2515                        }
2516                        Err(error) => {
2517                            log::error!("Failed to restore worktree: {error:#}");
2518                            this.update_in(cx, |this, _window, cx| {
2519                                this.restoring_tasks.remove(&session_id);
2520                                if let Some(weak_archive_view) = &weak_archive_view {
2521                                    weak_archive_view
2522                                        .update(cx, |view, cx| {
2523                                            view.clear_restoring(&session_id, cx);
2524                                        })
2525                                        .ok();
2526                                }
2527
2528                                if let Some(multi_workspace) = this.multi_workspace.upgrade() {
2529                                    let workspace = multi_workspace.read(cx).workspace().clone();
2530                                    workspace.update(cx, |workspace, cx| {
2531                                        struct RestoreWorktreeErrorToast;
2532                                        workspace.show_toast(
2533                                            Toast::new(
2534                                                NotificationId::unique::<RestoreWorktreeErrorToast>(
2535                                                ),
2536                                                format!("Failed to restore worktree: {error:#}"),
2537                                            )
2538                                            .autohide(),
2539                                            cx,
2540                                        );
2541                                    });
2542                                }
2543                            })
2544                            .ok();
2545                            return anyhow::Ok(());
2546                        }
2547                    }
2548                }
2549
2550                if !path_replacements.is_empty() {
2551                    cx.update(|_window, cx| {
2552                        store.update(cx, |store, cx| {
2553                            store.update_restored_worktree_paths(
2554                                &session_id,
2555                                &path_replacements,
2556                                cx,
2557                            );
2558                        });
2559                    })?;
2560
2561                    let updated_metadata =
2562                        cx.update(|_window, cx| store.read(cx).entry(&session_id).cloned())?;
2563
2564                    if let Some(updated_metadata) = updated_metadata {
2565                        let new_paths = updated_metadata.folder_paths().clone();
2566
2567                        cx.update(|_window, cx| {
2568                            store.update(cx, |store, cx| {
2569                                store.unarchive(&updated_metadata.session_id, cx);
2570                            });
2571                        })?;
2572
2573                        this.update_in(cx, |this, window, cx| {
2574                            this.restoring_tasks.remove(&session_id);
2575                            let key = ProjectGroupKey::new(None, new_paths.clone());
2576                            this.open_workspace_and_activate_thread(
2577                                updated_metadata,
2578                                new_paths,
2579                                &key,
2580                                window,
2581                                cx,
2582                            );
2583                            this.show_thread_list(window, cx);
2584                        })?;
2585                    }
2586                }
2587
2588                anyhow::Ok(())
2589            }
2590            .await;
2591            if let Err(error) = result {
2592                log::error!("{error:#}");
2593            }
2594        });
2595        self.restoring_tasks.insert(task_session_id, restore_task);
2596    }
2597
2598    fn expand_selected_entry(
2599        &mut self,
2600        _: &SelectChild,
2601        _window: &mut Window,
2602        cx: &mut Context<Self>,
2603    ) {
2604        let Some(ix) = self.selection else { return };
2605
2606        match self.contents.entries.get(ix) {
2607            Some(ListEntry::ProjectHeader { key, .. }) => {
2608                if self.collapsed_groups.contains(key) {
2609                    self.collapsed_groups.remove(key);
2610                    self.update_entries(cx);
2611                } else if ix + 1 < self.contents.entries.len() {
2612                    self.selection = Some(ix + 1);
2613                    self.list_state.scroll_to_reveal_item(ix + 1);
2614                    cx.notify();
2615                }
2616            }
2617            _ => {}
2618        }
2619    }
2620
2621    fn collapse_selected_entry(
2622        &mut self,
2623        _: &SelectParent,
2624        _window: &mut Window,
2625        cx: &mut Context<Self>,
2626    ) {
2627        let Some(ix) = self.selection else { return };
2628
2629        match self.contents.entries.get(ix) {
2630            Some(ListEntry::ProjectHeader { key, .. }) => {
2631                if !self.collapsed_groups.contains(key) {
2632                    self.collapsed_groups.insert(key.clone());
2633                    self.update_entries(cx);
2634                }
2635            }
2636            Some(
2637                ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::DraftThread { .. },
2638            ) => {
2639                for i in (0..ix).rev() {
2640                    if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(i)
2641                    {
2642                        self.selection = Some(i);
2643                        self.collapsed_groups.insert(key.clone());
2644                        self.update_entries(cx);
2645                        break;
2646                    }
2647                }
2648            }
2649            None => {}
2650        }
2651    }
2652
2653    fn toggle_selected_fold(
2654        &mut self,
2655        _: &editor::actions::ToggleFold,
2656        _window: &mut Window,
2657        cx: &mut Context<Self>,
2658    ) {
2659        let Some(ix) = self.selection else { return };
2660
2661        // Find the group header for the current selection.
2662        let header_ix = match self.contents.entries.get(ix) {
2663            Some(ListEntry::ProjectHeader { .. }) => Some(ix),
2664            Some(
2665                ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::DraftThread { .. },
2666            ) => (0..ix).rev().find(|&i| {
2667                matches!(
2668                    self.contents.entries.get(i),
2669                    Some(ListEntry::ProjectHeader { .. })
2670                )
2671            }),
2672            None => None,
2673        };
2674
2675        if let Some(header_ix) = header_ix {
2676            if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(header_ix)
2677            {
2678                if self.collapsed_groups.contains(key) {
2679                    self.collapsed_groups.remove(key);
2680                } else {
2681                    self.selection = Some(header_ix);
2682                    self.collapsed_groups.insert(key.clone());
2683                }
2684                self.update_entries(cx);
2685            }
2686        }
2687    }
2688
2689    fn fold_all(
2690        &mut self,
2691        _: &editor::actions::FoldAll,
2692        _window: &mut Window,
2693        cx: &mut Context<Self>,
2694    ) {
2695        for entry in &self.contents.entries {
2696            if let ListEntry::ProjectHeader { key, .. } = entry {
2697                self.collapsed_groups.insert(key.clone());
2698            }
2699        }
2700        self.update_entries(cx);
2701    }
2702
2703    fn unfold_all(
2704        &mut self,
2705        _: &editor::actions::UnfoldAll,
2706        _window: &mut Window,
2707        cx: &mut Context<Self>,
2708    ) {
2709        self.collapsed_groups.clear();
2710        self.update_entries(cx);
2711    }
2712
2713    fn stop_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
2714        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2715            return;
2716        };
2717
2718        let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect();
2719        for workspace in workspaces {
2720            if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2721                let cancelled =
2722                    agent_panel.update(cx, |panel, cx| panel.cancel_thread(session_id, cx));
2723                if cancelled {
2724                    return;
2725                }
2726            }
2727        }
2728    }
2729
2730    fn archive_thread(
2731        &mut self,
2732        session_id: &acp::SessionId,
2733        window: &mut Window,
2734        cx: &mut Context<Self>,
2735    ) {
2736        let metadata = ThreadMetadataStore::global(cx)
2737            .read(cx)
2738            .entry(session_id)
2739            .cloned();
2740        let thread_folder_paths = metadata.as_ref().map(|m| m.folder_paths().clone());
2741
2742        // Compute which linked worktree roots should be archived from disk if
2743        // this thread is archived. This must happen before we remove any
2744        // workspace from the MultiWorkspace, because `build_root_plan` needs
2745        // the currently open workspaces in order to find the affected projects
2746        // and repository handles for each linked worktree.
2747        let roots_to_archive = metadata
2748            .as_ref()
2749            .map(|metadata| {
2750                let mut workspaces = self
2751                    .multi_workspace
2752                    .upgrade()
2753                    .map(|multi_workspace| {
2754                        multi_workspace
2755                            .read(cx)
2756                            .workspaces()
2757                            .cloned()
2758                            .collect::<Vec<_>>()
2759                    })
2760                    .unwrap_or_default();
2761                for workspace in thread_worktree_archive::all_open_workspaces(cx) {
2762                    if !workspaces.contains(&workspace) {
2763                        workspaces.push(workspace);
2764                    }
2765                }
2766                metadata
2767                    .folder_paths()
2768                    .ordered_paths()
2769                    .filter_map(|path| {
2770                        thread_worktree_archive::build_root_plan(path, &workspaces, cx)
2771                    })
2772                    .filter(|plan| {
2773                        !thread_worktree_archive::path_is_referenced_by_other_unarchived_threads(
2774                            session_id,
2775                            &plan.root_path,
2776                            cx,
2777                        )
2778                    })
2779                    .collect::<Vec<_>>()
2780            })
2781            .unwrap_or_default();
2782
2783        // Find the neighbor thread in the sidebar (by display position).
2784        // Look below first, then above, for the nearest thread that isn't
2785        // the one being archived. We capture both the neighbor's metadata
2786        // (for activation) and its workspace paths (for the workspace
2787        // removal fallback).
2788        let current_pos = self.contents.entries.iter().position(
2789            |entry| matches!(entry, ListEntry::Thread(t) if &t.metadata.session_id == session_id),
2790        );
2791        let neighbor = current_pos.and_then(|pos| {
2792            self.contents.entries[pos + 1..]
2793                .iter()
2794                .chain(self.contents.entries[..pos].iter().rev())
2795                .find_map(|entry| match entry {
2796                    ListEntry::Thread(t) if t.metadata.session_id != *session_id => {
2797                        let workspace_paths = match &t.workspace {
2798                            ThreadEntryWorkspace::Open(ws) => {
2799                                PathList::new(&ws.read(cx).root_paths(cx))
2800                            }
2801                            ThreadEntryWorkspace::Closed { folder_paths, .. } => {
2802                                folder_paths.clone()
2803                            }
2804                        };
2805                        Some((t.metadata.clone(), workspace_paths))
2806                    }
2807                    _ => None,
2808                })
2809        });
2810
2811        // Check if archiving this thread would leave its worktree workspace
2812        // with no threads, requiring workspace removal.
2813        let workspace_to_remove = thread_folder_paths.as_ref().and_then(|folder_paths| {
2814            if folder_paths.is_empty() {
2815                return None;
2816            }
2817
2818            let remaining = ThreadMetadataStore::global(cx)
2819                .read(cx)
2820                .entries_for_path(folder_paths)
2821                .filter(|t| t.session_id != *session_id)
2822                .count();
2823
2824            if remaining > 0 {
2825                return None;
2826            }
2827
2828            let multi_workspace = self.multi_workspace.upgrade()?;
2829            let workspace = multi_workspace
2830                .read(cx)
2831                .workspace_for_paths(folder_paths, None, cx)?;
2832
2833            let group_key = workspace.read(cx).project_group_key(cx);
2834            let is_linked_worktree = group_key.path_list() != folder_paths;
2835
2836            is_linked_worktree.then_some(workspace)
2837        });
2838
2839        if let Some(workspace_to_remove) = workspace_to_remove {
2840            let multi_workspace = self.multi_workspace.upgrade().unwrap();
2841            let session_id = session_id.clone();
2842
2843            // For the workspace-removal fallback, use the neighbor's workspace
2844            // paths if available, otherwise fall back to the project group key.
2845            let fallback_paths = neighbor
2846                .as_ref()
2847                .map(|(_, paths)| paths.clone())
2848                .unwrap_or_else(|| {
2849                    workspace_to_remove
2850                        .read(cx)
2851                        .project_group_key(cx)
2852                        .path_list()
2853                        .clone()
2854                });
2855
2856            let remove_task = multi_workspace.update(cx, |mw, cx| {
2857                mw.remove(
2858                    [workspace_to_remove],
2859                    move |this, window, cx| {
2860                        this.find_or_create_local_workspace(fallback_paths, window, cx)
2861                    },
2862                    window,
2863                    cx,
2864                )
2865            });
2866
2867            let neighbor_metadata = neighbor.map(|(metadata, _)| metadata);
2868            let thread_folder_paths = thread_folder_paths.clone();
2869            cx.spawn_in(window, async move |this, cx| {
2870                let removed = remove_task.await?;
2871                if removed {
2872                    this.update_in(cx, |this, window, cx| {
2873                        let in_flight =
2874                            this.start_archive_worktree_task(&session_id, roots_to_archive, cx);
2875                        this.archive_and_activate(
2876                            &session_id,
2877                            neighbor_metadata.as_ref(),
2878                            thread_folder_paths.as_ref(),
2879                            in_flight,
2880                            window,
2881                            cx,
2882                        );
2883                    })?;
2884                }
2885                anyhow::Ok(())
2886            })
2887            .detach_and_log_err(cx);
2888        } else {
2889            let neighbor_metadata = neighbor.map(|(metadata, _)| metadata);
2890            let in_flight = self.start_archive_worktree_task(session_id, roots_to_archive, cx);
2891            self.archive_and_activate(
2892                session_id,
2893                neighbor_metadata.as_ref(),
2894                thread_folder_paths.as_ref(),
2895                in_flight,
2896                window,
2897                cx,
2898            );
2899        }
2900    }
2901
2902    /// Archive a thread and activate the nearest neighbor or a draft.
2903    ///
2904    /// IMPORTANT: when activating a neighbor or creating a fallback draft,
2905    /// this method also activates the target workspace in the MultiWorkspace.
2906    /// This is critical because `rebuild_contents` derives the active
2907    /// workspace from `mw.workspace()`. If the linked worktree workspace is
2908    /// still active after archiving its last thread, `rebuild_contents` sees
2909    /// the threadless linked worktree as active and emits a spurious
2910    /// "+ New Thread" entry with the worktree chip — keeping the worktree
2911    /// alive and preventing disk cleanup.
2912    ///
2913    /// When `in_flight_archive` is present, it is the background task that
2914    /// persists the linked worktree's git state and deletes it from disk.
2915    /// We attach it to the metadata store at the same time we mark the thread
2916    /// archived so failures can automatically unarchive the thread and user-
2917    /// initiated unarchive can cancel the task.
2918    fn archive_and_activate(
2919        &mut self,
2920        session_id: &acp::SessionId,
2921        neighbor: Option<&ThreadMetadata>,
2922        thread_folder_paths: Option<&PathList>,
2923        in_flight_archive: Option<(Task<()>, smol::channel::Sender<()>)>,
2924        window: &mut Window,
2925        cx: &mut Context<Self>,
2926    ) {
2927        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
2928            store.archive(session_id, in_flight_archive, cx);
2929        });
2930
2931        let is_active = self
2932            .active_entry
2933            .as_ref()
2934            .is_some_and(|e| e.is_active_thread(session_id));
2935
2936        if !is_active {
2937            // The user is looking at a different thread/draft. Clear the
2938            // archived thread from its workspace's panel so that switching
2939            // to that workspace later doesn't show a stale thread.
2940            if let Some(folder_paths) = thread_folder_paths {
2941                if let Some(workspace) = self
2942                    .multi_workspace
2943                    .upgrade()
2944                    .and_then(|mw| mw.read(cx).workspace_for_paths(folder_paths, None, cx))
2945                {
2946                    if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2947                        let panel_shows_archived = panel
2948                            .read(cx)
2949                            .active_conversation_view()
2950                            .and_then(|cv| cv.read(cx).parent_id(cx))
2951                            .is_some_and(|id| id == *session_id);
2952                        if panel_shows_archived {
2953                            panel.update(cx, |panel, cx| {
2954                                // Replace the archived thread with a
2955                                // tracked draft so the panel isn't left
2956                                // in Uninitialized state.
2957                                let id = panel.create_draft(window, cx);
2958                                panel.activate_draft(id, false, window, cx);
2959                            });
2960                        }
2961                    }
2962                }
2963            }
2964            return;
2965        }
2966
2967        // Try to activate the neighbor thread. If its workspace is open,
2968        // tell the panel to load it and activate that workspace.
2969        // `rebuild_contents` will reconcile `active_entry` once the thread
2970        // finishes loading.
2971
2972        if let Some(metadata) = neighbor {
2973            if let Some(workspace) = self.multi_workspace.upgrade().and_then(|mw| {
2974                mw.read(cx)
2975                    .workspace_for_paths(metadata.folder_paths(), None, cx)
2976            }) {
2977                self.activate_workspace(&workspace, window, cx);
2978                Self::load_agent_thread_in_workspace(&workspace, metadata, true, window, cx);
2979                return;
2980            }
2981        }
2982
2983        // No neighbor or its workspace isn't open — fall back to a new
2984        // draft. Use the group workspace (main project) rather than the
2985        // active entry workspace, which may be a linked worktree that is
2986        // about to be cleaned up or already removed.
2987        let fallback_workspace = thread_folder_paths
2988            .and_then(|folder_paths| {
2989                let mw = self.multi_workspace.upgrade()?;
2990                let mw = mw.read(cx);
2991                let thread_workspace = mw.workspace_for_paths(folder_paths, None, cx)?;
2992                let group_key = thread_workspace.read(cx).project_group_key(cx);
2993                mw.workspace_for_paths(group_key.path_list(), None, cx)
2994            })
2995            .or_else(|| {
2996                self.multi_workspace
2997                    .upgrade()
2998                    .map(|mw| mw.read(cx).workspace().clone())
2999            });
3000
3001        if let Some(workspace) = fallback_workspace {
3002            self.activate_workspace(&workspace, window, cx);
3003            self.create_new_thread(&workspace, window, cx);
3004        }
3005    }
3006
3007    fn start_archive_worktree_task(
3008        &self,
3009        session_id: &acp::SessionId,
3010        roots: Vec<thread_worktree_archive::RootPlan>,
3011        cx: &mut Context<Self>,
3012    ) -> Option<(Task<()>, smol::channel::Sender<()>)> {
3013        if roots.is_empty() {
3014            return None;
3015        }
3016
3017        let (cancel_tx, cancel_rx) = smol::channel::bounded::<()>(1);
3018        let session_id = session_id.clone();
3019        let task = cx.spawn(async move |_this, cx| {
3020            match Self::archive_worktree_roots(roots, cancel_rx, cx).await {
3021                Ok(ArchiveWorktreeOutcome::Success) => {
3022                    cx.update(|cx| {
3023                        ThreadMetadataStore::global(cx).update(cx, |store, _cx| {
3024                            store.cleanup_completed_archive(&session_id);
3025                        });
3026                    });
3027                }
3028                Ok(ArchiveWorktreeOutcome::Cancelled) => {}
3029                Err(error) => {
3030                    log::error!("Failed to archive worktree: {error:#}");
3031                    cx.update(|cx| {
3032                        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
3033                            store.unarchive(&session_id, cx);
3034                        });
3035                    });
3036                }
3037            }
3038        });
3039
3040        Some((task, cancel_tx))
3041    }
3042
3043    async fn archive_worktree_roots(
3044        roots: Vec<thread_worktree_archive::RootPlan>,
3045        cancel_rx: smol::channel::Receiver<()>,
3046        cx: &mut gpui::AsyncApp,
3047    ) -> anyhow::Result<ArchiveWorktreeOutcome> {
3048        let mut completed_persists: Vec<(i64, thread_worktree_archive::RootPlan)> = Vec::new();
3049
3050        for root in &roots {
3051            if cancel_rx.is_closed() {
3052                for &(id, ref completed_root) in completed_persists.iter().rev() {
3053                    thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
3054                }
3055                return Ok(ArchiveWorktreeOutcome::Cancelled);
3056            }
3057
3058            if root.worktree_repo.is_some() {
3059                match thread_worktree_archive::persist_worktree_state(root, cx).await {
3060                    Ok(id) => {
3061                        completed_persists.push((id, root.clone()));
3062                    }
3063                    Err(error) => {
3064                        for &(id, ref completed_root) in completed_persists.iter().rev() {
3065                            thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
3066                        }
3067                        return Err(error);
3068                    }
3069                }
3070            }
3071
3072            if cancel_rx.is_closed() {
3073                for &(id, ref completed_root) in completed_persists.iter().rev() {
3074                    thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
3075                }
3076                return Ok(ArchiveWorktreeOutcome::Cancelled);
3077            }
3078
3079            if let Err(error) = thread_worktree_archive::remove_root(root.clone(), cx).await {
3080                if let Some(&(id, ref completed_root)) = completed_persists.last() {
3081                    if completed_root.root_path == root.root_path {
3082                        thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
3083                        completed_persists.pop();
3084                    }
3085                }
3086                for &(id, ref completed_root) in completed_persists.iter().rev() {
3087                    thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
3088                }
3089                return Err(error);
3090            }
3091        }
3092
3093        Ok(ArchiveWorktreeOutcome::Success)
3094    }
3095
3096    fn activate_workspace(
3097        &self,
3098        workspace: &Entity<Workspace>,
3099        window: &mut Window,
3100        cx: &mut Context<Self>,
3101    ) {
3102        if let Some(multi_workspace) = self.multi_workspace.upgrade() {
3103            multi_workspace.update(cx, |mw, cx| {
3104                mw.activate(workspace.clone(), window, cx);
3105            });
3106        }
3107    }
3108
3109    fn remove_selected_thread(
3110        &mut self,
3111        _: &RemoveSelectedThread,
3112        window: &mut Window,
3113        cx: &mut Context<Self>,
3114    ) {
3115        let Some(ix) = self.selection else {
3116            return;
3117        };
3118        match self.contents.entries.get(ix) {
3119            Some(ListEntry::Thread(thread)) => {
3120                match thread.status {
3121                    AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation => {
3122                        return;
3123                    }
3124                    AgentThreadStatus::Completed | AgentThreadStatus::Error => {}
3125                }
3126                let session_id = thread.metadata.session_id.clone();
3127                self.archive_thread(&session_id, window, cx);
3128            }
3129            Some(ListEntry::DraftThread {
3130                draft_id: Some(draft_id),
3131                workspace: Some(workspace),
3132                ..
3133            }) => {
3134                let draft_id = *draft_id;
3135                let workspace = workspace.clone();
3136                self.remove_draft(draft_id, &workspace, window, cx);
3137            }
3138            _ => {}
3139        }
3140    }
3141
3142    fn record_thread_access(&mut self, session_id: &acp::SessionId) {
3143        self.thread_last_accessed
3144            .insert(session_id.clone(), Utc::now());
3145    }
3146
3147    fn record_thread_message_sent(&mut self, session_id: &acp::SessionId) {
3148        self.thread_last_message_sent_or_queued
3149            .insert(session_id.clone(), Utc::now());
3150    }
3151
3152    fn mru_threads_for_switcher(&self, cx: &App) -> Vec<ThreadSwitcherEntry> {
3153        let mut current_header_label: Option<SharedString> = None;
3154        let mut current_header_key: Option<ProjectGroupKey> = None;
3155        let mut entries: Vec<ThreadSwitcherEntry> = self
3156            .contents
3157            .entries
3158            .iter()
3159            .filter_map(|entry| match entry {
3160                ListEntry::ProjectHeader { label, key, .. } => {
3161                    current_header_label = Some(label.clone());
3162                    current_header_key = Some(key.clone());
3163                    None
3164                }
3165                ListEntry::Thread(thread) => {
3166                    let workspace = match &thread.workspace {
3167                        ThreadEntryWorkspace::Open(workspace) => Some(workspace.clone()),
3168                        ThreadEntryWorkspace::Closed { .. } => {
3169                            current_header_key.as_ref().and_then(|key| {
3170                                self.multi_workspace.upgrade().and_then(|mw| {
3171                                    mw.read(cx).workspace_for_paths(
3172                                        key.path_list(),
3173                                        key.host().as_ref(),
3174                                        cx,
3175                                    )
3176                                })
3177                            })
3178                        }
3179                    }?;
3180                    let notified = self
3181                        .contents
3182                        .is_thread_notified(&thread.metadata.session_id);
3183                    let timestamp: SharedString = format_history_entry_timestamp(
3184                        self.thread_last_message_sent_or_queued
3185                            .get(&thread.metadata.session_id)
3186                            .copied()
3187                            .or(thread.metadata.created_at)
3188                            .unwrap_or(thread.metadata.updated_at),
3189                    )
3190                    .into();
3191                    Some(ThreadSwitcherEntry {
3192                        session_id: thread.metadata.session_id.clone(),
3193                        title: thread.metadata.title.clone(),
3194                        icon: thread.icon,
3195                        icon_from_external_svg: thread.icon_from_external_svg.clone(),
3196                        status: thread.status,
3197                        metadata: thread.metadata.clone(),
3198                        workspace,
3199                        project_name: current_header_label.clone(),
3200                        worktrees: thread
3201                            .worktrees
3202                            .iter()
3203                            .map(|wt| ThreadItemWorktreeInfo {
3204                                name: wt.name.clone(),
3205                                full_path: wt.full_path.clone(),
3206                                highlight_positions: Vec::new(),
3207                                kind: wt.kind,
3208                            })
3209                            .collect(),
3210                        diff_stats: thread.diff_stats,
3211                        is_title_generating: thread.is_title_generating,
3212                        notified,
3213                        timestamp,
3214                    })
3215                }
3216                _ => None,
3217            })
3218            .collect();
3219
3220        entries.sort_by(|a, b| {
3221            let a_accessed = self.thread_last_accessed.get(&a.session_id);
3222            let b_accessed = self.thread_last_accessed.get(&b.session_id);
3223
3224            match (a_accessed, b_accessed) {
3225                (Some(a_time), Some(b_time)) => b_time.cmp(a_time),
3226                (Some(_), None) => std::cmp::Ordering::Less,
3227                (None, Some(_)) => std::cmp::Ordering::Greater,
3228                (None, None) => {
3229                    let a_sent = self.thread_last_message_sent_or_queued.get(&a.session_id);
3230                    let b_sent = self.thread_last_message_sent_or_queued.get(&b.session_id);
3231
3232                    match (a_sent, b_sent) {
3233                        (Some(a_time), Some(b_time)) => b_time.cmp(a_time),
3234                        (Some(_), None) => std::cmp::Ordering::Less,
3235                        (None, Some(_)) => std::cmp::Ordering::Greater,
3236                        (None, None) => {
3237                            let a_time = a.metadata.created_at.or(Some(a.metadata.updated_at));
3238                            let b_time = b.metadata.created_at.or(Some(b.metadata.updated_at));
3239                            b_time.cmp(&a_time)
3240                        }
3241                    }
3242                }
3243            }
3244        });
3245
3246        entries
3247    }
3248
3249    fn dismiss_thread_switcher(&mut self, cx: &mut Context<Self>) {
3250        self.thread_switcher = None;
3251        self._thread_switcher_subscriptions.clear();
3252        if let Some(mw) = self.multi_workspace.upgrade() {
3253            mw.update(cx, |mw, cx| {
3254                mw.set_sidebar_overlay(None, cx);
3255            });
3256        }
3257    }
3258
3259    fn on_toggle_thread_switcher(
3260        &mut self,
3261        action: &ToggleThreadSwitcher,
3262        window: &mut Window,
3263        cx: &mut Context<Self>,
3264    ) {
3265        self.toggle_thread_switcher_impl(action.select_last, window, cx);
3266    }
3267
3268    fn toggle_thread_switcher_impl(
3269        &mut self,
3270        select_last: bool,
3271        window: &mut Window,
3272        cx: &mut Context<Self>,
3273    ) {
3274        if let Some(thread_switcher) = &self.thread_switcher {
3275            thread_switcher.update(cx, |switcher, cx| {
3276                if select_last {
3277                    switcher.select_last(cx);
3278                } else {
3279                    switcher.cycle_selection(cx);
3280                }
3281            });
3282            return;
3283        }
3284
3285        let entries = self.mru_threads_for_switcher(cx);
3286        if entries.len() < 2 {
3287            return;
3288        }
3289
3290        let weak_multi_workspace = self.multi_workspace.clone();
3291
3292        let original_metadata = match &self.active_entry {
3293            Some(ActiveEntry::Thread { session_id, .. }) => entries
3294                .iter()
3295                .find(|e| &e.session_id == session_id)
3296                .map(|e| e.metadata.clone()),
3297            _ => None,
3298        };
3299        let original_workspace = self
3300            .multi_workspace
3301            .upgrade()
3302            .map(|mw| mw.read(cx).workspace().clone());
3303
3304        let thread_switcher = cx.new(|cx| ThreadSwitcher::new(entries, select_last, window, cx));
3305
3306        let mut subscriptions = Vec::new();
3307
3308        subscriptions.push(cx.subscribe_in(&thread_switcher, window, {
3309            let thread_switcher = thread_switcher.clone();
3310            move |this, _emitter, event: &ThreadSwitcherEvent, window, cx| match event {
3311                ThreadSwitcherEvent::Preview {
3312                    metadata,
3313                    workspace,
3314                } => {
3315                    if let Some(mw) = weak_multi_workspace.upgrade() {
3316                        mw.update(cx, |mw, cx| {
3317                            mw.activate(workspace.clone(), window, cx);
3318                        });
3319                    }
3320                    this.active_entry = Some(ActiveEntry::Thread {
3321                        session_id: metadata.session_id.clone(),
3322                        workspace: workspace.clone(),
3323                    });
3324                    this.update_entries(cx);
3325                    Self::load_agent_thread_in_workspace(workspace, metadata, false, window, cx);
3326                    let focus = thread_switcher.focus_handle(cx);
3327                    window.focus(&focus, cx);
3328                }
3329                ThreadSwitcherEvent::Confirmed {
3330                    metadata,
3331                    workspace,
3332                } => {
3333                    if let Some(mw) = weak_multi_workspace.upgrade() {
3334                        mw.update(cx, |mw, cx| {
3335                            mw.activate(workspace.clone(), window, cx);
3336                            mw.retain_active_workspace(cx);
3337                        });
3338                    }
3339                    this.record_thread_access(&metadata.session_id);
3340                    this.active_entry = Some(ActiveEntry::Thread {
3341                        session_id: metadata.session_id.clone(),
3342                        workspace: workspace.clone(),
3343                    });
3344                    this.update_entries(cx);
3345                    Self::load_agent_thread_in_workspace(workspace, metadata, false, window, cx);
3346                    this.dismiss_thread_switcher(cx);
3347                    workspace.update(cx, |workspace, cx| {
3348                        workspace.focus_panel::<AgentPanel>(window, cx);
3349                    });
3350                }
3351                ThreadSwitcherEvent::Dismissed => {
3352                    if let Some(mw) = weak_multi_workspace.upgrade() {
3353                        if let Some(original_ws) = &original_workspace {
3354                            mw.update(cx, |mw, cx| {
3355                                mw.activate(original_ws.clone(), window, cx);
3356                            });
3357                        }
3358                    }
3359                    if let Some(metadata) = &original_metadata {
3360                        if let Some(original_ws) = &original_workspace {
3361                            this.active_entry = Some(ActiveEntry::Thread {
3362                                session_id: metadata.session_id.clone(),
3363                                workspace: original_ws.clone(),
3364                            });
3365                        }
3366                        this.update_entries(cx);
3367                        if let Some(original_ws) = &original_workspace {
3368                            Self::load_agent_thread_in_workspace(
3369                                original_ws,
3370                                metadata,
3371                                false,
3372                                window,
3373                                cx,
3374                            );
3375                        }
3376                    }
3377                    this.dismiss_thread_switcher(cx);
3378                }
3379            }
3380        }));
3381
3382        subscriptions.push(cx.subscribe_in(
3383            &thread_switcher,
3384            window,
3385            |this, _emitter, _event: &gpui::DismissEvent, _window, cx| {
3386                this.dismiss_thread_switcher(cx);
3387            },
3388        ));
3389
3390        let focus = thread_switcher.focus_handle(cx);
3391        let overlay_view = gpui::AnyView::from(thread_switcher.clone());
3392
3393        // Replay the initial preview that was emitted during construction
3394        // before subscriptions were wired up.
3395        let initial_preview = thread_switcher
3396            .read(cx)
3397            .selected_entry()
3398            .map(|entry| (entry.metadata.clone(), entry.workspace.clone()));
3399
3400        self.thread_switcher = Some(thread_switcher);
3401        self._thread_switcher_subscriptions = subscriptions;
3402        if let Some(mw) = self.multi_workspace.upgrade() {
3403            mw.update(cx, |mw, cx| {
3404                mw.set_sidebar_overlay(Some(overlay_view), cx);
3405            });
3406        }
3407
3408        if let Some((metadata, workspace)) = initial_preview {
3409            if let Some(mw) = self.multi_workspace.upgrade() {
3410                mw.update(cx, |mw, cx| {
3411                    mw.activate(workspace.clone(), window, cx);
3412                });
3413            }
3414            self.active_entry = Some(ActiveEntry::Thread {
3415                session_id: metadata.session_id.clone(),
3416                workspace: workspace.clone(),
3417            });
3418            self.update_entries(cx);
3419            Self::load_agent_thread_in_workspace(&workspace, &metadata, false, window, cx);
3420        }
3421
3422        window.focus(&focus, cx);
3423    }
3424
3425    fn render_thread(
3426        &self,
3427        ix: usize,
3428        thread: &ThreadEntry,
3429        is_active: bool,
3430        is_focused: bool,
3431        cx: &mut Context<Self>,
3432    ) -> AnyElement {
3433        let has_notification = self
3434            .contents
3435            .is_thread_notified(&thread.metadata.session_id);
3436
3437        let title: SharedString = thread.metadata.title.clone();
3438        let metadata = thread.metadata.clone();
3439        let thread_workspace = thread.workspace.clone();
3440
3441        let is_hovered = self.hovered_thread_index == Some(ix);
3442        let is_selected = is_active;
3443        let is_running = matches!(
3444            thread.status,
3445            AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
3446        );
3447
3448        let session_id_for_delete = thread.metadata.session_id.clone();
3449        let focus_handle = self.focus_handle.clone();
3450
3451        let id = SharedString::from(format!("thread-entry-{}", ix));
3452
3453        let color = cx.theme().colors();
3454        let sidebar_bg = color
3455            .title_bar_background
3456            .blend(color.panel_background.opacity(0.25));
3457
3458        let timestamp = format_history_entry_timestamp(
3459            self.thread_last_message_sent_or_queued
3460                .get(&thread.metadata.session_id)
3461                .copied()
3462                .or(thread.metadata.created_at)
3463                .unwrap_or(thread.metadata.updated_at),
3464        );
3465
3466        let is_remote = thread.workspace.is_remote(cx);
3467
3468        ThreadItem::new(id, title)
3469            .base_bg(sidebar_bg)
3470            .icon(thread.icon)
3471            .status(thread.status)
3472            .is_remote(is_remote)
3473            .when_some(thread.icon_from_external_svg.clone(), |this, svg| {
3474                this.custom_icon_from_external_svg(svg)
3475            })
3476            .worktrees(
3477                thread
3478                    .worktrees
3479                    .iter()
3480                    .map(|wt| ThreadItemWorktreeInfo {
3481                        name: wt.name.clone(),
3482                        full_path: wt.full_path.clone(),
3483                        highlight_positions: wt.highlight_positions.clone(),
3484                        kind: wt.kind,
3485                    })
3486                    .collect(),
3487            )
3488            .timestamp(timestamp)
3489            .highlight_positions(thread.highlight_positions.to_vec())
3490            .title_generating(thread.is_title_generating)
3491            .notified(has_notification)
3492            .when(thread.diff_stats.lines_added > 0, |this| {
3493                this.added(thread.diff_stats.lines_added as usize)
3494            })
3495            .when(thread.diff_stats.lines_removed > 0, |this| {
3496                this.removed(thread.diff_stats.lines_removed as usize)
3497            })
3498            .selected(is_selected)
3499            .focused(is_focused)
3500            .hovered(is_hovered)
3501            .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| {
3502                if *is_hovered {
3503                    this.hovered_thread_index = Some(ix);
3504                } else if this.hovered_thread_index == Some(ix) {
3505                    this.hovered_thread_index = None;
3506                }
3507                cx.notify();
3508            }))
3509            .when(is_hovered && is_running, |this| {
3510                this.action_slot(
3511                    IconButton::new("stop-thread", IconName::Stop)
3512                        .icon_size(IconSize::Small)
3513                        .icon_color(Color::Error)
3514                        .style(ButtonStyle::Tinted(TintColor::Error))
3515                        .tooltip(Tooltip::text("Stop Generation"))
3516                        .on_click({
3517                            let session_id = session_id_for_delete.clone();
3518                            cx.listener(move |this, _, _window, cx| {
3519                                this.stop_thread(&session_id, cx);
3520                            })
3521                        }),
3522                )
3523            })
3524            .when(is_hovered && !is_running, |this| {
3525                this.action_slot(
3526                    IconButton::new("archive-thread", IconName::Archive)
3527                        .icon_size(IconSize::Small)
3528                        .icon_color(Color::Muted)
3529                        .tooltip({
3530                            let focus_handle = focus_handle.clone();
3531                            move |_window, cx| {
3532                                Tooltip::for_action_in(
3533                                    "Archive Thread",
3534                                    &RemoveSelectedThread,
3535                                    &focus_handle,
3536                                    cx,
3537                                )
3538                            }
3539                        })
3540                        .on_click({
3541                            let session_id = session_id_for_delete.clone();
3542                            cx.listener(move |this, _, window, cx| {
3543                                this.archive_thread(&session_id, window, cx);
3544                            })
3545                        }),
3546                )
3547            })
3548            .on_click({
3549                cx.listener(move |this, _, window, cx| {
3550                    this.selection = None;
3551                    match &thread_workspace {
3552                        ThreadEntryWorkspace::Open(workspace) => {
3553                            this.activate_thread(metadata.clone(), workspace, false, window, cx);
3554                        }
3555                        ThreadEntryWorkspace::Closed {
3556                            folder_paths,
3557                            project_group_key,
3558                        } => {
3559                            this.open_workspace_and_activate_thread(
3560                                metadata.clone(),
3561                                folder_paths.clone(),
3562                                project_group_key,
3563                                window,
3564                                cx,
3565                            );
3566                        }
3567                    }
3568                })
3569            })
3570            .into_any_element()
3571    }
3572
3573    fn render_filter_input(&self, cx: &mut Context<Self>) -> impl IntoElement {
3574        div()
3575            .min_w_0()
3576            .flex_1()
3577            .capture_action(
3578                cx.listener(|this, _: &editor::actions::Newline, window, cx| {
3579                    this.editor_confirm(window, cx);
3580                }),
3581            )
3582            .child(self.filter_editor.clone())
3583    }
3584
3585    fn render_recent_projects_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
3586        let multi_workspace = self.multi_workspace.upgrade();
3587
3588        let workspace = multi_workspace
3589            .as_ref()
3590            .map(|mw| mw.read(cx).workspace().downgrade());
3591
3592        let focus_handle = workspace
3593            .as_ref()
3594            .and_then(|ws| ws.upgrade())
3595            .map(|w| w.read(cx).focus_handle(cx))
3596            .unwrap_or_else(|| cx.focus_handle());
3597
3598        let window_project_groups: Vec<ProjectGroupKey> = multi_workspace
3599            .as_ref()
3600            .map(|mw| mw.read(cx).project_group_keys().cloned().collect())
3601            .unwrap_or_default();
3602
3603        let popover_handle = self.recent_projects_popover_handle.clone();
3604
3605        PopoverMenu::new("sidebar-recent-projects-menu")
3606            .with_handle(popover_handle)
3607            .menu(move |window, cx| {
3608                workspace.as_ref().map(|ws| {
3609                    SidebarRecentProjects::popover(
3610                        ws.clone(),
3611                        window_project_groups.clone(),
3612                        focus_handle.clone(),
3613                        window,
3614                        cx,
3615                    )
3616                })
3617            })
3618            .trigger_with_tooltip(
3619                IconButton::new("open-project", IconName::OpenFolder)
3620                    .icon_size(IconSize::Small)
3621                    .selected_style(ButtonStyle::Tinted(TintColor::Accent)),
3622                |_window, cx| {
3623                    Tooltip::for_action(
3624                        "Add Project",
3625                        &OpenRecent {
3626                            create_new_window: false,
3627                        },
3628                        cx,
3629                    )
3630                },
3631            )
3632            .offset(gpui::Point {
3633                x: px(-2.0),
3634                y: px(-2.0),
3635            })
3636            .anchor(gpui::Corner::BottomRight)
3637    }
3638
3639    fn render_view_more(
3640        &self,
3641        ix: usize,
3642        key: &ProjectGroupKey,
3643        is_fully_expanded: bool,
3644        is_selected: bool,
3645        cx: &mut Context<Self>,
3646    ) -> AnyElement {
3647        let key = key.clone();
3648        let id = SharedString::from(format!("view-more-{}", ix));
3649
3650        let label: SharedString = if is_fully_expanded {
3651            "Collapse".into()
3652        } else {
3653            "View More".into()
3654        };
3655
3656        ThreadItem::new(id, label)
3657            .focused(is_selected)
3658            .icon_visible(false)
3659            .title_label_color(Color::Muted)
3660            .on_click(cx.listener(move |this, _, _window, cx| {
3661                this.selection = None;
3662                if is_fully_expanded {
3663                    this.reset_thread_group_expansion(&key, cx);
3664                } else {
3665                    this.expand_thread_group(&key, cx);
3666                }
3667            }))
3668            .into_any_element()
3669    }
3670
3671    fn new_thread_in_group(
3672        &mut self,
3673        _: &NewThreadInGroup,
3674        window: &mut Window,
3675        cx: &mut Context<Self>,
3676    ) {
3677        // If there is a keyboard selection, walk backwards through
3678        // `project_header_indices` to find the header that owns the selected
3679        // row. Otherwise fall back to the active workspace.
3680        // Always use the currently active workspace so that drafts
3681        // are created in the linked worktree the user is focused on,
3682        // not the main worktree resolved from the project header.
3683        let workspace = self
3684            .multi_workspace
3685            .upgrade()
3686            .map(|mw| mw.read(cx).workspace().clone());
3687
3688        let Some(workspace) = workspace else {
3689            return;
3690        };
3691
3692        self.create_new_thread(&workspace, window, cx);
3693    }
3694
3695    fn create_new_thread(
3696        &mut self,
3697        workspace: &Entity<Workspace>,
3698        window: &mut Window,
3699        cx: &mut Context<Self>,
3700    ) {
3701        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
3702            return;
3703        };
3704
3705        multi_workspace.update(cx, |multi_workspace, cx| {
3706            multi_workspace.activate(workspace.clone(), window, cx);
3707        });
3708
3709        // TODO DL: The reason why the new thread icon button doesn't create a draft item for non-activated workspaces
3710        // might be here. We're only calling activate after getting the workspace?
3711
3712        let draft_id = workspace.update(cx, |workspace, cx| {
3713            let panel = workspace.panel::<AgentPanel>(cx)?;
3714            let draft_id = panel.update(cx, |panel, cx| {
3715                let id = panel.create_draft(window, cx);
3716                panel.activate_draft(id, true, window, cx);
3717                id
3718            });
3719            workspace.focus_panel::<AgentPanel>(window, cx);
3720            Some(draft_id)
3721        });
3722
3723        if let Some(draft_id) = draft_id {
3724            self.active_entry = Some(ActiveEntry::Draft {
3725                id: draft_id,
3726                workspace: workspace.clone(),
3727            });
3728        }
3729    }
3730
3731    fn activate_draft(
3732        &mut self,
3733        draft_id: DraftId,
3734        workspace: &Entity<Workspace>,
3735        window: &mut Window,
3736        cx: &mut Context<Self>,
3737    ) {
3738        if let Some(multi_workspace) = self.multi_workspace.upgrade() {
3739            multi_workspace.update(cx, |mw, cx| {
3740                mw.activate(workspace.clone(), window, cx);
3741            });
3742        }
3743
3744        workspace.update(cx, |ws, cx| {
3745            if let Some(panel) = ws.panel::<AgentPanel>(cx) {
3746                panel.update(cx, |panel, cx| {
3747                    panel.activate_draft(draft_id, true, window, cx);
3748                });
3749            }
3750            ws.focus_panel::<AgentPanel>(window, cx);
3751        });
3752
3753        self.active_entry = Some(ActiveEntry::Draft {
3754            id: draft_id,
3755            workspace: workspace.clone(),
3756        });
3757
3758        self.observe_draft_editor(cx);
3759    }
3760
3761    fn remove_draft(
3762        &mut self,
3763        draft_id: DraftId,
3764        workspace: &Entity<Workspace>,
3765        window: &mut Window,
3766        cx: &mut Context<Self>,
3767    ) {
3768        workspace.update(cx, |ws, cx| {
3769            if let Some(panel) = ws.panel::<AgentPanel>(cx) {
3770                panel.update(cx, |panel, _cx| {
3771                    panel.remove_draft(draft_id);
3772                });
3773            }
3774        });
3775
3776        let was_active = self
3777            .active_entry
3778            .as_ref()
3779            .is_some_and(|e| e.is_active_draft(draft_id));
3780
3781        if was_active {
3782            let mut switched = false;
3783            let group_key = workspace.read(cx).project_group_key(cx);
3784
3785            // Try the next draft below in the sidebar (smaller ID
3786            // since the list is newest-first). Fall back to the one
3787            // above (larger ID) if the deleted draft was last.
3788            if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
3789                let ids = panel.read(cx).draft_ids();
3790                let sibling = ids
3791                    .iter()
3792                    .find(|id| id.0 < draft_id.0)
3793                    .or_else(|| ids.first());
3794                if let Some(&sibling_id) = sibling {
3795                    self.activate_draft(sibling_id, workspace, window, cx);
3796                    switched = true;
3797                }
3798            }
3799
3800            // No sibling draft — try the first thread in the group.
3801            if !switched {
3802                let first_thread = self.contents.entries.iter().find_map(|entry| {
3803                    if let ListEntry::Thread(thread) = entry {
3804                        if let ThreadEntryWorkspace::Open(ws) = &thread.workspace {
3805                            if ws.read(cx).project_group_key(cx) == group_key {
3806                                return Some((thread.metadata.clone(), ws.clone()));
3807                            }
3808                        }
3809                    }
3810                    None
3811                });
3812                if let Some((metadata, ws)) = first_thread {
3813                    self.activate_thread(metadata, &ws, false, window, cx);
3814                    switched = true;
3815                }
3816            }
3817
3818            if !switched {
3819                self.active_entry = None;
3820            }
3821        }
3822
3823        self.update_entries(cx);
3824    }
3825
3826    fn clear_draft(
3827        &mut self,
3828        draft_id: DraftId,
3829        workspace: &Entity<Workspace>,
3830        window: &mut Window,
3831        cx: &mut Context<Self>,
3832    ) {
3833        workspace.update(cx, |ws, cx| {
3834            if let Some(panel) = ws.panel::<AgentPanel>(cx) {
3835                panel.update(cx, |panel, cx| {
3836                    panel.clear_draft_editor(draft_id, window, cx);
3837                });
3838            }
3839        });
3840        self.update_entries(cx);
3841    }
3842
3843    /// Cleans, collapses whitespace, and truncates raw editor text
3844    /// for display as a draft label in the sidebar.
3845    fn truncate_draft_label(raw: &str) -> Option<SharedString> {
3846        let cleaned = Self::clean_mention_links(raw);
3847        let mut text: String = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
3848        if text.is_empty() {
3849            return None;
3850        }
3851        const MAX_CHARS: usize = 250;
3852        if let Some((truncate_at, _)) = text.char_indices().nth(MAX_CHARS) {
3853            text.truncate(truncate_at);
3854        }
3855        Some(text.into())
3856    }
3857
3858    /// Reads a draft's prompt text from its ConversationView in the AgentPanel.
3859    fn read_draft_text(
3860        &self,
3861        draft_id: DraftId,
3862        workspace: &Entity<Workspace>,
3863        cx: &App,
3864    ) -> Option<SharedString> {
3865        let panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
3866        let raw = panel.read(cx).draft_editor_text(draft_id, cx)?;
3867        Self::truncate_draft_label(&raw)
3868    }
3869
3870    fn active_project_group_key(&self, cx: &App) -> Option<ProjectGroupKey> {
3871        let multi_workspace = self.multi_workspace.upgrade()?;
3872        let multi_workspace = multi_workspace.read(cx);
3873        Some(multi_workspace.project_group_key_for_workspace(multi_workspace.workspace(), cx))
3874    }
3875
3876    fn active_project_header_position(&self, cx: &App) -> Option<usize> {
3877        let active_key = self.active_project_group_key(cx)?;
3878        self.contents
3879            .project_header_indices
3880            .iter()
3881            .position(|&entry_ix| {
3882                matches!(
3883                    &self.contents.entries[entry_ix],
3884                    ListEntry::ProjectHeader { key, .. } if *key == active_key
3885                )
3886            })
3887    }
3888
3889    fn cycle_project_impl(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
3890        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
3891            return;
3892        };
3893
3894        let header_count = self.contents.project_header_indices.len();
3895        if header_count == 0 {
3896            return;
3897        }
3898
3899        let current_pos = self.active_project_header_position(cx);
3900
3901        let next_pos = match current_pos {
3902            Some(pos) => {
3903                if forward {
3904                    (pos + 1) % header_count
3905                } else {
3906                    (pos + header_count - 1) % header_count
3907                }
3908            }
3909            None => 0,
3910        };
3911
3912        let header_entry_ix = self.contents.project_header_indices[next_pos];
3913        let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(header_entry_ix)
3914        else {
3915            return;
3916        };
3917        let key = key.clone();
3918
3919        // Uncollapse the target group so that threads become visible.
3920        self.collapsed_groups.remove(&key);
3921
3922        if let Some(workspace) = self.multi_workspace.upgrade().and_then(|mw| {
3923            mw.read(cx)
3924                .workspace_for_paths(key.path_list(), key.host().as_ref(), cx)
3925        }) {
3926            multi_workspace.update(cx, |multi_workspace, cx| {
3927                multi_workspace.activate(workspace, window, cx);
3928                multi_workspace.retain_active_workspace(cx);
3929            });
3930        } else {
3931            self.open_workspace_for_group(&key, window, cx);
3932        }
3933    }
3934
3935    fn on_next_project(&mut self, _: &NextProject, window: &mut Window, cx: &mut Context<Self>) {
3936        self.cycle_project_impl(true, window, cx);
3937    }
3938
3939    fn on_previous_project(
3940        &mut self,
3941        _: &PreviousProject,
3942        window: &mut Window,
3943        cx: &mut Context<Self>,
3944    ) {
3945        self.cycle_project_impl(false, window, cx);
3946    }
3947
3948    fn cycle_thread_impl(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
3949        let thread_indices: Vec<usize> = self
3950            .contents
3951            .entries
3952            .iter()
3953            .enumerate()
3954            .filter_map(|(ix, entry)| match entry {
3955                ListEntry::Thread(_) => Some(ix),
3956                _ => None,
3957            })
3958            .collect();
3959
3960        if thread_indices.is_empty() {
3961            return;
3962        }
3963
3964        let current_thread_pos = self.active_entry.as_ref().and_then(|active| {
3965            thread_indices
3966                .iter()
3967                .position(|&ix| active.matches_entry(&self.contents.entries[ix]))
3968        });
3969
3970        let next_pos = match current_thread_pos {
3971            Some(pos) => {
3972                let count = thread_indices.len();
3973                if forward {
3974                    (pos + 1) % count
3975                } else {
3976                    (pos + count - 1) % count
3977                }
3978            }
3979            None => 0,
3980        };
3981
3982        let entry_ix = thread_indices[next_pos];
3983        let ListEntry::Thread(thread) = &self.contents.entries[entry_ix] else {
3984            return;
3985        };
3986
3987        let metadata = thread.metadata.clone();
3988        match &thread.workspace {
3989            ThreadEntryWorkspace::Open(workspace) => {
3990                let workspace = workspace.clone();
3991                self.activate_thread(metadata, &workspace, true, window, cx);
3992            }
3993            ThreadEntryWorkspace::Closed {
3994                folder_paths,
3995                project_group_key,
3996            } => {
3997                let folder_paths = folder_paths.clone();
3998                let project_group_key = project_group_key.clone();
3999                self.open_workspace_and_activate_thread(
4000                    metadata,
4001                    folder_paths,
4002                    &project_group_key,
4003                    window,
4004                    cx,
4005                );
4006            }
4007        }
4008    }
4009
4010    fn on_next_thread(&mut self, _: &NextThread, window: &mut Window, cx: &mut Context<Self>) {
4011        self.cycle_thread_impl(true, window, cx);
4012    }
4013
4014    fn on_previous_thread(
4015        &mut self,
4016        _: &PreviousThread,
4017        window: &mut Window,
4018        cx: &mut Context<Self>,
4019    ) {
4020        self.cycle_thread_impl(false, window, cx);
4021    }
4022
4023    fn expand_thread_group(&mut self, project_group_key: &ProjectGroupKey, cx: &mut Context<Self>) {
4024        let current = self
4025            .expanded_groups
4026            .get(project_group_key)
4027            .copied()
4028            .unwrap_or(0);
4029        self.expanded_groups
4030            .insert(project_group_key.clone(), current + 1);
4031        self.serialize(cx);
4032        self.update_entries(cx);
4033    }
4034
4035    fn reset_thread_group_expansion(
4036        &mut self,
4037        project_group_key: &ProjectGroupKey,
4038        cx: &mut Context<Self>,
4039    ) {
4040        self.expanded_groups.remove(project_group_key);
4041        self.serialize(cx);
4042        self.update_entries(cx);
4043    }
4044
4045    fn collapse_thread_group(
4046        &mut self,
4047        project_group_key: &ProjectGroupKey,
4048        cx: &mut Context<Self>,
4049    ) {
4050        match self.expanded_groups.get(project_group_key).copied() {
4051            Some(batches) if batches > 1 => {
4052                self.expanded_groups
4053                    .insert(project_group_key.clone(), batches - 1);
4054            }
4055            Some(_) => {
4056                self.expanded_groups.remove(project_group_key);
4057            }
4058            None => return,
4059        }
4060        self.serialize(cx);
4061        self.update_entries(cx);
4062    }
4063
4064    fn on_show_more_threads(
4065        &mut self,
4066        _: &ShowMoreThreads,
4067        _window: &mut Window,
4068        cx: &mut Context<Self>,
4069    ) {
4070        let Some(active_key) = self.active_project_group_key(cx) else {
4071            return;
4072        };
4073        self.expand_thread_group(&active_key, cx);
4074    }
4075
4076    fn on_show_fewer_threads(
4077        &mut self,
4078        _: &ShowFewerThreads,
4079        _window: &mut Window,
4080        cx: &mut Context<Self>,
4081    ) {
4082        let Some(active_key) = self.active_project_group_key(cx) else {
4083            return;
4084        };
4085        self.collapse_thread_group(&active_key, cx);
4086    }
4087
4088    fn on_new_thread(
4089        &mut self,
4090        _: &workspace::NewThread,
4091        window: &mut Window,
4092        cx: &mut Context<Self>,
4093    ) {
4094        let Some(workspace) = self.active_workspace(cx) else {
4095            return;
4096        };
4097        self.create_new_thread(&workspace, window, cx);
4098    }
4099
4100    fn render_draft_thread(
4101        &self,
4102        ix: usize,
4103        draft_id: Option<DraftId>,
4104        key: &ProjectGroupKey,
4105        workspace: Option<&Entity<Workspace>>,
4106        is_active: bool,
4107        worktrees: &[WorktreeInfo],
4108        is_selected: bool,
4109        can_dismiss: bool,
4110        cx: &mut Context<Self>,
4111    ) -> AnyElement {
4112        let label: SharedString = draft_id
4113            .and_then(|id| workspace.and_then(|ws| self.read_draft_text(id, ws, cx)))
4114            .unwrap_or_else(|| "New Agent Thread".into());
4115
4116        let id = SharedString::from(format!("draft-thread-btn-{}", ix));
4117
4118        let worktrees = worktrees
4119            .iter()
4120            .map(|worktree| ThreadItemWorktreeInfo {
4121                name: worktree.name.clone(),
4122                full_path: worktree.full_path.clone(),
4123                highlight_positions: worktree.highlight_positions.clone(),
4124                kind: worktree.kind,
4125            })
4126            .collect();
4127
4128        let is_hovered = self.hovered_thread_index == Some(ix);
4129
4130        let key = key.clone();
4131        let workspace_for_click = workspace.cloned();
4132        let workspace_for_remove = workspace.cloned();
4133        let workspace_for_clear = workspace.cloned();
4134
4135        ThreadItem::new(id, label)
4136            .icon(IconName::Pencil)
4137            .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.4)))
4138            .worktrees(worktrees)
4139            .selected(is_active)
4140            .focused(is_selected)
4141            .hovered(is_hovered)
4142            .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| {
4143                if *is_hovered {
4144                    this.hovered_thread_index = Some(ix);
4145                } else if this.hovered_thread_index == Some(ix) {
4146                    this.hovered_thread_index = None;
4147                }
4148                cx.notify();
4149            }))
4150            .on_click(cx.listener(move |this, _, window, cx| {
4151                if let Some(draft_id) = draft_id {
4152                    if let Some(workspace) = &workspace_for_click {
4153                        this.activate_draft(draft_id, workspace, window, cx);
4154                    }
4155                } else if let Some(workspace) = &workspace_for_click {
4156                    // Placeholder with an open workspace — just
4157                    // activate it. The panel remembers its last view.
4158                    this.activate_workspace(workspace, window, cx);
4159                    if AgentPanel::is_visible(workspace, cx) {
4160                        workspace.update(cx, |ws, cx| {
4161                            ws.focus_panel::<AgentPanel>(window, cx);
4162                        });
4163                    }
4164                } else {
4165                    // No workspace at all — just open one. The
4166                    // panel's load fallback will create a draft.
4167                    this.open_workspace_for_group(&key, window, cx);
4168                }
4169            }))
4170            .when_some(draft_id.filter(|_| can_dismiss), |this, draft_id| {
4171                this.action_slot(
4172                    div()
4173                        .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
4174                            cx.stop_propagation();
4175                        })
4176                        .child(
4177                            IconButton::new(
4178                                SharedString::from(format!("close-draft-{}", ix)),
4179                                IconName::Close,
4180                            )
4181                            .icon_size(IconSize::Small)
4182                            .icon_color(Color::Muted)
4183                            .tooltip(Tooltip::text("Remove Draft"))
4184                            .on_click(cx.listener(
4185                                move |this, _, window, cx| {
4186                                    if let Some(workspace) = &workspace_for_remove {
4187                                        this.remove_draft(draft_id, workspace, window, cx);
4188                                    }
4189                                },
4190                            )),
4191                        ),
4192                )
4193            })
4194            .when_some(draft_id.filter(|_| !can_dismiss), |this, draft_id| {
4195                this.action_slot(
4196                    div()
4197                        .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
4198                            cx.stop_propagation();
4199                        })
4200                        .child(
4201                            IconButton::new(
4202                                SharedString::from(format!("clear-draft-{}", ix)),
4203                                IconName::Close,
4204                            )
4205                            .icon_size(IconSize::Small)
4206                            .icon_color(Color::Muted)
4207                            .tooltip(Tooltip::text("Clear Draft"))
4208                            .on_click(cx.listener(
4209                                move |this, _, window, cx| {
4210                                    if let Some(workspace) = &workspace_for_clear {
4211                                        this.clear_draft(draft_id, workspace, window, cx);
4212                                    }
4213                                },
4214                            )),
4215                        ),
4216                )
4217            })
4218            .into_any_element()
4219    }
4220
4221    fn render_no_results(&self, cx: &mut Context<Self>) -> impl IntoElement {
4222        let has_query = self.has_filter_query(cx);
4223        let message = if has_query {
4224            "No threads match your search."
4225        } else {
4226            "No threads yet"
4227        };
4228
4229        v_flex()
4230            .id("sidebar-no-results")
4231            .p_4()
4232            .size_full()
4233            .items_center()
4234            .justify_center()
4235            .child(
4236                Label::new(message)
4237                    .size(LabelSize::Small)
4238                    .color(Color::Muted),
4239            )
4240    }
4241
4242    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
4243        v_flex()
4244            .id("sidebar-empty-state")
4245            .p_4()
4246            .size_full()
4247            .items_center()
4248            .justify_center()
4249            .gap_1()
4250            .track_focus(&self.focus_handle(cx))
4251            .child(
4252                Button::new("open_project", "Open Project")
4253                    .full_width()
4254                    .key_binding(KeyBinding::for_action(&workspace::Open::default(), cx))
4255                    .on_click(|_, window, cx| {
4256                        window.dispatch_action(
4257                            Open {
4258                                create_new_window: false,
4259                            }
4260                            .boxed_clone(),
4261                            cx,
4262                        );
4263                    }),
4264            )
4265            .child(
4266                h_flex()
4267                    .w_1_2()
4268                    .gap_2()
4269                    .child(Divider::horizontal().color(ui::DividerColor::Border))
4270                    .child(Label::new("or").size(LabelSize::XSmall).color(Color::Muted))
4271                    .child(Divider::horizontal().color(ui::DividerColor::Border)),
4272            )
4273            .child(
4274                Button::new("clone_repo", "Clone Repository")
4275                    .full_width()
4276                    .on_click(|_, window, cx| {
4277                        window.dispatch_action(git::Clone.boxed_clone(), cx);
4278                    }),
4279            )
4280    }
4281
4282    fn render_sidebar_header(
4283        &self,
4284        no_open_projects: bool,
4285        window: &Window,
4286        cx: &mut Context<Self>,
4287    ) -> impl IntoElement {
4288        let has_query = self.has_filter_query(cx);
4289        let sidebar_on_left = self.side(cx) == SidebarSide::Left;
4290        let sidebar_on_right = self.side(cx) == SidebarSide::Right;
4291        let not_fullscreen = !window.is_fullscreen();
4292        let traffic_lights = cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left;
4293        let left_window_controls = !cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left;
4294        let right_window_controls =
4295            !cfg!(target_os = "macos") && not_fullscreen && sidebar_on_right;
4296        let header_height = platform_title_bar_height(window);
4297
4298        h_flex()
4299            .h(header_height)
4300            .mt_px()
4301            .pb_px()
4302            .when(left_window_controls, |this| {
4303                this.children(Self::render_left_window_controls(window, cx))
4304            })
4305            .map(|this| {
4306                if traffic_lights {
4307                    this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
4308                } else if !left_window_controls {
4309                    this.pl_1p5()
4310                } else {
4311                    this
4312                }
4313            })
4314            .when(!right_window_controls, |this| this.pr_1p5())
4315            .gap_1()
4316            .when(!no_open_projects, |this| {
4317                this.border_b_1()
4318                    .border_color(cx.theme().colors().border)
4319                    .when(traffic_lights, |this| {
4320                        this.child(Divider::vertical().color(ui::DividerColor::Border))
4321                    })
4322                    .child(
4323                        div().ml_1().child(
4324                            Icon::new(IconName::MagnifyingGlass)
4325                                .size(IconSize::Small)
4326                                .color(Color::Muted),
4327                        ),
4328                    )
4329                    .child(self.render_filter_input(cx))
4330                    .child(
4331                        h_flex()
4332                            .gap_1()
4333                            .when(
4334                                self.selection.is_some()
4335                                    && !self.filter_editor.focus_handle(cx).is_focused(window),
4336                                |this| this.child(KeyBinding::for_action(&FocusSidebarFilter, cx)),
4337                            )
4338                            .when(has_query, |this| {
4339                                this.child(
4340                                    IconButton::new("clear_filter", IconName::Close)
4341                                        .icon_size(IconSize::Small)
4342                                        .tooltip(Tooltip::text("Clear Search"))
4343                                        .on_click(cx.listener(|this, _, window, cx| {
4344                                            this.reset_filter_editor_text(window, cx);
4345                                            this.update_entries(cx);
4346                                        })),
4347                                )
4348                            }),
4349                    )
4350            })
4351            .when(right_window_controls, |this| {
4352                this.children(Self::render_right_window_controls(window, cx))
4353            })
4354    }
4355
4356    fn render_left_window_controls(window: &Window, cx: &mut App) -> Option<AnyElement> {
4357        platform_title_bar::render_left_window_controls(
4358            cx.button_layout(),
4359            Box::new(CloseWindow),
4360            window,
4361        )
4362    }
4363
4364    fn render_right_window_controls(window: &Window, cx: &mut App) -> Option<AnyElement> {
4365        platform_title_bar::render_right_window_controls(
4366            cx.button_layout(),
4367            Box::new(CloseWindow),
4368            window,
4369        )
4370    }
4371
4372    fn render_sidebar_toggle_button(&self, _cx: &mut Context<Self>) -> impl IntoElement {
4373        let on_right = AgentSettings::get_global(_cx).sidebar_side() == SidebarSide::Right;
4374
4375        sidebar_side_context_menu("sidebar-toggle-menu", _cx)
4376            .anchor(if on_right {
4377                gpui::Corner::BottomRight
4378            } else {
4379                gpui::Corner::BottomLeft
4380            })
4381            .attach(if on_right {
4382                gpui::Corner::TopRight
4383            } else {
4384                gpui::Corner::TopLeft
4385            })
4386            .trigger(move |_is_active, _window, _cx| {
4387                let icon = if on_right {
4388                    IconName::ThreadsSidebarRightOpen
4389                } else {
4390                    IconName::ThreadsSidebarLeftOpen
4391                };
4392                IconButton::new("sidebar-close-toggle", icon)
4393                    .icon_size(IconSize::Small)
4394                    .tooltip(Tooltip::element(move |_window, cx| {
4395                        v_flex()
4396                            .gap_1()
4397                            .child(
4398                                h_flex()
4399                                    .gap_2()
4400                                    .justify_between()
4401                                    .child(Label::new("Toggle Sidebar"))
4402                                    .child(KeyBinding::for_action(&ToggleWorkspaceSidebar, cx)),
4403                            )
4404                            .child(
4405                                h_flex()
4406                                    .pt_1()
4407                                    .gap_2()
4408                                    .border_t_1()
4409                                    .border_color(cx.theme().colors().border_variant)
4410                                    .justify_between()
4411                                    .child(Label::new("Focus Sidebar"))
4412                                    .child(KeyBinding::for_action(&FocusWorkspaceSidebar, cx)),
4413                            )
4414                            .into_any_element()
4415                    }))
4416                    .on_click(|_, window, cx| {
4417                        if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
4418                            multi_workspace.update(cx, |multi_workspace, cx| {
4419                                multi_workspace.close_sidebar(window, cx);
4420                            });
4421                        }
4422                    })
4423            })
4424    }
4425
4426    fn render_sidebar_bottom_bar(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
4427        let is_archive = matches!(self.view, SidebarView::Archive(..));
4428        let show_import_button = is_archive && !self.should_render_acp_import_onboarding(cx);
4429        let on_right = self.side(cx) == SidebarSide::Right;
4430
4431        let action_buttons = h_flex()
4432            .gap_1()
4433            .when(on_right, |this| this.flex_row_reverse())
4434            .when(show_import_button, |this| {
4435                this.child(
4436                    IconButton::new("thread-import", IconName::ThreadImport)
4437                        .icon_size(IconSize::Small)
4438                        .tooltip(Tooltip::text("Import ACP Threads"))
4439                        .on_click(cx.listener(|this, _, window, cx| {
4440                            this.show_archive(window, cx);
4441                            this.show_thread_import_modal(window, cx);
4442                        })),
4443                )
4444            })
4445            .child(
4446                IconButton::new("archive", IconName::Archive)
4447                    .icon_size(IconSize::Small)
4448                    .toggle_state(is_archive)
4449                    .tooltip(move |_, cx| {
4450                        Tooltip::for_action("Toggle Archived Threads", &ToggleArchive, cx)
4451                    })
4452                    .on_click(cx.listener(|this, _, window, cx| {
4453                        this.toggle_archive(&ToggleArchive, window, cx);
4454                    })),
4455            )
4456            .child(self.render_recent_projects_button(cx));
4457
4458        h_flex()
4459            .p_1()
4460            .gap_1()
4461            .when(on_right, |this| this.flex_row_reverse())
4462            .justify_between()
4463            .border_t_1()
4464            .border_color(cx.theme().colors().border)
4465            .child(self.render_sidebar_toggle_button(cx))
4466            .child(action_buttons)
4467    }
4468
4469    fn active_workspace(&self, cx: &App) -> Option<Entity<Workspace>> {
4470        self.multi_workspace
4471            .upgrade()
4472            .map(|w| w.read(cx).workspace().clone())
4473    }
4474
4475    fn show_thread_import_modal(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4476        let Some(active_workspace) = self.active_workspace(cx) else {
4477            return;
4478        };
4479
4480        let Some(agent_registry_store) = AgentRegistryStore::try_global(cx) else {
4481            return;
4482        };
4483
4484        let agent_server_store = active_workspace
4485            .read(cx)
4486            .project()
4487            .read(cx)
4488            .agent_server_store()
4489            .clone();
4490
4491        let workspace_handle = active_workspace.downgrade();
4492        let multi_workspace = self.multi_workspace.clone();
4493
4494        active_workspace.update(cx, |workspace, cx| {
4495            workspace.toggle_modal(window, cx, |window, cx| {
4496                ThreadImportModal::new(
4497                    agent_server_store,
4498                    agent_registry_store,
4499                    workspace_handle.clone(),
4500                    multi_workspace.clone(),
4501                    window,
4502                    cx,
4503                )
4504            });
4505        });
4506    }
4507
4508    fn should_render_acp_import_onboarding(&self, cx: &App) -> bool {
4509        let has_external_agents = self
4510            .active_workspace(cx)
4511            .map(|ws| {
4512                ws.read(cx)
4513                    .project()
4514                    .read(cx)
4515                    .agent_server_store()
4516                    .read(cx)
4517                    .has_external_agents()
4518            })
4519            .unwrap_or(false);
4520
4521        has_external_agents && !AcpThreadImportOnboarding::dismissed(cx)
4522    }
4523
4524    fn render_acp_import_onboarding(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
4525        let description = "Import threads from agents like Claude Agent, Codex, and more, whether started in Zed or another client.";
4526
4527        let bg = cx.theme().colors().text_accent;
4528
4529        v_flex()
4530            .min_w_0()
4531            .w_full()
4532            .p_2()
4533            .border_t_1()
4534            .border_color(cx.theme().colors().border)
4535            .bg(linear_gradient(
4536                360.,
4537                linear_color_stop(bg.opacity(0.06), 1.),
4538                linear_color_stop(bg.opacity(0.), 0.),
4539            ))
4540            .child(
4541                h_flex()
4542                    .min_w_0()
4543                    .w_full()
4544                    .gap_1()
4545                    .justify_between()
4546                    .child(Label::new("Looking for threads from external agents?"))
4547                    .child(
4548                        IconButton::new("close-onboarding", IconName::Close)
4549                            .icon_size(IconSize::Small)
4550                            .on_click(|_, _window, cx| AcpThreadImportOnboarding::dismiss(cx)),
4551                    ),
4552            )
4553            .child(Label::new(description).color(Color::Muted).mb_2())
4554            .child(
4555                Button::new("import-acp", "Import Threads")
4556                    .full_width()
4557                    .style(ButtonStyle::OutlinedCustom(cx.theme().colors().border))
4558                    .label_size(LabelSize::Small)
4559                    .start_icon(
4560                        Icon::new(IconName::ThreadImport)
4561                            .size(IconSize::Small)
4562                            .color(Color::Muted),
4563                    )
4564                    .on_click(cx.listener(|this, _, window, cx| {
4565                        this.show_archive(window, cx);
4566                        this.show_thread_import_modal(window, cx);
4567                    })),
4568            )
4569    }
4570
4571    fn toggle_archive(&mut self, _: &ToggleArchive, window: &mut Window, cx: &mut Context<Self>) {
4572        match &self.view {
4573            SidebarView::ThreadList => self.show_archive(window, cx),
4574            SidebarView::Archive(_) => self.show_thread_list(window, cx),
4575        }
4576    }
4577
4578    fn show_archive(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4579        let Some(active_workspace) = self
4580            .multi_workspace
4581            .upgrade()
4582            .map(|w| w.read(cx).workspace().clone())
4583        else {
4584            return;
4585        };
4586        let Some(agent_panel) = active_workspace.read(cx).panel::<AgentPanel>(cx) else {
4587            return;
4588        };
4589
4590        let agent_server_store = active_workspace
4591            .read(cx)
4592            .project()
4593            .read(cx)
4594            .agent_server_store()
4595            .downgrade();
4596
4597        let agent_connection_store = agent_panel.read(cx).connection_store().downgrade();
4598
4599        let archive_view = cx.new(|cx| {
4600            ThreadsArchiveView::new(
4601                active_workspace.downgrade(),
4602                agent_connection_store.clone(),
4603                agent_server_store.clone(),
4604                window,
4605                cx,
4606            )
4607        });
4608
4609        let subscription = cx.subscribe_in(
4610            &archive_view,
4611            window,
4612            |this, _, event: &ThreadsArchiveViewEvent, window, cx| match event {
4613                ThreadsArchiveViewEvent::Close => {
4614                    this.show_thread_list(window, cx);
4615                }
4616                ThreadsArchiveViewEvent::Unarchive { thread } => {
4617                    this.activate_archived_thread(thread.clone(), window, cx);
4618                }
4619                ThreadsArchiveViewEvent::CancelRestore { session_id } => {
4620                    this.restoring_tasks.remove(session_id);
4621                }
4622            },
4623        );
4624
4625        self._subscriptions.push(subscription);
4626        self.view = SidebarView::Archive(archive_view.clone());
4627        archive_view.update(cx, |view, cx| view.focus_filter_editor(window, cx));
4628        self.serialize(cx);
4629        cx.notify();
4630    }
4631
4632    fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4633        self.view = SidebarView::ThreadList;
4634        self._subscriptions.clear();
4635        let handle = self.filter_editor.read(cx).focus_handle(cx);
4636        handle.focus(window, cx);
4637        self.serialize(cx);
4638        cx.notify();
4639    }
4640}
4641
4642impl WorkspaceSidebar for Sidebar {
4643    fn width(&self, _cx: &App) -> Pixels {
4644        self.width
4645    }
4646
4647    fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
4648        self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
4649        cx.notify();
4650    }
4651
4652    fn has_notifications(&self, _cx: &App) -> bool {
4653        !self.contents.notified_threads.is_empty()
4654    }
4655
4656    fn is_threads_list_view_active(&self) -> bool {
4657        matches!(self.view, SidebarView::ThreadList)
4658    }
4659
4660    fn side(&self, cx: &App) -> SidebarSide {
4661        AgentSettings::get_global(cx).sidebar_side()
4662    }
4663
4664    fn prepare_for_focus(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
4665        self.selection = None;
4666        cx.notify();
4667    }
4668
4669    fn toggle_thread_switcher(
4670        &mut self,
4671        select_last: bool,
4672        window: &mut Window,
4673        cx: &mut Context<Self>,
4674    ) {
4675        self.toggle_thread_switcher_impl(select_last, window, cx);
4676    }
4677
4678    fn cycle_project(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
4679        self.cycle_project_impl(forward, window, cx);
4680    }
4681
4682    fn cycle_thread(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
4683        self.cycle_thread_impl(forward, window, cx);
4684    }
4685
4686    fn serialized_state(&self, _cx: &App) -> Option<String> {
4687        let serialized = SerializedSidebar {
4688            width: Some(f32::from(self.width)),
4689            collapsed_groups: self
4690                .collapsed_groups
4691                .iter()
4692                .cloned()
4693                .map(SerializedProjectGroupKey::from)
4694                .collect(),
4695            expanded_groups: self
4696                .expanded_groups
4697                .iter()
4698                .map(|(key, count)| (SerializedProjectGroupKey::from(key.clone()), *count))
4699                .collect(),
4700            active_view: match self.view {
4701                SidebarView::ThreadList => SerializedSidebarView::ThreadList,
4702                SidebarView::Archive(_) => SerializedSidebarView::Archive,
4703            },
4704        };
4705        serde_json::to_string(&serialized).ok()
4706    }
4707
4708    fn restore_serialized_state(
4709        &mut self,
4710        state: &str,
4711        window: &mut Window,
4712        cx: &mut Context<Self>,
4713    ) {
4714        if let Some(serialized) = serde_json::from_str::<SerializedSidebar>(state).log_err() {
4715            if let Some(width) = serialized.width {
4716                self.width = px(width).clamp(MIN_WIDTH, MAX_WIDTH);
4717            }
4718            self.collapsed_groups = serialized
4719                .collapsed_groups
4720                .into_iter()
4721                .map(ProjectGroupKey::from)
4722                .collect();
4723            self.expanded_groups = serialized
4724                .expanded_groups
4725                .into_iter()
4726                .map(|(s, count)| (ProjectGroupKey::from(s), count))
4727                .collect();
4728            if serialized.active_view == SerializedSidebarView::Archive {
4729                cx.defer_in(window, |this, window, cx| {
4730                    this.show_archive(window, cx);
4731                });
4732            }
4733        }
4734        cx.notify();
4735    }
4736}
4737
4738impl gpui::EventEmitter<workspace::SidebarEvent> for Sidebar {}
4739
4740impl Focusable for Sidebar {
4741    fn focus_handle(&self, _cx: &App) -> FocusHandle {
4742        self.focus_handle.clone()
4743    }
4744}
4745
4746impl Render for Sidebar {
4747    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4748        let _titlebar_height = ui::utils::platform_title_bar_height(window);
4749        let ui_font = theme_settings::setup_ui_font(window, cx);
4750        let sticky_header = self.render_sticky_header(window, cx);
4751
4752        let color = cx.theme().colors();
4753        let bg = color
4754            .title_bar_background
4755            .blend(color.panel_background.opacity(0.25));
4756
4757        let no_open_projects = !self.contents.has_open_projects;
4758        let no_search_results = self.contents.entries.is_empty();
4759
4760        v_flex()
4761            .id("workspace-sidebar")
4762            .key_context(self.dispatch_context(window, cx))
4763            .track_focus(&self.focus_handle)
4764            .on_action(cx.listener(Self::select_next))
4765            .on_action(cx.listener(Self::select_previous))
4766            .on_action(cx.listener(Self::editor_move_down))
4767            .on_action(cx.listener(Self::editor_move_up))
4768            .on_action(cx.listener(Self::select_first))
4769            .on_action(cx.listener(Self::select_last))
4770            .on_action(cx.listener(Self::confirm))
4771            .on_action(cx.listener(Self::expand_selected_entry))
4772            .on_action(cx.listener(Self::collapse_selected_entry))
4773            .on_action(cx.listener(Self::toggle_selected_fold))
4774            .on_action(cx.listener(Self::fold_all))
4775            .on_action(cx.listener(Self::unfold_all))
4776            .on_action(cx.listener(Self::cancel))
4777            .on_action(cx.listener(Self::remove_selected_thread))
4778            .on_action(cx.listener(Self::new_thread_in_group))
4779            .on_action(cx.listener(Self::toggle_archive))
4780            .on_action(cx.listener(Self::focus_sidebar_filter))
4781            .on_action(cx.listener(Self::on_toggle_thread_switcher))
4782            .on_action(cx.listener(Self::on_next_project))
4783            .on_action(cx.listener(Self::on_previous_project))
4784            .on_action(cx.listener(Self::on_next_thread))
4785            .on_action(cx.listener(Self::on_previous_thread))
4786            .on_action(cx.listener(Self::on_show_more_threads))
4787            .on_action(cx.listener(Self::on_show_fewer_threads))
4788            .on_action(cx.listener(Self::on_new_thread))
4789            .on_action(cx.listener(|this, _: &OpenRecent, window, cx| {
4790                this.recent_projects_popover_handle.toggle(window, cx);
4791            }))
4792            .font(ui_font)
4793            .h_full()
4794            .w(self.width)
4795            .bg(bg)
4796            .when(self.side(cx) == SidebarSide::Left, |el| el.border_r_1())
4797            .when(self.side(cx) == SidebarSide::Right, |el| el.border_l_1())
4798            .border_color(color.border)
4799            .map(|this| match &self.view {
4800                SidebarView::ThreadList => this
4801                    .child(self.render_sidebar_header(no_open_projects, window, cx))
4802                    .map(|this| {
4803                        if no_open_projects {
4804                            this.child(self.render_empty_state(cx))
4805                        } else {
4806                            this.child(
4807                                v_flex()
4808                                    .relative()
4809                                    .flex_1()
4810                                    .overflow_hidden()
4811                                    .child(
4812                                        list(
4813                                            self.list_state.clone(),
4814                                            cx.processor(Self::render_list_entry),
4815                                        )
4816                                        .flex_1()
4817                                        .size_full(),
4818                                    )
4819                                    .when(no_search_results, |this| {
4820                                        this.child(self.render_no_results(cx))
4821                                    })
4822                                    .when_some(sticky_header, |this, header| this.child(header))
4823                                    .vertical_scrollbar_for(&self.list_state, window, cx),
4824                            )
4825                        }
4826                    }),
4827                SidebarView::Archive(archive_view) => this.child(archive_view.clone()),
4828            })
4829            .when(self.should_render_acp_import_onboarding(cx), |this| {
4830                this.child(self.render_acp_import_onboarding(cx))
4831            })
4832            .child(self.render_sidebar_bottom_bar(cx))
4833    }
4834}
4835
4836fn all_thread_infos_for_workspace(
4837    workspace: &Entity<Workspace>,
4838    cx: &App,
4839) -> impl Iterator<Item = ActiveThreadInfo> {
4840    let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
4841        return None.into_iter().flatten();
4842    };
4843    let agent_panel = agent_panel.read(cx);
4844    let threads = agent_panel
4845        .conversation_views()
4846        .into_iter()
4847        .filter_map(|conversation_view| {
4848            let has_pending_tool_call = conversation_view
4849                .read(cx)
4850                .root_thread_has_pending_tool_call(cx);
4851            let thread_view = conversation_view.read(cx).root_thread(cx)?;
4852            let thread_view_ref = thread_view.read(cx);
4853            let thread = thread_view_ref.thread.read(cx);
4854
4855            let icon = thread_view_ref.agent_icon;
4856            let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone();
4857            let title = thread
4858                .title()
4859                .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into());
4860            let is_native = thread_view_ref.as_native_thread(cx).is_some();
4861            let is_title_generating = is_native && thread.has_provisional_title();
4862            let session_id = thread.session_id().clone();
4863            let is_background = agent_panel.is_background_thread(&session_id);
4864
4865            let status = if has_pending_tool_call {
4866                AgentThreadStatus::WaitingForConfirmation
4867            } else if thread.had_error() {
4868                AgentThreadStatus::Error
4869            } else {
4870                match thread.status() {
4871                    ThreadStatus::Generating => AgentThreadStatus::Running,
4872                    ThreadStatus::Idle => AgentThreadStatus::Completed,
4873                }
4874            };
4875
4876            let diff_stats = thread.action_log().read(cx).diff_stats(cx);
4877
4878            Some(ActiveThreadInfo {
4879                session_id,
4880                title,
4881                status,
4882                icon,
4883                icon_from_external_svg,
4884                is_background,
4885                is_title_generating,
4886                diff_stats,
4887            })
4888        });
4889
4890    Some(threads).into_iter().flatten()
4891}
4892
4893pub fn dump_workspace_info(
4894    workspace: &mut Workspace,
4895    _: &DumpWorkspaceInfo,
4896    window: &mut gpui::Window,
4897    cx: &mut gpui::Context<Workspace>,
4898) {
4899    use std::fmt::Write;
4900
4901    let mut output = String::new();
4902    let this_entity = cx.entity();
4903
4904    let multi_workspace = workspace.multi_workspace().and_then(|weak| weak.upgrade());
4905    let workspaces: Vec<gpui::Entity<Workspace>> = match &multi_workspace {
4906        Some(mw) => mw.read(cx).workspaces().cloned().collect(),
4907        None => vec![this_entity.clone()],
4908    };
4909    let active_workspace = multi_workspace
4910        .as_ref()
4911        .map(|mw| mw.read(cx).workspace().clone());
4912
4913    writeln!(output, "MultiWorkspace: {} workspace(s)", workspaces.len()).ok();
4914
4915    if let Some(mw) = &multi_workspace {
4916        let keys: Vec<_> = mw.read(cx).project_group_keys().cloned().collect();
4917        writeln!(output, "Project group keys ({}):", keys.len()).ok();
4918        for key in keys {
4919            writeln!(output, "  - {key:?}").ok();
4920        }
4921    }
4922
4923    writeln!(output).ok();
4924
4925    for (index, ws) in workspaces.iter().enumerate() {
4926        let is_active = active_workspace.as_ref() == Some(ws);
4927        writeln!(
4928            output,
4929            "--- Workspace {index}{} ---",
4930            if is_active { " (active)" } else { "" }
4931        )
4932        .ok();
4933
4934        // project_group_key_for_workspace internally reads the workspace,
4935        // so we can only call it for workspaces other than this_entity
4936        // (which is already being updated).
4937        if let Some(mw) = &multi_workspace {
4938            if *ws == this_entity {
4939                let workspace_key = workspace.project_group_key(cx);
4940                writeln!(output, "ProjectGroupKey: {workspace_key:?}").ok();
4941            } else {
4942                let effective_key = mw.read(cx).project_group_key_for_workspace(ws, cx);
4943                let workspace_key = ws.read(cx).project_group_key(cx);
4944                if effective_key != workspace_key {
4945                    writeln!(
4946                        output,
4947                        "ProjectGroupKey (multi_workspace): {effective_key:?}"
4948                    )
4949                    .ok();
4950                    writeln!(
4951                        output,
4952                        "ProjectGroupKey (workspace, DISAGREES): {workspace_key:?}"
4953                    )
4954                    .ok();
4955                } else {
4956                    writeln!(output, "ProjectGroupKey: {effective_key:?}").ok();
4957                }
4958            }
4959        } else {
4960            let workspace_key = workspace.project_group_key(cx);
4961            writeln!(output, "ProjectGroupKey: {workspace_key:?}").ok();
4962        }
4963
4964        // The action handler is already inside an update on `this_entity`,
4965        // so we must avoid a nested read/update on that same entity.
4966        if *ws == this_entity {
4967            dump_single_workspace(workspace, &mut output, cx);
4968        } else {
4969            ws.read_with(cx, |ws, cx| {
4970                dump_single_workspace(ws, &mut output, cx);
4971            });
4972        }
4973    }
4974
4975    let project = workspace.project().clone();
4976    cx.spawn_in(window, async move |_this, cx| {
4977        let buffer = project
4978            .update(cx, |project, cx| project.create_buffer(None, false, cx))
4979            .await?;
4980
4981        buffer.update(cx, |buffer, cx| {
4982            buffer.set_text(output, cx);
4983        });
4984
4985        let buffer = cx.new(|cx| {
4986            editor::MultiBuffer::singleton(buffer, cx).with_title("Workspace Info".into())
4987        });
4988
4989        _this.update_in(cx, |workspace, window, cx| {
4990            workspace.add_item_to_active_pane(
4991                Box::new(cx.new(|cx| {
4992                    let mut editor =
4993                        editor::Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
4994                    editor.set_read_only(true);
4995                    editor.set_should_serialize(false, cx);
4996                    editor.set_breadcrumb_header("Workspace Info".into());
4997                    editor
4998                })),
4999                None,
5000                true,
5001                window,
5002                cx,
5003            );
5004        })
5005    })
5006    .detach_and_log_err(cx);
5007}
5008
5009fn dump_single_workspace(workspace: &Workspace, output: &mut String, cx: &gpui::App) {
5010    use std::fmt::Write;
5011
5012    let workspace_db_id = workspace.database_id();
5013    match workspace_db_id {
5014        Some(id) => writeln!(output, "Workspace DB ID: {id:?}").ok(),
5015        None => writeln!(output, "Workspace DB ID: (none)").ok(),
5016    };
5017
5018    let project = workspace.project().read(cx);
5019
5020    let repos: Vec<_> = project
5021        .repositories(cx)
5022        .values()
5023        .map(|repo| repo.read(cx).snapshot())
5024        .collect();
5025
5026    writeln!(output, "Worktrees:").ok();
5027    for worktree in project.worktrees(cx) {
5028        let worktree = worktree.read(cx);
5029        let abs_path = worktree.abs_path();
5030        let visible = worktree.is_visible();
5031
5032        let repo_info = repos
5033            .iter()
5034            .find(|snapshot| abs_path.starts_with(&*snapshot.work_directory_abs_path));
5035
5036        let is_linked = repo_info.map(|s| s.is_linked_worktree()).unwrap_or(false);
5037        let original_repo_path = repo_info.map(|s| &s.original_repo_abs_path);
5038        let branch = repo_info.and_then(|s| s.branch.as_ref().map(|b| b.ref_name.clone()));
5039
5040        write!(output, "  - {}", abs_path.display()).ok();
5041        if !visible {
5042            write!(output, " (hidden)").ok();
5043        }
5044        if let Some(branch) = &branch {
5045            write!(output, " [branch: {branch}]").ok();
5046        }
5047        if is_linked {
5048            if let Some(original) = original_repo_path {
5049                write!(output, " [linked worktree -> {}]", original.display()).ok();
5050            } else {
5051                write!(output, " [linked worktree]").ok();
5052            }
5053        }
5054        writeln!(output).ok();
5055    }
5056
5057    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
5058        let panel = panel.read(cx);
5059
5060        let panel_workspace_id = panel.workspace_id();
5061        if panel_workspace_id != workspace_db_id {
5062            writeln!(
5063                output,
5064                "  \u{26a0} workspace ID mismatch! panel has {panel_workspace_id:?}, workspace has {workspace_db_id:?}"
5065            )
5066            .ok();
5067        }
5068
5069        if let Some(thread) = panel.active_agent_thread(cx) {
5070            let thread = thread.read(cx);
5071            let title = thread.title().unwrap_or_else(|| "(untitled)".into());
5072            let session_id = thread.session_id();
5073            let status = match thread.status() {
5074                ThreadStatus::Idle => "idle",
5075                ThreadStatus::Generating => "generating",
5076            };
5077            let entry_count = thread.entries().len();
5078            write!(output, "Active thread: {title} (session: {session_id})").ok();
5079            write!(output, " [{status}, {entry_count} entries").ok();
5080            if panel
5081                .active_conversation_view()
5082                .is_some_and(|conversation_view| {
5083                    conversation_view
5084                        .read(cx)
5085                        .root_thread_has_pending_tool_call(cx)
5086                })
5087            {
5088                write!(output, ", awaiting confirmation").ok();
5089            }
5090            writeln!(output, "]").ok();
5091        } else {
5092            writeln!(output, "Active thread: (none)").ok();
5093        }
5094
5095        let background_threads = panel.background_threads();
5096        if !background_threads.is_empty() {
5097            writeln!(
5098                output,
5099                "Background threads ({}): ",
5100                background_threads.len()
5101            )
5102            .ok();
5103            for (session_id, conversation_view) in background_threads {
5104                if let Some(thread_view) = conversation_view.read(cx).root_thread(cx) {
5105                    let thread = thread_view.read(cx).thread.read(cx);
5106                    let title = thread.title().unwrap_or_else(|| "(untitled)".into());
5107                    let status = match thread.status() {
5108                        ThreadStatus::Idle => "idle",
5109                        ThreadStatus::Generating => "generating",
5110                    };
5111                    let entry_count = thread.entries().len();
5112                    write!(output, "  - {title} (session: {session_id})").ok();
5113                    write!(output, " [{status}, {entry_count} entries").ok();
5114                    if conversation_view
5115                        .read(cx)
5116                        .root_thread_has_pending_tool_call(cx)
5117                    {
5118                        write!(output, ", awaiting confirmation").ok();
5119                    }
5120                    writeln!(output, "]").ok();
5121                } else {
5122                    writeln!(output, "  - (not connected) (session: {session_id})").ok();
5123                }
5124            }
5125        }
5126    } else {
5127        writeln!(output, "Agent panel: not loaded").ok();
5128    }
5129
5130    writeln!(output).ok();
5131}