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