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