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