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