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