sidebar.rs

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