sidebar.rs

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