sidebar.rs

   1use acp_thread::ThreadStatus;
   2use action_log::DiffStats;
   3use agent_client_protocol::{self as acp};
   4use agent_ui::thread_metadata_store::{SidebarThreadMetadataStore, ThreadMetadata};
   5use agent_ui::threads_archive_view::{
   6    ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp,
   7};
   8use agent_ui::{
   9    Agent, AgentPanel, AgentPanelEvent, DEFAULT_THREAD_TITLE, NewThread, RemoveSelectedThread,
  10};
  11use chrono::Utc;
  12use editor::Editor;
  13use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _};
  14use gpui::{
  15    Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, ListState, Pixels,
  16    Render, SharedString, WeakEntity, Window, WindowHandle, list, prelude::*, px,
  17};
  18use menu::{
  19    Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious,
  20};
  21use project::{AgentId, Event as ProjectEvent, linked_worktree_short_name};
  22use recent_projects::sidebar_recent_projects::SidebarRecentProjects;
  23use ui::utils::platform_title_bar_height;
  24
  25use settings::Settings as _;
  26use std::collections::{HashMap, HashSet};
  27use std::mem;
  28use std::path::Path;
  29use std::rc::Rc;
  30use std::sync::Arc;
  31use theme::ActiveTheme;
  32use ui::{
  33    AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, HighlightedLabel, KeyBinding,
  34    PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, prelude::*,
  35};
  36use util::ResultExt as _;
  37use util::path_list::PathList;
  38use workspace::{
  39    AddFolderToProject, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, Open,
  40    Sidebar as WorkspaceSidebar, ToggleWorkspaceSidebar, Workspace, WorkspaceId,
  41};
  42
  43use zed_actions::OpenRecent;
  44use zed_actions::editor::{MoveDown, MoveUp};
  45
  46use zed_actions::agents_sidebar::FocusSidebarFilter;
  47
  48gpui::actions!(
  49    agents_sidebar,
  50    [
  51        /// Creates a new thread in the currently selected or active project group.
  52        NewThreadInGroup,
  53        /// Toggles between the thread list and the archive view.
  54        ToggleArchive,
  55    ]
  56);
  57
  58const DEFAULT_WIDTH: Pixels = px(300.0);
  59const MIN_WIDTH: Pixels = px(200.0);
  60const MAX_WIDTH: Pixels = px(800.0);
  61const DEFAULT_THREADS_SHOWN: usize = 5;
  62
  63#[derive(Debug, Default)]
  64enum SidebarView {
  65    #[default]
  66    ThreadList,
  67    Archive(Entity<ThreadsArchiveView>),
  68}
  69
  70#[derive(Clone, Debug)]
  71struct ActiveThreadInfo {
  72    session_id: acp::SessionId,
  73    title: SharedString,
  74    status: AgentThreadStatus,
  75    icon: IconName,
  76    icon_from_external_svg: Option<SharedString>,
  77    is_background: bool,
  78    is_title_generating: bool,
  79    diff_stats: DiffStats,
  80}
  81
  82impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo {
  83    fn from(info: &ActiveThreadInfo) -> Self {
  84        Self {
  85            session_id: info.session_id.clone(),
  86            work_dirs: None,
  87            title: Some(info.title.clone()),
  88            updated_at: Some(Utc::now()),
  89            created_at: Some(Utc::now()),
  90            meta: None,
  91        }
  92    }
  93}
  94
  95#[derive(Clone)]
  96enum ThreadEntryWorkspace {
  97    Open(Entity<Workspace>),
  98    Closed(PathList),
  99}
 100
 101#[derive(Clone)]
 102struct ThreadEntry {
 103    agent: Agent,
 104    session_info: acp_thread::AgentSessionInfo,
 105    icon: IconName,
 106    icon_from_external_svg: Option<SharedString>,
 107    status: AgentThreadStatus,
 108    workspace: ThreadEntryWorkspace,
 109    is_live: bool,
 110    is_background: bool,
 111    is_title_generating: bool,
 112    highlight_positions: Vec<usize>,
 113    worktree_name: Option<SharedString>,
 114    worktree_full_path: Option<SharedString>,
 115    worktree_highlight_positions: Vec<usize>,
 116    diff_stats: DiffStats,
 117}
 118
 119#[derive(Clone)]
 120enum ListEntry {
 121    ProjectHeader {
 122        path_list: PathList,
 123        label: SharedString,
 124        workspace: Entity<Workspace>,
 125        highlight_positions: Vec<usize>,
 126        has_running_threads: bool,
 127        waiting_thread_count: usize,
 128        is_active: bool,
 129    },
 130    Thread(ThreadEntry),
 131    ViewMore {
 132        path_list: PathList,
 133        is_fully_expanded: bool,
 134    },
 135    NewThread {
 136        path_list: PathList,
 137        workspace: Entity<Workspace>,
 138        is_active_draft: bool,
 139    },
 140}
 141
 142impl From<ThreadEntry> for ListEntry {
 143    fn from(thread: ThreadEntry) -> Self {
 144        ListEntry::Thread(thread)
 145    }
 146}
 147
 148#[derive(Default)]
 149struct SidebarContents {
 150    entries: Vec<ListEntry>,
 151    notified_threads: HashSet<acp::SessionId>,
 152    project_header_indices: Vec<usize>,
 153    has_open_projects: bool,
 154}
 155
 156impl SidebarContents {
 157    fn is_thread_notified(&self, session_id: &acp::SessionId) -> bool {
 158        self.notified_threads.contains(session_id)
 159    }
 160}
 161
 162fn fuzzy_match_positions(query: &str, candidate: &str) -> Option<Vec<usize>> {
 163    let mut positions = Vec::new();
 164    let mut query_chars = query.chars().peekable();
 165
 166    for (byte_idx, candidate_char) in candidate.char_indices() {
 167        if let Some(&query_char) = query_chars.peek() {
 168            if candidate_char.eq_ignore_ascii_case(&query_char) {
 169                positions.push(byte_idx);
 170                query_chars.next();
 171            }
 172        } else {
 173            break;
 174        }
 175    }
 176
 177    if query_chars.peek().is_none() {
 178        Some(positions)
 179    } else {
 180        None
 181    }
 182}
 183
 184// TODO: The mapping from workspace root paths to git repositories needs a
 185// unified approach across the codebase: this function, `AgentPanel::classify_worktrees`,
 186// thread persistence (which PathList is saved to the database), and thread
 187// querying (which PathList is used to read threads back). All of these need
 188// to agree on how repos are resolved for a given workspace, especially in
 189// multi-root and nested-repo configurations.
 190fn root_repository_snapshots(
 191    workspace: &Entity<Workspace>,
 192    cx: &App,
 193) -> impl Iterator<Item = project::git_store::RepositorySnapshot> {
 194    let path_list = workspace_path_list(workspace, cx);
 195    let project = workspace.read(cx).project().read(cx);
 196    project.repositories(cx).values().filter_map(move |repo| {
 197        let snapshot = repo.read(cx).snapshot();
 198        let is_root = path_list
 199            .paths()
 200            .iter()
 201            .any(|p| p.as_path() == snapshot.work_directory_abs_path.as_ref());
 202        is_root.then_some(snapshot)
 203    })
 204}
 205
 206fn workspace_path_list(workspace: &Entity<Workspace>, cx: &App) -> PathList {
 207    PathList::new(&workspace.read(cx).root_paths(cx))
 208}
 209
 210fn workspace_label_from_path_list(path_list: &PathList) -> SharedString {
 211    let mut names = Vec::with_capacity(path_list.paths().len());
 212    for abs_path in path_list.paths() {
 213        if let Some(name) = abs_path.file_name() {
 214            names.push(name.to_string_lossy().to_string());
 215        }
 216    }
 217    if names.is_empty() {
 218        // TODO: Can we do something better in this case?
 219        "Empty Workspace".into()
 220    } else {
 221        names.join(", ").into()
 222    }
 223}
 224
 225/// The sidebar re-derives its entire entry list from scratch on every
 226/// change via `update_entries` → `rebuild_contents`. Avoid adding
 227/// incremental or inter-event coordination state — if something can
 228/// be computed from the current world state, compute it in the rebuild.
 229pub struct Sidebar {
 230    multi_workspace: WeakEntity<MultiWorkspace>,
 231    width: Pixels,
 232    focus_handle: FocusHandle,
 233    filter_editor: Entity<Editor>,
 234    list_state: ListState,
 235    contents: SidebarContents,
 236    /// The index of the list item that currently has the keyboard focus
 237    ///
 238    /// Note: This is NOT the same as the active item.
 239    selection: Option<usize>,
 240    /// Derived from the active panel's thread in `rebuild_contents`.
 241    /// Only updated when the panel returns `Some` — never cleared by
 242    /// derivation, since the panel may transiently return `None` while
 243    /// loading. User actions may write directly for immediate feedback.
 244    focused_thread: Option<acp::SessionId>,
 245    agent_panel_visible: bool,
 246    active_thread_is_draft: bool,
 247    hovered_thread_index: Option<usize>,
 248    collapsed_groups: HashSet<PathList>,
 249    expanded_groups: HashMap<PathList, usize>,
 250    view: SidebarView,
 251    recent_projects_popover_handle: PopoverMenuHandle<SidebarRecentProjects>,
 252    project_header_menu_ix: Option<usize>,
 253    _subscriptions: Vec<gpui::Subscription>,
 254    _draft_observation: Option<gpui::Subscription>,
 255}
 256
 257impl Sidebar {
 258    pub fn new(
 259        multi_workspace: Entity<MultiWorkspace>,
 260        window: &mut Window,
 261        cx: &mut Context<Self>,
 262    ) -> Self {
 263        let focus_handle = cx.focus_handle();
 264        cx.on_focus_in(&focus_handle, window, Self::focus_in)
 265            .detach();
 266
 267        let filter_editor = cx.new(|cx| {
 268            let mut editor = Editor::single_line(window, cx);
 269            editor.set_use_modal_editing(true);
 270            editor.set_placeholder_text("Search…", window, cx);
 271            editor
 272        });
 273
 274        cx.subscribe_in(
 275            &multi_workspace,
 276            window,
 277            |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
 278                MultiWorkspaceEvent::ActiveWorkspaceChanged => {
 279                    this.observe_draft_editor(cx);
 280                    this.update_entries(cx);
 281                }
 282                MultiWorkspaceEvent::WorkspaceAdded(workspace) => {
 283                    this.subscribe_to_workspace(workspace, window, cx);
 284                    this.update_entries(cx);
 285                }
 286                MultiWorkspaceEvent::WorkspaceRemoved(_) => {
 287                    this.update_entries(cx);
 288                }
 289            },
 290        )
 291        .detach();
 292
 293        cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
 294            if let editor::EditorEvent::BufferEdited = event {
 295                let query = this.filter_editor.read(cx).text(cx);
 296                if !query.is_empty() {
 297                    this.selection.take();
 298                }
 299                this.update_entries(cx);
 300                if !query.is_empty() {
 301                    this.select_first_entry();
 302                }
 303            }
 304        })
 305        .detach();
 306
 307        cx.observe(
 308            &SidebarThreadMetadataStore::global(cx),
 309            |this, _store, cx| {
 310                this.update_entries(cx);
 311            },
 312        )
 313        .detach();
 314
 315        cx.observe_flag::<AgentV2FeatureFlag, _>(window, |_is_enabled, this, _window, cx| {
 316            this.update_entries(cx);
 317        })
 318        .detach();
 319
 320        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
 321        cx.defer_in(window, move |this, window, cx| {
 322            for workspace in &workspaces {
 323                this.subscribe_to_workspace(workspace, window, cx);
 324            }
 325            this.update_entries(cx);
 326        });
 327
 328        Self {
 329            multi_workspace: multi_workspace.downgrade(),
 330            width: DEFAULT_WIDTH,
 331            focus_handle,
 332            filter_editor,
 333            list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
 334            contents: SidebarContents::default(),
 335            selection: None,
 336            focused_thread: None,
 337            agent_panel_visible: false,
 338            active_thread_is_draft: false,
 339            hovered_thread_index: None,
 340            collapsed_groups: HashSet::new(),
 341            expanded_groups: HashMap::new(),
 342            view: SidebarView::default(),
 343            recent_projects_popover_handle: PopoverMenuHandle::default(),
 344            project_header_menu_ix: None,
 345            _subscriptions: Vec::new(),
 346            _draft_observation: None,
 347        }
 348    }
 349
 350    fn is_active_workspace(&self, workspace: &Entity<Workspace>, cx: &App) -> bool {
 351        self.multi_workspace
 352            .upgrade()
 353            .map_or(false, |mw| mw.read(cx).workspace() == workspace)
 354    }
 355
 356    fn subscribe_to_workspace(
 357        &mut self,
 358        workspace: &Entity<Workspace>,
 359        window: &mut Window,
 360        cx: &mut Context<Self>,
 361    ) {
 362        let project = workspace.read(cx).project().clone();
 363        cx.subscribe_in(
 364            &project,
 365            window,
 366            |this, _project, event, _window, cx| match event {
 367                ProjectEvent::WorktreeAdded(_)
 368                | ProjectEvent::WorktreeRemoved(_)
 369                | ProjectEvent::WorktreeOrderChanged => {
 370                    this.update_entries(cx);
 371                }
 372                _ => {}
 373            },
 374        )
 375        .detach();
 376
 377        let git_store = workspace.read(cx).project().read(cx).git_store().clone();
 378        cx.subscribe_in(
 379            &git_store,
 380            window,
 381            |this, _, event: &project::git_store::GitStoreEvent, window, cx| {
 382                if matches!(
 383                    event,
 384                    project::git_store::GitStoreEvent::RepositoryUpdated(
 385                        _,
 386                        project::git_store::RepositoryEvent::GitWorktreeListChanged,
 387                        _,
 388                    )
 389                ) {
 390                    this.prune_stale_worktree_workspaces(window, cx);
 391                    this.update_entries(cx);
 392                }
 393            },
 394        )
 395        .detach();
 396
 397        cx.subscribe_in(
 398            workspace,
 399            window,
 400            |this, _workspace, event: &workspace::Event, window, cx| {
 401                if let workspace::Event::PanelAdded(view) = event {
 402                    if let Ok(agent_panel) = view.clone().downcast::<AgentPanel>() {
 403                        this.subscribe_to_agent_panel(&agent_panel, window, cx);
 404                    }
 405                }
 406            },
 407        )
 408        .detach();
 409
 410        self.observe_docks(workspace, cx);
 411
 412        if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
 413            self.subscribe_to_agent_panel(&agent_panel, window, cx);
 414            if self.is_active_workspace(workspace, cx) {
 415                self.agent_panel_visible = AgentPanel::is_visible(workspace, cx);
 416            }
 417            self.observe_draft_editor(cx);
 418        }
 419    }
 420
 421    fn subscribe_to_agent_panel(
 422        &mut self,
 423        agent_panel: &Entity<AgentPanel>,
 424        window: &mut Window,
 425        cx: &mut Context<Self>,
 426    ) {
 427        cx.subscribe_in(
 428            agent_panel,
 429            window,
 430            |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event {
 431                AgentPanelEvent::ActiveViewChanged => {
 432                    let is_new_draft = agent_panel
 433                        .read(cx)
 434                        .active_conversation_view()
 435                        .is_some_and(|cv| cv.read(cx).parent_id(cx).is_none());
 436                    if is_new_draft {
 437                        this.focused_thread = None;
 438                    }
 439                    this.observe_draft_editor(cx);
 440                    this.update_entries(cx);
 441                }
 442                AgentPanelEvent::ThreadFocused | AgentPanelEvent::BackgroundThreadChanged => {
 443                    this.update_entries(cx);
 444                }
 445            },
 446        )
 447        .detach();
 448    }
 449
 450    fn observe_docks(&mut self, workspace: &Entity<Workspace>, cx: &mut Context<Self>) {
 451        let docks: Vec<_> = workspace
 452            .read(cx)
 453            .all_docks()
 454            .into_iter()
 455            .cloned()
 456            .collect();
 457        let workspace = workspace.downgrade();
 458        for dock in docks {
 459            let workspace = workspace.clone();
 460            cx.observe(&dock, move |this, _dock, cx| {
 461                let Some(workspace) = workspace.upgrade() else {
 462                    return;
 463                };
 464                if !this.is_active_workspace(&workspace, cx) {
 465                    return;
 466                }
 467
 468                let is_visible = AgentPanel::is_visible(&workspace, cx);
 469
 470                if this.agent_panel_visible != is_visible {
 471                    this.agent_panel_visible = is_visible;
 472                    cx.notify();
 473                }
 474            })
 475            .detach();
 476        }
 477    }
 478
 479    fn observe_draft_editor(&mut self, cx: &mut Context<Self>) {
 480        self._draft_observation = self
 481            .multi_workspace
 482            .upgrade()
 483            .and_then(|mw| {
 484                let ws = mw.read(cx).workspace();
 485                ws.read(cx).panel::<AgentPanel>(cx)
 486            })
 487            .and_then(|panel| {
 488                let cv = panel.read(cx).active_conversation_view()?;
 489                let tv = cv.read(cx).active_thread()?;
 490                Some(tv.read(cx).message_editor.clone())
 491            })
 492            .map(|editor| {
 493                cx.observe(&editor, |_this, _editor, cx| {
 494                    cx.notify();
 495                })
 496            });
 497    }
 498
 499    fn active_draft_text(&self, cx: &App) -> Option<SharedString> {
 500        let mw = self.multi_workspace.upgrade()?;
 501        let workspace = mw.read(cx).workspace();
 502        let panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
 503        let conversation_view = panel.read(cx).active_conversation_view()?;
 504        let thread_view = conversation_view.read(cx).active_thread()?;
 505        let raw = thread_view.read(cx).message_editor.read(cx).text(cx);
 506        let cleaned = Self::clean_mention_links(&raw);
 507        let mut text: String = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
 508        if text.is_empty() {
 509            None
 510        } else {
 511            const MAX_CHARS: usize = 250;
 512            if let Some((truncate_at, _)) = text.char_indices().nth(MAX_CHARS) {
 513                text.truncate(truncate_at);
 514            }
 515            Some(text.into())
 516        }
 517    }
 518
 519    fn clean_mention_links(input: &str) -> String {
 520        let mut result = String::with_capacity(input.len());
 521        let mut remaining = input;
 522
 523        while let Some(start) = remaining.find("[@") {
 524            result.push_str(&remaining[..start]);
 525            let after_bracket = &remaining[start + 1..]; // skip '['
 526            if let Some(close_bracket) = after_bracket.find("](") {
 527                let mention = &after_bracket[..close_bracket]; // "@something"
 528                let after_link_start = &after_bracket[close_bracket + 2..]; // after "]("
 529                if let Some(close_paren) = after_link_start.find(')') {
 530                    result.push_str(mention);
 531                    remaining = &after_link_start[close_paren + 1..];
 532                    continue;
 533                }
 534            }
 535            // Couldn't parse full link syntax — emit the literal "[@" and move on.
 536            result.push_str("[@");
 537            remaining = &remaining[start + 2..];
 538        }
 539        result.push_str(remaining);
 540        result
 541    }
 542
 543    /// When modifying this thread, aim for a single forward pass over workspaces
 544    /// and threads plus an O(T log T) sort. Avoid adding extra scans over the data.
 545    fn rebuild_contents(&mut self, cx: &App) {
 546        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
 547            return;
 548        };
 549        let mw = multi_workspace.read(cx);
 550        let workspaces = mw.workspaces().to_vec();
 551        let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned();
 552
 553        // Build a lookup for agent icons from the first workspace's AgentServerStore.
 554        let agent_server_store = workspaces
 555            .first()
 556            .map(|ws| ws.read(cx).project().read(cx).agent_server_store().clone());
 557
 558        let query = self.filter_editor.read(cx).text(cx);
 559
 560        // Re-derive agent_panel_visible from the active workspace so it stays
 561        // correct after workspace switches.
 562        self.agent_panel_visible = active_workspace
 563            .as_ref()
 564            .map_or(false, |ws| AgentPanel::is_visible(ws, cx));
 565
 566        // Derive active_thread_is_draft BEFORE focused_thread so we can
 567        // use it as a guard below.
 568        self.active_thread_is_draft = active_workspace
 569            .as_ref()
 570            .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
 571            .map_or(false, |panel| panel.read(cx).active_thread_is_draft(cx));
 572
 573        // Derive focused_thread from the active workspace's agent panel.
 574        // Only update when the panel gives us a positive signal — if the
 575        // panel returns None (e.g. still loading after a thread activation),
 576        // keep the previous value so eager writes from user actions survive.
 577        let panel_focused = active_workspace
 578            .as_ref()
 579            .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
 580            .and_then(|panel| {
 581                panel
 582                    .read(cx)
 583                    .active_conversation_view()
 584                    .and_then(|cv| cv.read(cx).parent_id(cx))
 585            });
 586        if panel_focused.is_some() && !self.active_thread_is_draft {
 587            self.focused_thread = panel_focused;
 588        }
 589
 590        let previous = mem::take(&mut self.contents);
 591
 592        let old_statuses: HashMap<acp::SessionId, AgentThreadStatus> = previous
 593            .entries
 594            .iter()
 595            .filter_map(|entry| match entry {
 596                ListEntry::Thread(thread) if thread.is_live => {
 597                    Some((thread.session_info.session_id.clone(), thread.status))
 598                }
 599                _ => None,
 600            })
 601            .collect();
 602
 603        let mut entries = Vec::new();
 604        let mut notified_threads = previous.notified_threads;
 605        let mut current_session_ids: HashSet<acp::SessionId> = HashSet::new();
 606        let mut project_header_indices: Vec<usize> = Vec::new();
 607
 608        // Identify absorbed workspaces in a single pass. A workspace is
 609        // "absorbed" when it points at a git worktree checkout whose main
 610        // repo is open as another workspace — its threads appear under the
 611        // main repo's header instead of getting their own.
 612        let mut main_repo_workspace: HashMap<Arc<Path>, usize> = HashMap::new();
 613        let mut absorbed: HashMap<usize, (usize, SharedString)> = HashMap::new();
 614        let mut pending: HashMap<Arc<Path>, Vec<(usize, SharedString, Arc<Path>)>> = HashMap::new();
 615        let mut absorbed_workspace_by_path: HashMap<Arc<Path>, usize> = HashMap::new();
 616        let workspace_indices_by_path: HashMap<Arc<Path>, Vec<usize>> = workspaces
 617            .iter()
 618            .enumerate()
 619            .flat_map(|(index, workspace)| {
 620                let paths = workspace_path_list(workspace, cx).paths().to_vec();
 621                paths
 622                    .into_iter()
 623                    .map(move |path| (Arc::from(path.as_path()), index))
 624            })
 625            .fold(HashMap::new(), |mut map, (path, index)| {
 626                map.entry(path).or_default().push(index);
 627                map
 628            });
 629
 630        for (i, workspace) in workspaces.iter().enumerate() {
 631            for snapshot in root_repository_snapshots(workspace, cx) {
 632                if snapshot.is_main_worktree() {
 633                    main_repo_workspace
 634                        .entry(snapshot.work_directory_abs_path.clone())
 635                        .or_insert(i);
 636
 637                    for git_worktree in snapshot.linked_worktrees() {
 638                        let worktree_path: Arc<Path> = Arc::from(git_worktree.path.as_path());
 639                        if let Some(worktree_indices) =
 640                            workspace_indices_by_path.get(worktree_path.as_ref())
 641                        {
 642                            for &worktree_idx in worktree_indices {
 643                                if worktree_idx == i {
 644                                    continue;
 645                                }
 646
 647                                let worktree_name = linked_worktree_short_name(
 648                                    &snapshot.original_repo_abs_path,
 649                                    &git_worktree.path,
 650                                )
 651                                .unwrap_or_default();
 652                                absorbed.insert(worktree_idx, (i, worktree_name.clone()));
 653                                absorbed_workspace_by_path
 654                                    .insert(worktree_path.clone(), worktree_idx);
 655                            }
 656                        }
 657                    }
 658
 659                    if let Some(waiting) = pending.remove(&snapshot.work_directory_abs_path) {
 660                        for (ws_idx, name, ws_path) in waiting {
 661                            absorbed.insert(ws_idx, (i, name));
 662                            absorbed_workspace_by_path.insert(ws_path, ws_idx);
 663                        }
 664                    }
 665                } else {
 666                    let name: SharedString = snapshot
 667                        .work_directory_abs_path
 668                        .file_name()
 669                        .unwrap_or_default()
 670                        .to_string_lossy()
 671                        .to_string()
 672                        .into();
 673                    if let Some(&main_idx) =
 674                        main_repo_workspace.get(&snapshot.original_repo_abs_path)
 675                    {
 676                        absorbed.insert(i, (main_idx, name));
 677                        absorbed_workspace_by_path
 678                            .insert(snapshot.work_directory_abs_path.clone(), i);
 679                    } else {
 680                        pending
 681                            .entry(snapshot.original_repo_abs_path.clone())
 682                            .or_default()
 683                            .push((i, name, snapshot.work_directory_abs_path.clone()));
 684                    }
 685                }
 686            }
 687        }
 688
 689        let has_open_projects = workspaces
 690            .iter()
 691            .any(|ws| !workspace_path_list(ws, cx).paths().is_empty());
 692
 693        let active_ws_index = active_workspace
 694            .as_ref()
 695            .and_then(|active| workspaces.iter().position(|ws| ws == active));
 696
 697        for (ws_index, workspace) in workspaces.iter().enumerate() {
 698            if absorbed.contains_key(&ws_index) {
 699                continue;
 700            }
 701
 702            let path_list = workspace_path_list(workspace, cx);
 703            if path_list.paths().is_empty() {
 704                continue;
 705            }
 706
 707            let label = workspace_label_from_path_list(&path_list);
 708
 709            let is_collapsed = self.collapsed_groups.contains(&path_list);
 710            let should_load_threads = !is_collapsed || !query.is_empty();
 711
 712            let is_active = active_ws_index.is_some_and(|active_idx| {
 713                active_idx == ws_index
 714                    || absorbed
 715                        .get(&active_idx)
 716                        .is_some_and(|(main_idx, _)| *main_idx == ws_index)
 717            });
 718
 719            let mut live_infos: Vec<_> = all_thread_infos_for_workspace(workspace, cx).collect();
 720
 721            let mut threads: Vec<ThreadEntry> = Vec::new();
 722            let mut has_running_threads = false;
 723            let mut waiting_thread_count: usize = 0;
 724
 725            if should_load_threads {
 726                let mut seen_session_ids: HashSet<acp::SessionId> = HashSet::new();
 727
 728                // Read threads from the store cache for this workspace's path list.
 729                let thread_store = SidebarThreadMetadataStore::global(cx);
 730                let workspace_rows: Vec<_> =
 731                    thread_store.read(cx).entries_for_path(&path_list).collect();
 732                for row in workspace_rows {
 733                    seen_session_ids.insert(row.session_id.clone());
 734                    let (agent, icon, icon_from_external_svg) = match &row.agent_id {
 735                        None => (Agent::NativeAgent, IconName::ZedAgent, None),
 736                        Some(id) => {
 737                            let custom_icon = agent_server_store
 738                                .as_ref()
 739                                .and_then(|store| store.read(cx).agent_icon(&id));
 740                            (
 741                                Agent::Custom { id: id.clone() },
 742                                IconName::Terminal,
 743                                custom_icon,
 744                            )
 745                        }
 746                    };
 747                    threads.push(ThreadEntry {
 748                        agent,
 749                        session_info: acp_thread::AgentSessionInfo {
 750                            session_id: row.session_id.clone(),
 751                            work_dirs: None,
 752                            title: Some(row.title.clone()),
 753                            updated_at: Some(row.updated_at),
 754                            created_at: row.created_at,
 755                            meta: None,
 756                        },
 757                        icon,
 758                        icon_from_external_svg,
 759                        status: AgentThreadStatus::default(),
 760                        workspace: ThreadEntryWorkspace::Open(workspace.clone()),
 761                        is_live: false,
 762                        is_background: false,
 763                        is_title_generating: false,
 764                        highlight_positions: Vec::new(),
 765                        worktree_name: None,
 766                        worktree_full_path: None,
 767                        worktree_highlight_positions: Vec::new(),
 768                        diff_stats: DiffStats::default(),
 769                    });
 770                }
 771
 772                // Load threads from linked git worktrees of this workspace's repos.
 773                {
 774                    let mut linked_worktree_queries: Vec<(PathList, SharedString, Arc<Path>)> =
 775                        Vec::new();
 776                    for snapshot in root_repository_snapshots(workspace, cx) {
 777                        if snapshot.is_linked_worktree() {
 778                            continue;
 779                        }
 780
 781                        let main_worktree_path = snapshot.original_repo_abs_path.clone();
 782
 783                        for git_worktree in snapshot.linked_worktrees() {
 784                            let worktree_name =
 785                                linked_worktree_short_name(&main_worktree_path, &git_worktree.path)
 786                                    .unwrap_or_default();
 787                            linked_worktree_queries.push((
 788                                PathList::new(std::slice::from_ref(&git_worktree.path)),
 789                                worktree_name,
 790                                Arc::from(git_worktree.path.as_path()),
 791                            ));
 792                        }
 793                    }
 794
 795                    for (worktree_path_list, worktree_name, worktree_path) in
 796                        &linked_worktree_queries
 797                    {
 798                        let target_workspace = match absorbed_workspace_by_path
 799                            .get(worktree_path.as_ref())
 800                        {
 801                            Some(&idx) => {
 802                                live_infos
 803                                    .extend(all_thread_infos_for_workspace(&workspaces[idx], cx));
 804                                ThreadEntryWorkspace::Open(workspaces[idx].clone())
 805                            }
 806                            None => ThreadEntryWorkspace::Closed(worktree_path_list.clone()),
 807                        };
 808
 809                        let worktree_rows: Vec<_> = thread_store
 810                            .read(cx)
 811                            .entries_for_path(worktree_path_list)
 812                            .collect();
 813                        for row in worktree_rows {
 814                            if !seen_session_ids.insert(row.session_id.clone()) {
 815                                continue;
 816                            }
 817                            let (agent, icon, icon_from_external_svg) = match &row.agent_id {
 818                                None => (Agent::NativeAgent, IconName::ZedAgent, None),
 819                                Some(name) => {
 820                                    let custom_icon =
 821                                        agent_server_store.as_ref().and_then(|store| {
 822                                            store.read(cx).agent_icon(&AgentId(name.clone().into()))
 823                                        });
 824                                    (
 825                                        Agent::Custom {
 826                                            id: AgentId::new(name.clone()),
 827                                        },
 828                                        IconName::Terminal,
 829                                        custom_icon,
 830                                    )
 831                                }
 832                            };
 833                            threads.push(ThreadEntry {
 834                                agent,
 835                                session_info: acp_thread::AgentSessionInfo {
 836                                    session_id: row.session_id.clone(),
 837                                    work_dirs: None,
 838                                    title: Some(row.title.clone()),
 839                                    updated_at: Some(row.updated_at),
 840                                    created_at: row.created_at,
 841                                    meta: None,
 842                                },
 843                                icon,
 844                                icon_from_external_svg,
 845                                status: AgentThreadStatus::default(),
 846                                workspace: target_workspace.clone(),
 847                                is_live: false,
 848                                is_background: false,
 849                                is_title_generating: false,
 850                                highlight_positions: Vec::new(),
 851                                worktree_name: Some(worktree_name.clone()),
 852                                worktree_full_path: Some(
 853                                    worktree_path.display().to_string().into(),
 854                                ),
 855                                worktree_highlight_positions: Vec::new(),
 856                                diff_stats: DiffStats::default(),
 857                            });
 858                        }
 859                    }
 860                }
 861
 862                // Build a lookup from live_infos and compute running/waiting
 863                // counts in a single pass.
 864                let mut live_info_by_session: HashMap<&acp::SessionId, &ActiveThreadInfo> =
 865                    HashMap::new();
 866                for info in &live_infos {
 867                    live_info_by_session.insert(&info.session_id, info);
 868                    if info.status == AgentThreadStatus::Running {
 869                        has_running_threads = true;
 870                    }
 871                    if info.status == AgentThreadStatus::WaitingForConfirmation {
 872                        waiting_thread_count += 1;
 873                    }
 874                }
 875
 876                // Merge live info into threads and update notification state
 877                // in a single pass.
 878                for thread in &mut threads {
 879                    let session_id = &thread.session_info.session_id;
 880
 881                    if let Some(info) = live_info_by_session.get(session_id) {
 882                        thread.session_info.title = Some(info.title.clone());
 883                        thread.status = info.status;
 884                        thread.icon = info.icon;
 885                        thread.icon_from_external_svg = info.icon_from_external_svg.clone();
 886                        thread.is_live = true;
 887                        thread.is_background = info.is_background;
 888                        thread.is_title_generating = info.is_title_generating;
 889                        thread.diff_stats = info.diff_stats;
 890                    }
 891
 892                    let is_thread_workspace_active = match &thread.workspace {
 893                        ThreadEntryWorkspace::Open(thread_workspace) => active_workspace
 894                            .as_ref()
 895                            .is_some_and(|active| active == thread_workspace),
 896                        ThreadEntryWorkspace::Closed(_) => false,
 897                    };
 898
 899                    if thread.status == AgentThreadStatus::Completed
 900                        && !is_thread_workspace_active
 901                        && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running)
 902                    {
 903                        notified_threads.insert(session_id.clone());
 904                    }
 905
 906                    if is_thread_workspace_active && !thread.is_background {
 907                        notified_threads.remove(session_id);
 908                    }
 909                }
 910
 911                threads.sort_by(|a, b| {
 912                    let a_time = a.session_info.created_at.or(a.session_info.updated_at);
 913                    let b_time = b.session_info.created_at.or(b.session_info.updated_at);
 914                    b_time.cmp(&a_time)
 915                });
 916            } else {
 917                for info in &live_infos {
 918                    if info.status == AgentThreadStatus::Running {
 919                        has_running_threads = true;
 920                    }
 921                    if info.status == AgentThreadStatus::WaitingForConfirmation {
 922                        waiting_thread_count += 1;
 923                    }
 924                }
 925            }
 926
 927            if !query.is_empty() {
 928                let workspace_highlight_positions =
 929                    fuzzy_match_positions(&query, &label).unwrap_or_default();
 930                let workspace_matched = !workspace_highlight_positions.is_empty();
 931
 932                let mut matched_threads: Vec<ThreadEntry> = Vec::new();
 933                for mut thread in threads {
 934                    let title = thread
 935                        .session_info
 936                        .title
 937                        .as_ref()
 938                        .map(|s| s.as_ref())
 939                        .unwrap_or("");
 940                    if let Some(positions) = fuzzy_match_positions(&query, title) {
 941                        thread.highlight_positions = positions;
 942                    }
 943                    if let Some(worktree_name) = &thread.worktree_name {
 944                        if let Some(positions) = fuzzy_match_positions(&query, worktree_name) {
 945                            thread.worktree_highlight_positions = positions;
 946                        }
 947                    }
 948                    let worktree_matched = !thread.worktree_highlight_positions.is_empty();
 949                    if workspace_matched
 950                        || !thread.highlight_positions.is_empty()
 951                        || worktree_matched
 952                    {
 953                        matched_threads.push(thread);
 954                    }
 955                }
 956
 957                if matched_threads.is_empty() && !workspace_matched {
 958                    continue;
 959                }
 960
 961                project_header_indices.push(entries.len());
 962                entries.push(ListEntry::ProjectHeader {
 963                    path_list: path_list.clone(),
 964                    label,
 965                    workspace: workspace.clone(),
 966                    highlight_positions: workspace_highlight_positions,
 967                    has_running_threads,
 968                    waiting_thread_count,
 969                    is_active,
 970                });
 971
 972                for thread in matched_threads {
 973                    current_session_ids.insert(thread.session_info.session_id.clone());
 974                    entries.push(thread.into());
 975                }
 976            } else {
 977                let thread_count = threads.len();
 978                let is_draft_for_workspace = self.agent_panel_visible
 979                    && self.active_thread_is_draft
 980                    && self.focused_thread.is_none()
 981                    && is_active;
 982
 983                let show_new_thread_entry = thread_count == 0 || is_draft_for_workspace;
 984
 985                project_header_indices.push(entries.len());
 986                entries.push(ListEntry::ProjectHeader {
 987                    path_list: path_list.clone(),
 988                    label,
 989                    workspace: workspace.clone(),
 990                    highlight_positions: Vec::new(),
 991                    has_running_threads,
 992                    waiting_thread_count,
 993                    is_active,
 994                });
 995
 996                if is_collapsed {
 997                    continue;
 998                }
 999
1000                if show_new_thread_entry {
1001                    entries.push(ListEntry::NewThread {
1002                        path_list: path_list.clone(),
1003                        workspace: workspace.clone(),
1004                        is_active_draft: is_draft_for_workspace,
1005                    });
1006                }
1007
1008                let total = threads.len();
1009
1010                let extra_batches = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
1011                let threads_to_show =
1012                    DEFAULT_THREADS_SHOWN + (extra_batches * DEFAULT_THREADS_SHOWN);
1013                let count = threads_to_show.min(total);
1014
1015                let mut promoted_threads: HashSet<acp::SessionId> = HashSet::new();
1016
1017                // Build visible entries in a single pass. Threads within
1018                // the cutoff are always shown. Threads beyond it are shown
1019                // only if they should be promoted (running, waiting, or
1020                // focused)
1021                for (index, thread) in threads.into_iter().enumerate() {
1022                    let is_hidden = index >= count;
1023
1024                    let session_id = &thread.session_info.session_id;
1025                    if is_hidden {
1026                        let is_promoted = thread.status == AgentThreadStatus::Running
1027                            || thread.status == AgentThreadStatus::WaitingForConfirmation
1028                            || notified_threads.contains(session_id)
1029                            || self
1030                                .focused_thread
1031                                .as_ref()
1032                                .is_some_and(|id| id == session_id);
1033                        if is_promoted {
1034                            promoted_threads.insert(session_id.clone());
1035                        }
1036                        if !promoted_threads.contains(session_id) {
1037                            continue;
1038                        }
1039                    }
1040
1041                    current_session_ids.insert(session_id.clone());
1042                    entries.push(thread.into());
1043                }
1044
1045                let visible = count + promoted_threads.len();
1046                let is_fully_expanded = visible >= total;
1047
1048                if total > DEFAULT_THREADS_SHOWN {
1049                    entries.push(ListEntry::ViewMore {
1050                        path_list: path_list.clone(),
1051                        is_fully_expanded,
1052                    });
1053                }
1054            }
1055        }
1056
1057        // Prune stale notifications using the session IDs we collected during
1058        // the build pass (no extra scan needed).
1059        notified_threads.retain(|id| current_session_ids.contains(id));
1060
1061        self.contents = SidebarContents {
1062            entries,
1063            notified_threads,
1064            project_header_indices,
1065            has_open_projects,
1066        };
1067    }
1068
1069    /// Rebuilds the sidebar's visible entries from already-cached state.
1070    fn update_entries(&mut self, cx: &mut Context<Self>) {
1071        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1072            return;
1073        };
1074        if !multi_workspace.read(cx).multi_workspace_enabled(cx) {
1075            return;
1076        }
1077
1078        let had_notifications = self.has_notifications(cx);
1079        let scroll_position = self.list_state.logical_scroll_top();
1080
1081        self.rebuild_contents(cx);
1082
1083        self.list_state.reset(self.contents.entries.len());
1084        self.list_state.scroll_to(scroll_position);
1085
1086        if had_notifications != self.has_notifications(cx) {
1087            multi_workspace.update(cx, |_, cx| {
1088                cx.notify();
1089            });
1090        }
1091
1092        cx.notify();
1093    }
1094
1095    fn select_first_entry(&mut self) {
1096        self.selection = self
1097            .contents
1098            .entries
1099            .iter()
1100            .position(|entry| matches!(entry, ListEntry::Thread(_)))
1101            .or_else(|| {
1102                if self.contents.entries.is_empty() {
1103                    None
1104                } else {
1105                    Some(0)
1106                }
1107            });
1108    }
1109
1110    fn render_list_entry(
1111        &mut self,
1112        ix: usize,
1113        window: &mut Window,
1114        cx: &mut Context<Self>,
1115    ) -> AnyElement {
1116        let Some(entry) = self.contents.entries.get(ix) else {
1117            return div().into_any_element();
1118        };
1119        let is_focused = self.focus_handle.is_focused(window);
1120        // is_selected means the keyboard selector is here.
1121        let is_selected = is_focused && self.selection == Some(ix);
1122
1123        let is_group_header_after_first =
1124            ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. });
1125
1126        let rendered = match entry {
1127            ListEntry::ProjectHeader {
1128                path_list,
1129                label,
1130                workspace,
1131                highlight_positions,
1132                has_running_threads,
1133                waiting_thread_count,
1134                is_active,
1135            } => self.render_project_header(
1136                ix,
1137                false,
1138                path_list,
1139                label,
1140                workspace,
1141                highlight_positions,
1142                *has_running_threads,
1143                *waiting_thread_count,
1144                *is_active,
1145                is_selected,
1146                cx,
1147            ),
1148            ListEntry::Thread(thread) => self.render_thread(ix, thread, is_selected, cx),
1149            ListEntry::ViewMore {
1150                path_list,
1151                is_fully_expanded,
1152            } => self.render_view_more(ix, path_list, *is_fully_expanded, is_selected, cx),
1153            ListEntry::NewThread {
1154                path_list,
1155                workspace,
1156                is_active_draft,
1157            } => {
1158                self.render_new_thread(ix, path_list, workspace, *is_active_draft, is_selected, cx)
1159            }
1160        };
1161
1162        if is_group_header_after_first {
1163            v_flex()
1164                .w_full()
1165                .border_t_1()
1166                .border_color(cx.theme().colors().border.opacity(0.5))
1167                .child(rendered)
1168                .into_any_element()
1169        } else {
1170            rendered
1171        }
1172    }
1173
1174    fn render_project_header(
1175        &self,
1176        ix: usize,
1177        is_sticky: bool,
1178        path_list: &PathList,
1179        label: &SharedString,
1180        workspace: &Entity<Workspace>,
1181        highlight_positions: &[usize],
1182        has_running_threads: bool,
1183        waiting_thread_count: usize,
1184        is_active: bool,
1185        is_selected: bool,
1186        cx: &mut Context<Self>,
1187    ) -> AnyElement {
1188        let id_prefix = if is_sticky { "sticky-" } else { "" };
1189        let id = SharedString::from(format!("{id_prefix}project-header-{ix}"));
1190        let group_name = SharedString::from(format!("{id_prefix}header-group-{ix}"));
1191
1192        let is_collapsed = self.collapsed_groups.contains(path_list);
1193        let disclosure_icon = if is_collapsed {
1194            IconName::ChevronRight
1195        } else {
1196            IconName::ChevronDown
1197        };
1198
1199        let has_new_thread_entry = self
1200            .contents
1201            .entries
1202            .get(ix + 1)
1203            .is_some_and(|entry| matches!(entry, ListEntry::NewThread { .. }));
1204        let show_new_thread_button = !has_new_thread_entry && !self.has_filter_query(cx);
1205
1206        let workspace_for_remove = workspace.clone();
1207        let workspace_for_menu = workspace.clone();
1208        let workspace_for_open = workspace.clone();
1209
1210        let path_list_for_toggle = path_list.clone();
1211        let path_list_for_collapse = path_list.clone();
1212        let view_more_expanded = self.expanded_groups.contains_key(path_list);
1213
1214        let label = if highlight_positions.is_empty() {
1215            Label::new(label.clone())
1216                .color(Color::Muted)
1217                .into_any_element()
1218        } else {
1219            HighlightedLabel::new(label.clone(), highlight_positions.to_vec())
1220                .color(Color::Muted)
1221                .into_any_element()
1222        };
1223
1224        let color = cx.theme().colors();
1225        let hover_color = color
1226            .element_active
1227            .blend(color.element_background.opacity(0.2));
1228
1229        h_flex()
1230            .id(id)
1231            .group(&group_name)
1232            .h(Tab::content_height(cx))
1233            .w_full()
1234            .pl_1p5()
1235            .pr_1()
1236            .border_1()
1237            .map(|this| {
1238                if is_selected {
1239                    this.border_color(color.border_focused)
1240                } else {
1241                    this.border_color(gpui::transparent_black())
1242                }
1243            })
1244            .justify_between()
1245            .hover(|s| s.bg(hover_color))
1246            .child(
1247                h_flex()
1248                    .relative()
1249                    .min_w_0()
1250                    .w_full()
1251                    .gap_1p5()
1252                    .child(
1253                        h_flex().size_4().flex_none().justify_center().child(
1254                            Icon::new(disclosure_icon)
1255                                .size(IconSize::Small)
1256                                .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.5))),
1257                        ),
1258                    )
1259                    .child(label)
1260                    .when(is_collapsed, |this| {
1261                        this.when(has_running_threads, |this| {
1262                            this.child(
1263                                Icon::new(IconName::LoadCircle)
1264                                    .size(IconSize::XSmall)
1265                                    .color(Color::Muted)
1266                                    .with_rotate_animation(2),
1267                            )
1268                        })
1269                        .when(waiting_thread_count > 0, |this| {
1270                            let tooltip_text = if waiting_thread_count == 1 {
1271                                "1 thread is waiting for confirmation".to_string()
1272                            } else {
1273                                format!(
1274                                    "{waiting_thread_count} threads are waiting for confirmation",
1275                                )
1276                            };
1277                            this.child(
1278                                div()
1279                                    .id(format!("{id_prefix}waiting-indicator-{ix}"))
1280                                    .child(
1281                                        Icon::new(IconName::Warning)
1282                                            .size(IconSize::XSmall)
1283                                            .color(Color::Warning),
1284                                    )
1285                                    .tooltip(Tooltip::text(tooltip_text)),
1286                            )
1287                        })
1288                    }),
1289            )
1290            .child({
1291                let workspace_for_new_thread = workspace.clone();
1292                let path_list_for_new_thread = path_list.clone();
1293
1294                h_flex()
1295                    .when(self.project_header_menu_ix != Some(ix), |this| {
1296                        this.visible_on_hover(group_name)
1297                    })
1298                    .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
1299                        cx.stop_propagation();
1300                    })
1301                    .child(self.render_project_header_menu(
1302                        ix,
1303                        id_prefix,
1304                        &workspace_for_menu,
1305                        &workspace_for_remove,
1306                        cx,
1307                    ))
1308                    .when(view_more_expanded && !is_collapsed, |this| {
1309                        this.child(
1310                            IconButton::new(
1311                                SharedString::from(format!(
1312                                    "{id_prefix}project-header-collapse-{ix}",
1313                                )),
1314                                IconName::ListCollapse,
1315                            )
1316                            .icon_size(IconSize::Small)
1317                            .icon_color(Color::Muted)
1318                            .tooltip(Tooltip::text("Collapse Displayed Threads"))
1319                            .on_click(cx.listener({
1320                                let path_list_for_collapse = path_list_for_collapse.clone();
1321                                move |this, _, _window, cx| {
1322                                    this.selection = None;
1323                                    this.expanded_groups.remove(&path_list_for_collapse);
1324                                    this.update_entries(cx);
1325                                }
1326                            })),
1327                        )
1328                    })
1329                    .when(!is_active, |this| {
1330                        this.child(
1331                            IconButton::new(
1332                                SharedString::from(format!(
1333                                    "{id_prefix}project-header-open-workspace-{ix}",
1334                                )),
1335                                IconName::Focus,
1336                            )
1337                            .icon_size(IconSize::Small)
1338                            .icon_color(Color::Muted)
1339                            .tooltip(Tooltip::text("Activate Workspace"))
1340                            .on_click(cx.listener({
1341                                move |this, _, window, cx| {
1342                                    this.focused_thread = None;
1343                                    if let Some(multi_workspace) = this.multi_workspace.upgrade() {
1344                                        multi_workspace.update(cx, |multi_workspace, cx| {
1345                                            multi_workspace
1346                                                .activate(workspace_for_open.clone(), cx);
1347                                        });
1348                                    }
1349                                    if AgentPanel::is_visible(&workspace_for_open, cx) {
1350                                        workspace_for_open.update(cx, |workspace, cx| {
1351                                            workspace.focus_panel::<AgentPanel>(window, cx);
1352                                        });
1353                                    }
1354                                }
1355                            })),
1356                        )
1357                    })
1358                    .when(show_new_thread_button, |this| {
1359                        this.child(
1360                            IconButton::new(
1361                                SharedString::from(format!(
1362                                    "{id_prefix}project-header-new-thread-{ix}",
1363                                )),
1364                                IconName::Plus,
1365                            )
1366                            .icon_size(IconSize::Small)
1367                            .icon_color(Color::Muted)
1368                            .tooltip(Tooltip::text("New Thread"))
1369                            .on_click(cx.listener({
1370                                let workspace_for_new_thread = workspace_for_new_thread.clone();
1371                                let path_list_for_new_thread = path_list_for_new_thread.clone();
1372                                move |this, _, window, cx| {
1373                                    // Uncollapse the group if collapsed so
1374                                    // the new-thread entry becomes visible.
1375                                    this.collapsed_groups.remove(&path_list_for_new_thread);
1376                                    this.selection = None;
1377                                    this.create_new_thread(&workspace_for_new_thread, window, cx);
1378                                }
1379                            })),
1380                        )
1381                    })
1382            })
1383            .on_click(cx.listener(move |this, _, window, cx| {
1384                this.selection = None;
1385                this.toggle_collapse(&path_list_for_toggle, window, cx);
1386            }))
1387            .into_any_element()
1388    }
1389
1390    fn render_project_header_menu(
1391        &self,
1392        ix: usize,
1393        id_prefix: &str,
1394        workspace: &Entity<Workspace>,
1395        workspace_for_remove: &Entity<Workspace>,
1396        cx: &mut Context<Self>,
1397    ) -> impl IntoElement {
1398        let workspace_for_menu = workspace.clone();
1399        let workspace_for_remove = workspace_for_remove.clone();
1400        let multi_workspace = self.multi_workspace.clone();
1401        let this = cx.weak_entity();
1402
1403        PopoverMenu::new(format!("{id_prefix}project-header-menu-{ix}"))
1404            .on_open(Rc::new({
1405                let this = this.clone();
1406                move |_window, cx| {
1407                    this.update(cx, |sidebar, cx| {
1408                        sidebar.project_header_menu_ix = Some(ix);
1409                        cx.notify();
1410                    })
1411                    .ok();
1412                }
1413            }))
1414            .menu(move |window, cx| {
1415                let workspace = workspace_for_menu.clone();
1416                let workspace_for_remove = workspace_for_remove.clone();
1417                let multi_workspace = multi_workspace.clone();
1418
1419                let menu = ContextMenu::build_persistent(window, cx, move |menu, _window, cx| {
1420                    let worktrees: Vec<_> = workspace
1421                        .read(cx)
1422                        .visible_worktrees(cx)
1423                        .map(|worktree| {
1424                            let worktree_read = worktree.read(cx);
1425                            let id = worktree_read.id();
1426                            let name: SharedString =
1427                                worktree_read.root_name().as_unix_str().to_string().into();
1428                            (id, name)
1429                        })
1430                        .collect();
1431
1432                    let worktree_count = worktrees.len();
1433
1434                    let mut menu = menu
1435                        .header("Project Folders")
1436                        .end_slot_action(Box::new(menu::EndSlot));
1437
1438                    for (worktree_id, name) in &worktrees {
1439                        let worktree_id = *worktree_id;
1440                        let workspace_for_worktree = workspace.clone();
1441                        let workspace_for_remove_worktree = workspace_for_remove.clone();
1442                        let multi_workspace_for_worktree = multi_workspace.clone();
1443
1444                        let remove_handler = move |window: &mut Window, cx: &mut App| {
1445                            if worktree_count <= 1 {
1446                                if let Some(mw) = multi_workspace_for_worktree.upgrade() {
1447                                    let ws = workspace_for_remove_worktree.clone();
1448                                    mw.update(cx, |multi_workspace, cx| {
1449                                        if let Some(index) = multi_workspace
1450                                            .workspaces()
1451                                            .iter()
1452                                            .position(|w| *w == ws)
1453                                        {
1454                                            multi_workspace.remove_workspace(index, window, cx);
1455                                        }
1456                                    });
1457                                }
1458                            } else {
1459                                workspace_for_worktree.update(cx, |workspace, cx| {
1460                                    workspace.project().update(cx, |project, cx| {
1461                                        project.remove_worktree(worktree_id, cx);
1462                                    });
1463                                });
1464                            }
1465                        };
1466
1467                        menu = menu.entry_with_end_slot_on_hover(
1468                            name.clone(),
1469                            None,
1470                            |_, _| {},
1471                            IconName::Close,
1472                            "Remove Folder".into(),
1473                            remove_handler,
1474                        );
1475                    }
1476
1477                    let workspace_for_add = workspace.clone();
1478                    let multi_workspace_for_add = multi_workspace.clone();
1479                    let menu = menu.separator().entry(
1480                        "Add Folder to Project",
1481                        Some(Box::new(AddFolderToProject)),
1482                        move |window, cx| {
1483                            if let Some(mw) = multi_workspace_for_add.upgrade() {
1484                                mw.update(cx, |mw, cx| {
1485                                    mw.activate(workspace_for_add.clone(), cx);
1486                                });
1487                            }
1488                            workspace_for_add.update(cx, |workspace, cx| {
1489                                workspace.add_folder_to_project(&AddFolderToProject, window, cx);
1490                            });
1491                        },
1492                    );
1493
1494                    let workspace_count = multi_workspace
1495                        .upgrade()
1496                        .map_or(0, |mw| mw.read(cx).workspaces().len());
1497                    let menu = if workspace_count > 1 {
1498                        let workspace_for_move = workspace.clone();
1499                        let multi_workspace_for_move = multi_workspace.clone();
1500                        menu.entry(
1501                            "Move to New Window",
1502                            Some(Box::new(
1503                                zed_actions::agents_sidebar::MoveWorkspaceToNewWindow,
1504                            )),
1505                            move |window, cx| {
1506                                if let Some(mw) = multi_workspace_for_move.upgrade() {
1507                                    mw.update(cx, |multi_workspace, cx| {
1508                                        if let Some(index) = multi_workspace
1509                                            .workspaces()
1510                                            .iter()
1511                                            .position(|w| *w == workspace_for_move)
1512                                        {
1513                                            multi_workspace
1514                                                .move_workspace_to_new_window(index, window, cx);
1515                                        }
1516                                    });
1517                                }
1518                            },
1519                        )
1520                    } else {
1521                        menu
1522                    };
1523
1524                    let workspace_for_remove = workspace_for_remove.clone();
1525                    let multi_workspace_for_remove = multi_workspace.clone();
1526                    menu.separator()
1527                        .entry("Remove Project", None, move |window, cx| {
1528                            if let Some(mw) = multi_workspace_for_remove.upgrade() {
1529                                let ws = workspace_for_remove.clone();
1530                                mw.update(cx, |multi_workspace, cx| {
1531                                    if let Some(index) =
1532                                        multi_workspace.workspaces().iter().position(|w| *w == ws)
1533                                    {
1534                                        multi_workspace.remove_workspace(index, window, cx);
1535                                    }
1536                                });
1537                            }
1538                        })
1539                });
1540
1541                let this = this.clone();
1542                window
1543                    .subscribe(&menu, cx, move |_, _: &gpui::DismissEvent, _window, cx| {
1544                        this.update(cx, |sidebar, cx| {
1545                            sidebar.project_header_menu_ix = None;
1546                            cx.notify();
1547                        })
1548                        .ok();
1549                    })
1550                    .detach();
1551
1552                Some(menu)
1553            })
1554            .trigger(
1555                IconButton::new(
1556                    SharedString::from(format!("{id_prefix}-ellipsis-menu-{ix}")),
1557                    IconName::Ellipsis,
1558                )
1559                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1560                .icon_size(IconSize::Small)
1561                .icon_color(Color::Muted),
1562            )
1563            .anchor(gpui::Corner::TopRight)
1564            .offset(gpui::Point {
1565                x: px(0.),
1566                y: px(1.),
1567            })
1568    }
1569
1570    fn render_sticky_header(
1571        &self,
1572        window: &mut Window,
1573        cx: &mut Context<Self>,
1574    ) -> Option<AnyElement> {
1575        let scroll_top = self.list_state.logical_scroll_top();
1576
1577        let &header_idx = self
1578            .contents
1579            .project_header_indices
1580            .iter()
1581            .rev()
1582            .find(|&&idx| idx <= scroll_top.item_ix)?;
1583
1584        let needs_sticky = header_idx < scroll_top.item_ix
1585            || (header_idx == scroll_top.item_ix && scroll_top.offset_in_item > px(0.));
1586
1587        if !needs_sticky {
1588            return None;
1589        }
1590
1591        let ListEntry::ProjectHeader {
1592            path_list,
1593            label,
1594            workspace,
1595            highlight_positions,
1596            has_running_threads,
1597            waiting_thread_count,
1598            is_active,
1599        } = self.contents.entries.get(header_idx)?
1600        else {
1601            return None;
1602        };
1603
1604        let is_focused = self.focus_handle.is_focused(window);
1605        let is_selected = is_focused && self.selection == Some(header_idx);
1606
1607        let header_element = self.render_project_header(
1608            header_idx,
1609            true,
1610            &path_list,
1611            &label,
1612            &workspace,
1613            &highlight_positions,
1614            *has_running_threads,
1615            *waiting_thread_count,
1616            *is_active,
1617            is_selected,
1618            cx,
1619        );
1620
1621        let top_offset = self
1622            .contents
1623            .project_header_indices
1624            .iter()
1625            .find(|&&idx| idx > header_idx)
1626            .and_then(|&next_idx| {
1627                let bounds = self.list_state.bounds_for_item(next_idx)?;
1628                let viewport = self.list_state.viewport_bounds();
1629                let y_in_viewport = bounds.origin.y - viewport.origin.y;
1630                let header_height = bounds.size.height;
1631                (y_in_viewport < header_height).then_some(y_in_viewport - header_height)
1632            })
1633            .unwrap_or(px(0.));
1634
1635        let color = cx.theme().colors();
1636        let background = color
1637            .title_bar_background
1638            .blend(color.panel_background.opacity(0.2));
1639
1640        let element = v_flex()
1641            .absolute()
1642            .top(top_offset)
1643            .left_0()
1644            .w_full()
1645            .bg(background)
1646            .border_b_1()
1647            .border_color(color.border.opacity(0.5))
1648            .child(header_element)
1649            .shadow_xs()
1650            .into_any_element();
1651
1652        Some(element)
1653    }
1654
1655    fn prune_stale_worktree_workspaces(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1656        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1657            return;
1658        };
1659        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
1660
1661        // Collect all worktree paths that are currently listed by any main
1662        // repo open in any workspace.
1663        let mut known_worktree_paths: HashSet<std::path::PathBuf> = HashSet::new();
1664        for workspace in &workspaces {
1665            for snapshot in root_repository_snapshots(workspace, cx) {
1666                if snapshot.is_linked_worktree() {
1667                    continue;
1668                }
1669                for git_worktree in snapshot.linked_worktrees() {
1670                    known_worktree_paths.insert(git_worktree.path.to_path_buf());
1671                }
1672            }
1673        }
1674
1675        // Find workspaces that consist of exactly one root folder which is a
1676        // stale worktree checkout. Multi-root workspaces are never pruned —
1677        // losing one worktree shouldn't destroy a workspace that also
1678        // contains other folders.
1679        let mut to_remove: Vec<Entity<Workspace>> = Vec::new();
1680        for workspace in &workspaces {
1681            let path_list = workspace_path_list(workspace, cx);
1682            if path_list.paths().len() != 1 {
1683                continue;
1684            }
1685            let should_prune = root_repository_snapshots(workspace, cx).any(|snapshot| {
1686                snapshot.is_linked_worktree()
1687                    && !known_worktree_paths.contains(snapshot.work_directory_abs_path.as_ref())
1688            });
1689            if should_prune {
1690                to_remove.push(workspace.clone());
1691            }
1692        }
1693
1694        for workspace in &to_remove {
1695            self.remove_workspace(workspace, window, cx);
1696        }
1697    }
1698
1699    fn remove_workspace(
1700        &mut self,
1701        workspace: &Entity<Workspace>,
1702        window: &mut Window,
1703        cx: &mut Context<Self>,
1704    ) {
1705        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1706            return;
1707        };
1708
1709        multi_workspace.update(cx, |multi_workspace, cx| {
1710            let Some(index) = multi_workspace
1711                .workspaces()
1712                .iter()
1713                .position(|w| w == workspace)
1714            else {
1715                return;
1716            };
1717            multi_workspace.remove_workspace(index, window, cx);
1718        });
1719    }
1720
1721    fn toggle_collapse(
1722        &mut self,
1723        path_list: &PathList,
1724        _window: &mut Window,
1725        cx: &mut Context<Self>,
1726    ) {
1727        if self.collapsed_groups.contains(path_list) {
1728            self.collapsed_groups.remove(path_list);
1729        } else {
1730            self.collapsed_groups.insert(path_list.clone());
1731        }
1732        self.update_entries(cx);
1733    }
1734
1735    fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1736        if !self.focus_handle.is_focused(window) {
1737            return;
1738        }
1739
1740        if let SidebarView::Archive(archive) = &self.view {
1741            let has_selection = archive.read(cx).has_selection();
1742            if !has_selection {
1743                archive.update(cx, |view, cx| view.focus_filter_editor(window, cx));
1744            }
1745        } else if self.selection.is_none() {
1746            self.filter_editor.focus_handle(cx).focus(window, cx);
1747        }
1748    }
1749
1750    fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
1751        if self.reset_filter_editor_text(window, cx) {
1752            self.update_entries(cx);
1753        } else {
1754            self.selection = None;
1755            self.filter_editor.focus_handle(cx).focus(window, cx);
1756            cx.notify();
1757        }
1758    }
1759
1760    fn focus_sidebar_filter(
1761        &mut self,
1762        _: &FocusSidebarFilter,
1763        window: &mut Window,
1764        cx: &mut Context<Self>,
1765    ) {
1766        self.selection = None;
1767        if let SidebarView::Archive(archive) = &self.view {
1768            archive.update(cx, |view, cx| {
1769                view.clear_selection();
1770                view.focus_filter_editor(window, cx);
1771            });
1772        } else {
1773            self.filter_editor.focus_handle(cx).focus(window, cx);
1774        }
1775
1776        // When vim mode is active, the editor defaults to normal mode which
1777        // blocks text input. Switch to insert mode so the user can type
1778        // immediately.
1779        if vim_mode_setting::VimModeSetting::get_global(cx).0 {
1780            if let Ok(action) = cx.build_action("vim::SwitchToInsertMode", None) {
1781                window.dispatch_action(action, cx);
1782            }
1783        }
1784
1785        cx.notify();
1786    }
1787
1788    fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1789        self.filter_editor.update(cx, |editor, cx| {
1790            if editor.buffer().read(cx).len(cx).0 > 0 {
1791                editor.set_text("", window, cx);
1792                true
1793            } else {
1794                false
1795            }
1796        })
1797    }
1798
1799    fn has_filter_query(&self, cx: &App) -> bool {
1800        !self.filter_editor.read(cx).text(cx).is_empty()
1801    }
1802
1803    fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
1804        self.select_next(&SelectNext, window, cx);
1805        if self.selection.is_some() {
1806            self.focus_handle.focus(window, cx);
1807        }
1808    }
1809
1810    fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
1811        self.select_previous(&SelectPrevious, window, cx);
1812        if self.selection.is_some() {
1813            self.focus_handle.focus(window, cx);
1814        }
1815    }
1816
1817    fn editor_confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1818        if self.selection.is_none() {
1819            self.select_next(&SelectNext, window, cx);
1820        }
1821        if self.selection.is_some() {
1822            self.focus_handle.focus(window, cx);
1823        }
1824    }
1825
1826    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
1827        let next = match self.selection {
1828            Some(ix) if ix + 1 < self.contents.entries.len() => ix + 1,
1829            Some(_) if !self.contents.entries.is_empty() => 0,
1830            None if !self.contents.entries.is_empty() => 0,
1831            _ => return,
1832        };
1833        self.selection = Some(next);
1834        self.list_state.scroll_to_reveal_item(next);
1835        cx.notify();
1836    }
1837
1838    fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
1839        match self.selection {
1840            Some(0) => {
1841                self.selection = None;
1842                self.filter_editor.focus_handle(cx).focus(window, cx);
1843                cx.notify();
1844            }
1845            Some(ix) => {
1846                self.selection = Some(ix - 1);
1847                self.list_state.scroll_to_reveal_item(ix - 1);
1848                cx.notify();
1849            }
1850            None if !self.contents.entries.is_empty() => {
1851                let last = self.contents.entries.len() - 1;
1852                self.selection = Some(last);
1853                self.list_state.scroll_to_reveal_item(last);
1854                cx.notify();
1855            }
1856            None => {}
1857        }
1858    }
1859
1860    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
1861        if !self.contents.entries.is_empty() {
1862            self.selection = Some(0);
1863            self.list_state.scroll_to_reveal_item(0);
1864            cx.notify();
1865        }
1866    }
1867
1868    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
1869        if let Some(last) = self.contents.entries.len().checked_sub(1) {
1870            self.selection = Some(last);
1871            self.list_state.scroll_to_reveal_item(last);
1872            cx.notify();
1873        }
1874    }
1875
1876    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1877        let Some(ix) = self.selection else { return };
1878        let Some(entry) = self.contents.entries.get(ix) else {
1879            return;
1880        };
1881
1882        match entry {
1883            ListEntry::ProjectHeader { path_list, .. } => {
1884                let path_list = path_list.clone();
1885                self.toggle_collapse(&path_list, window, cx);
1886            }
1887            ListEntry::Thread(thread) => {
1888                let session_info = thread.session_info.clone();
1889                match &thread.workspace {
1890                    ThreadEntryWorkspace::Open(workspace) => {
1891                        let workspace = workspace.clone();
1892                        self.activate_thread(
1893                            thread.agent.clone(),
1894                            session_info,
1895                            &workspace,
1896                            window,
1897                            cx,
1898                        );
1899                    }
1900                    ThreadEntryWorkspace::Closed(path_list) => {
1901                        self.open_workspace_and_activate_thread(
1902                            thread.agent.clone(),
1903                            session_info,
1904                            path_list.clone(),
1905                            window,
1906                            cx,
1907                        );
1908                    }
1909                }
1910            }
1911            ListEntry::ViewMore {
1912                path_list,
1913                is_fully_expanded,
1914                ..
1915            } => {
1916                let path_list = path_list.clone();
1917                if *is_fully_expanded {
1918                    self.expanded_groups.remove(&path_list);
1919                } else {
1920                    let current = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
1921                    self.expanded_groups.insert(path_list, current + 1);
1922                }
1923                self.update_entries(cx);
1924            }
1925            ListEntry::NewThread { workspace, .. } => {
1926                let workspace = workspace.clone();
1927                self.create_new_thread(&workspace, window, cx);
1928            }
1929        }
1930    }
1931
1932    fn find_workspace_across_windows(
1933        &self,
1934        cx: &App,
1935        predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
1936    ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
1937        cx.windows()
1938            .into_iter()
1939            .filter_map(|window| window.downcast::<MultiWorkspace>())
1940            .find_map(|window| {
1941                let workspace = window.read(cx).ok().and_then(|multi_workspace| {
1942                    multi_workspace
1943                        .workspaces()
1944                        .iter()
1945                        .find(|workspace| predicate(workspace, cx))
1946                        .cloned()
1947                })?;
1948                Some((window, workspace))
1949            })
1950    }
1951
1952    fn find_workspace_in_current_window(
1953        &self,
1954        cx: &App,
1955        predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
1956    ) -> Option<Entity<Workspace>> {
1957        self.multi_workspace.upgrade().and_then(|multi_workspace| {
1958            multi_workspace
1959                .read(cx)
1960                .workspaces()
1961                .iter()
1962                .find(|workspace| predicate(workspace, cx))
1963                .cloned()
1964        })
1965    }
1966
1967    fn load_agent_thread_in_workspace(
1968        workspace: &Entity<Workspace>,
1969        agent: Agent,
1970        session_info: acp_thread::AgentSessionInfo,
1971        window: &mut Window,
1972        cx: &mut App,
1973    ) {
1974        workspace.update(cx, |workspace, cx| {
1975            workspace.open_panel::<AgentPanel>(window, cx);
1976        });
1977
1978        if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
1979            agent_panel.update(cx, |panel, cx| {
1980                panel.load_agent_thread(
1981                    agent,
1982                    session_info.session_id,
1983                    session_info.work_dirs,
1984                    session_info.title,
1985                    true,
1986                    window,
1987                    cx,
1988                );
1989            });
1990        }
1991    }
1992
1993    fn activate_thread_locally(
1994        &mut self,
1995        agent: Agent,
1996        session_info: acp_thread::AgentSessionInfo,
1997        workspace: &Entity<Workspace>,
1998        window: &mut Window,
1999        cx: &mut Context<Self>,
2000    ) {
2001        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2002            return;
2003        };
2004
2005        // Set focused_thread eagerly so the sidebar highlight updates
2006        // immediately, rather than waiting for a deferred AgentPanel
2007        // event which can race with ActiveWorkspaceChanged clearing it.
2008        self.focused_thread = Some(session_info.session_id.clone());
2009
2010        multi_workspace.update(cx, |multi_workspace, cx| {
2011            multi_workspace.activate(workspace.clone(), cx);
2012        });
2013
2014        Self::load_agent_thread_in_workspace(workspace, agent, session_info, window, cx);
2015
2016        self.update_entries(cx);
2017    }
2018
2019    fn activate_thread_in_other_window(
2020        &self,
2021        agent: Agent,
2022        session_info: acp_thread::AgentSessionInfo,
2023        workspace: Entity<Workspace>,
2024        target_window: WindowHandle<MultiWorkspace>,
2025        cx: &mut Context<Self>,
2026    ) {
2027        let target_session_id = session_info.session_id.clone();
2028
2029        let activated = target_window
2030            .update(cx, |multi_workspace, window, cx| {
2031                window.activate_window();
2032                multi_workspace.activate(workspace.clone(), cx);
2033                Self::load_agent_thread_in_workspace(&workspace, agent, session_info, window, cx);
2034            })
2035            .log_err()
2036            .is_some();
2037
2038        if activated {
2039            if let Some(target_sidebar) = target_window
2040                .read(cx)
2041                .ok()
2042                .and_then(|multi_workspace| {
2043                    multi_workspace.sidebar().map(|sidebar| sidebar.to_any())
2044                })
2045                .and_then(|sidebar| sidebar.downcast::<Self>().ok())
2046            {
2047                target_sidebar.update(cx, |sidebar, cx| {
2048                    sidebar.focused_thread = Some(target_session_id);
2049                    sidebar.update_entries(cx);
2050                });
2051            }
2052        }
2053    }
2054
2055    fn activate_thread(
2056        &mut self,
2057        agent: Agent,
2058        session_info: acp_thread::AgentSessionInfo,
2059        workspace: &Entity<Workspace>,
2060        window: &mut Window,
2061        cx: &mut Context<Self>,
2062    ) {
2063        if self
2064            .find_workspace_in_current_window(cx, |candidate, _| candidate == workspace)
2065            .is_some()
2066        {
2067            self.activate_thread_locally(agent, session_info, &workspace, window, cx);
2068            return;
2069        }
2070
2071        let Some((target_window, workspace)) =
2072            self.find_workspace_across_windows(cx, |candidate, _| candidate == workspace)
2073        else {
2074            return;
2075        };
2076
2077        self.activate_thread_in_other_window(agent, session_info, workspace, target_window, cx);
2078    }
2079
2080    fn open_workspace_and_activate_thread(
2081        &mut self,
2082        agent: Agent,
2083        session_info: acp_thread::AgentSessionInfo,
2084        path_list: PathList,
2085        window: &mut Window,
2086        cx: &mut Context<Self>,
2087    ) {
2088        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2089            return;
2090        };
2091
2092        let paths: Vec<std::path::PathBuf> =
2093            path_list.paths().iter().map(|p| p.to_path_buf()).collect();
2094
2095        let open_task = multi_workspace.update(cx, |mw, cx| mw.open_project(paths, window, cx));
2096
2097        cx.spawn_in(window, async move |this, cx| {
2098            let workspace = open_task.await?;
2099
2100            this.update_in(cx, |this, window, cx| {
2101                this.activate_thread(agent, session_info, &workspace, window, cx);
2102            })?;
2103            anyhow::Ok(())
2104        })
2105        .detach_and_log_err(cx);
2106    }
2107
2108    fn find_current_workspace_for_path_list(
2109        &self,
2110        path_list: &PathList,
2111        cx: &App,
2112    ) -> Option<Entity<Workspace>> {
2113        self.find_workspace_in_current_window(cx, |workspace, cx| {
2114            workspace_path_list(workspace, cx).paths() == path_list.paths()
2115        })
2116    }
2117
2118    fn find_open_workspace_for_path_list(
2119        &self,
2120        path_list: &PathList,
2121        cx: &App,
2122    ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
2123        self.find_workspace_across_windows(cx, |workspace, cx| {
2124            workspace_path_list(workspace, cx).paths() == path_list.paths()
2125        })
2126    }
2127
2128    fn activate_archived_thread(
2129        &mut self,
2130        agent: Agent,
2131        session_info: acp_thread::AgentSessionInfo,
2132        window: &mut Window,
2133        cx: &mut Context<Self>,
2134    ) {
2135        // Eagerly save thread metadata so that the sidebar is updated immediately
2136        SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| {
2137            store.save(
2138                ThreadMetadata::from_session_info(agent.id(), &session_info),
2139                cx,
2140            )
2141        });
2142
2143        if let Some(path_list) = &session_info.work_dirs {
2144            if let Some(workspace) = self.find_current_workspace_for_path_list(path_list, cx) {
2145                self.activate_thread_locally(agent, session_info, &workspace, window, cx);
2146            } else if let Some((target_window, workspace)) =
2147                self.find_open_workspace_for_path_list(path_list, cx)
2148            {
2149                self.activate_thread_in_other_window(
2150                    agent,
2151                    session_info,
2152                    workspace,
2153                    target_window,
2154                    cx,
2155                );
2156            } else {
2157                let path_list = path_list.clone();
2158                self.open_workspace_and_activate_thread(agent, session_info, path_list, window, cx);
2159            }
2160            return;
2161        }
2162
2163        let active_workspace = self.multi_workspace.upgrade().and_then(|w| {
2164            w.read(cx)
2165                .workspaces()
2166                .get(w.read(cx).active_workspace_index())
2167                .cloned()
2168        });
2169
2170        if let Some(workspace) = active_workspace {
2171            self.activate_thread_locally(agent, session_info, &workspace, window, cx);
2172        }
2173    }
2174
2175    fn expand_selected_entry(
2176        &mut self,
2177        _: &SelectChild,
2178        _window: &mut Window,
2179        cx: &mut Context<Self>,
2180    ) {
2181        let Some(ix) = self.selection else { return };
2182
2183        match self.contents.entries.get(ix) {
2184            Some(ListEntry::ProjectHeader { path_list, .. }) => {
2185                if self.collapsed_groups.contains(path_list) {
2186                    let path_list = path_list.clone();
2187                    self.collapsed_groups.remove(&path_list);
2188                    self.update_entries(cx);
2189                } else if ix + 1 < self.contents.entries.len() {
2190                    self.selection = Some(ix + 1);
2191                    self.list_state.scroll_to_reveal_item(ix + 1);
2192                    cx.notify();
2193                }
2194            }
2195            _ => {}
2196        }
2197    }
2198
2199    fn collapse_selected_entry(
2200        &mut self,
2201        _: &SelectParent,
2202        _window: &mut Window,
2203        cx: &mut Context<Self>,
2204    ) {
2205        let Some(ix) = self.selection else { return };
2206
2207        match self.contents.entries.get(ix) {
2208            Some(ListEntry::ProjectHeader { path_list, .. }) => {
2209                if !self.collapsed_groups.contains(path_list) {
2210                    let path_list = path_list.clone();
2211                    self.collapsed_groups.insert(path_list);
2212                    self.update_entries(cx);
2213                }
2214            }
2215            Some(
2216                ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
2217            ) => {
2218                for i in (0..ix).rev() {
2219                    if let Some(ListEntry::ProjectHeader { path_list, .. }) =
2220                        self.contents.entries.get(i)
2221                    {
2222                        let path_list = path_list.clone();
2223                        self.selection = Some(i);
2224                        self.collapsed_groups.insert(path_list);
2225                        self.update_entries(cx);
2226                        break;
2227                    }
2228                }
2229            }
2230            None => {}
2231        }
2232    }
2233
2234    fn toggle_selected_fold(
2235        &mut self,
2236        _: &editor::actions::ToggleFold,
2237        _window: &mut Window,
2238        cx: &mut Context<Self>,
2239    ) {
2240        let Some(ix) = self.selection else { return };
2241
2242        // Find the group header for the current selection.
2243        let header_ix = match self.contents.entries.get(ix) {
2244            Some(ListEntry::ProjectHeader { .. }) => Some(ix),
2245            Some(
2246                ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
2247            ) => (0..ix).rev().find(|&i| {
2248                matches!(
2249                    self.contents.entries.get(i),
2250                    Some(ListEntry::ProjectHeader { .. })
2251                )
2252            }),
2253            None => None,
2254        };
2255
2256        if let Some(header_ix) = header_ix {
2257            if let Some(ListEntry::ProjectHeader { path_list, .. }) =
2258                self.contents.entries.get(header_ix)
2259            {
2260                let path_list = path_list.clone();
2261                if self.collapsed_groups.contains(&path_list) {
2262                    self.collapsed_groups.remove(&path_list);
2263                } else {
2264                    self.selection = Some(header_ix);
2265                    self.collapsed_groups.insert(path_list);
2266                }
2267                self.update_entries(cx);
2268            }
2269        }
2270    }
2271
2272    fn fold_all(
2273        &mut self,
2274        _: &editor::actions::FoldAll,
2275        _window: &mut Window,
2276        cx: &mut Context<Self>,
2277    ) {
2278        for entry in &self.contents.entries {
2279            if let ListEntry::ProjectHeader { path_list, .. } = entry {
2280                self.collapsed_groups.insert(path_list.clone());
2281            }
2282        }
2283        self.update_entries(cx);
2284    }
2285
2286    fn unfold_all(
2287        &mut self,
2288        _: &editor::actions::UnfoldAll,
2289        _window: &mut Window,
2290        cx: &mut Context<Self>,
2291    ) {
2292        self.collapsed_groups.clear();
2293        self.update_entries(cx);
2294    }
2295
2296    fn stop_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
2297        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2298            return;
2299        };
2300
2301        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
2302        for workspace in workspaces {
2303            if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2304                let cancelled =
2305                    agent_panel.update(cx, |panel, cx| panel.cancel_thread(session_id, cx));
2306                if cancelled {
2307                    return;
2308                }
2309            }
2310        }
2311    }
2312
2313    fn archive_thread(
2314        &mut self,
2315        session_id: &acp::SessionId,
2316        window: &mut Window,
2317        cx: &mut Context<Self>,
2318    ) {
2319        // If we're archiving the currently focused thread, move focus to the
2320        // nearest thread within the same project group. We never cross group
2321        // boundaries — if the group has no other threads, clear focus and open
2322        // a blank new thread in the panel instead.
2323        if self.focused_thread.as_ref() == Some(session_id) {
2324            let current_pos = self.contents.entries.iter().position(|entry| {
2325                matches!(entry, ListEntry::Thread(t) if &t.session_info.session_id == session_id)
2326            });
2327
2328            // Find the workspace that owns this thread's project group by
2329            // walking backwards to the nearest ProjectHeader. We must use
2330            // *this* workspace (not the active workspace) because the user
2331            // might be archiving a thread in a non-active group.
2332            let group_workspace = current_pos.and_then(|pos| {
2333                self.contents.entries[..pos]
2334                    .iter()
2335                    .rev()
2336                    .find_map(|e| match e {
2337                        ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
2338                        _ => None,
2339                    })
2340            });
2341
2342            let next_thread = current_pos.and_then(|pos| {
2343                let group_start = self.contents.entries[..pos]
2344                    .iter()
2345                    .rposition(|e| matches!(e, ListEntry::ProjectHeader { .. }))
2346                    .map_or(0, |i| i + 1);
2347                let group_end = self.contents.entries[pos + 1..]
2348                    .iter()
2349                    .position(|e| matches!(e, ListEntry::ProjectHeader { .. }))
2350                    .map_or(self.contents.entries.len(), |i| pos + 1 + i);
2351
2352                let above = self.contents.entries[group_start..pos]
2353                    .iter()
2354                    .rev()
2355                    .find_map(|entry| {
2356                        if let ListEntry::Thread(t) = entry {
2357                            Some(t)
2358                        } else {
2359                            None
2360                        }
2361                    });
2362
2363                above.or_else(|| {
2364                    self.contents.entries[pos + 1..group_end]
2365                        .iter()
2366                        .find_map(|entry| {
2367                            if let ListEntry::Thread(t) = entry {
2368                                Some(t)
2369                            } else {
2370                                None
2371                            }
2372                        })
2373                })
2374            });
2375
2376            if let Some(next) = next_thread {
2377                self.focused_thread = Some(next.session_info.session_id.clone());
2378
2379                // Use the thread's own workspace when it has one open (e.g. an absorbed
2380                // linked worktree thread that appears under the main workspace's header
2381                // but belongs to its own workspace). Loading into the wrong panel binds
2382                // the thread to the wrong project, which corrupts its stored folder_paths
2383                // when metadata is saved via ThreadMetadata::from_thread.
2384                let target_workspace = match &next.workspace {
2385                    ThreadEntryWorkspace::Open(ws) => Some(ws.clone()),
2386                    ThreadEntryWorkspace::Closed(_) => group_workspace,
2387                };
2388
2389                if let Some(workspace) = target_workspace {
2390                    if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2391                        agent_panel.update(cx, |panel, cx| {
2392                            panel.load_agent_thread(
2393                                next.agent.clone(),
2394                                next.session_info.session_id.clone(),
2395                                next.session_info.work_dirs.clone(),
2396                                next.session_info.title.clone(),
2397                                true,
2398                                window,
2399                                cx,
2400                            );
2401                        });
2402                    }
2403                }
2404            } else {
2405                self.focused_thread = None;
2406                if let Some(workspace) = &group_workspace {
2407                    if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2408                        agent_panel.update(cx, |panel, cx| {
2409                            panel.new_thread(&NewThread, window, cx);
2410                        });
2411                    }
2412                }
2413            }
2414        }
2415
2416        SidebarThreadMetadataStore::global(cx)
2417            .update(cx, |store, cx| store.delete(session_id.clone(), cx));
2418    }
2419
2420    fn remove_selected_thread(
2421        &mut self,
2422        _: &RemoveSelectedThread,
2423        window: &mut Window,
2424        cx: &mut Context<Self>,
2425    ) {
2426        let Some(ix) = self.selection else {
2427            return;
2428        };
2429        let Some(ListEntry::Thread(thread)) = self.contents.entries.get(ix) else {
2430            return;
2431        };
2432        if thread.agent != Agent::NativeAgent {
2433            return;
2434        }
2435        let session_id = thread.session_info.session_id.clone();
2436        self.archive_thread(&session_id, window, cx);
2437    }
2438
2439    fn render_thread(
2440        &self,
2441        ix: usize,
2442        thread: &ThreadEntry,
2443        is_focused: bool,
2444        cx: &mut Context<Self>,
2445    ) -> AnyElement {
2446        let has_notification = self
2447            .contents
2448            .is_thread_notified(&thread.session_info.session_id);
2449
2450        let title: SharedString = thread
2451            .session_info
2452            .title
2453            .clone()
2454            .unwrap_or_else(|| "Untitled".into());
2455        let session_info = thread.session_info.clone();
2456        let thread_workspace = thread.workspace.clone();
2457
2458        let is_hovered = self.hovered_thread_index == Some(ix);
2459        let is_selected = self.agent_panel_visible
2460            && self.focused_thread.as_ref() == Some(&session_info.session_id);
2461        let is_running = matches!(
2462            thread.status,
2463            AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
2464        );
2465
2466        let session_id_for_delete = thread.session_info.session_id.clone();
2467        let focus_handle = self.focus_handle.clone();
2468
2469        let id = SharedString::from(format!("thread-entry-{}", ix));
2470
2471        let timestamp = thread
2472            .session_info
2473            .created_at
2474            .or(thread.session_info.updated_at)
2475            .map(format_history_entry_timestamp);
2476
2477        ThreadItem::new(id, title)
2478            .icon(thread.icon)
2479            .status(thread.status)
2480            .when_some(thread.icon_from_external_svg.clone(), |this, svg| {
2481                this.custom_icon_from_external_svg(svg)
2482            })
2483            .when_some(thread.worktree_name.clone(), |this, name| {
2484                let this = this.worktree(name);
2485                match thread.worktree_full_path.clone() {
2486                    Some(path) => this.worktree_full_path(path),
2487                    None => this,
2488                }
2489            })
2490            .worktree_highlight_positions(thread.worktree_highlight_positions.clone())
2491            .when_some(timestamp, |this, ts| this.timestamp(ts))
2492            .highlight_positions(thread.highlight_positions.to_vec())
2493            .title_generating(thread.is_title_generating)
2494            .notified(has_notification)
2495            .when(thread.diff_stats.lines_added > 0, |this| {
2496                this.added(thread.diff_stats.lines_added as usize)
2497            })
2498            .when(thread.diff_stats.lines_removed > 0, |this| {
2499                this.removed(thread.diff_stats.lines_removed as usize)
2500            })
2501            .selected(is_selected)
2502            .focused(is_focused)
2503            .hovered(is_hovered)
2504            .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| {
2505                if *is_hovered {
2506                    this.hovered_thread_index = Some(ix);
2507                } else if this.hovered_thread_index == Some(ix) {
2508                    this.hovered_thread_index = None;
2509                }
2510                cx.notify();
2511            }))
2512            .when(is_hovered && is_running, |this| {
2513                this.action_slot(
2514                    IconButton::new("stop-thread", IconName::Stop)
2515                        .icon_size(IconSize::Small)
2516                        .icon_color(Color::Error)
2517                        .style(ButtonStyle::Tinted(TintColor::Error))
2518                        .tooltip(Tooltip::text("Stop Generation"))
2519                        .on_click({
2520                            let session_id = session_id_for_delete.clone();
2521                            cx.listener(move |this, _, _window, cx| {
2522                                this.stop_thread(&session_id, cx);
2523                            })
2524                        }),
2525                )
2526            })
2527            .when(is_hovered && !is_running, |this| {
2528                this.action_slot(
2529                    IconButton::new("archive-thread", IconName::Archive)
2530                        .icon_size(IconSize::Small)
2531                        .icon_color(Color::Muted)
2532                        .tooltip({
2533                            let focus_handle = focus_handle.clone();
2534                            move |_window, cx| {
2535                                Tooltip::for_action_in(
2536                                    "Archive Thread",
2537                                    &RemoveSelectedThread,
2538                                    &focus_handle,
2539                                    cx,
2540                                )
2541                            }
2542                        })
2543                        .on_click({
2544                            let session_id = session_id_for_delete.clone();
2545                            cx.listener(move |this, _, window, cx| {
2546                                this.archive_thread(&session_id, window, cx);
2547                            })
2548                        }),
2549                )
2550            })
2551            .on_click({
2552                let agent = thread.agent.clone();
2553                cx.listener(move |this, _, window, cx| {
2554                    this.selection = None;
2555                    match &thread_workspace {
2556                        ThreadEntryWorkspace::Open(workspace) => {
2557                            this.activate_thread(
2558                                agent.clone(),
2559                                session_info.clone(),
2560                                workspace,
2561                                window,
2562                                cx,
2563                            );
2564                        }
2565                        ThreadEntryWorkspace::Closed(path_list) => {
2566                            this.open_workspace_and_activate_thread(
2567                                agent.clone(),
2568                                session_info.clone(),
2569                                path_list.clone(),
2570                                window,
2571                                cx,
2572                            );
2573                        }
2574                    }
2575                })
2576            })
2577            .into_any_element()
2578    }
2579
2580    fn render_filter_input(&self, cx: &mut Context<Self>) -> impl IntoElement {
2581        div()
2582            .min_w_0()
2583            .flex_1()
2584            .capture_action(
2585                cx.listener(|this, _: &editor::actions::Newline, window, cx| {
2586                    this.editor_confirm(window, cx);
2587                }),
2588            )
2589            .child(self.filter_editor.clone())
2590    }
2591
2592    fn render_recent_projects_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
2593        let multi_workspace = self.multi_workspace.upgrade();
2594
2595        let workspace = multi_workspace
2596            .as_ref()
2597            .map(|mw| mw.read(cx).workspace().downgrade());
2598
2599        let focus_handle = workspace
2600            .as_ref()
2601            .and_then(|ws| ws.upgrade())
2602            .map(|w| w.read(cx).focus_handle(cx))
2603            .unwrap_or_else(|| cx.focus_handle());
2604
2605        let sibling_workspace_ids: HashSet<WorkspaceId> = multi_workspace
2606            .as_ref()
2607            .map(|mw| {
2608                mw.read(cx)
2609                    .workspaces()
2610                    .iter()
2611                    .filter_map(|ws| ws.read(cx).database_id())
2612                    .collect()
2613            })
2614            .unwrap_or_default();
2615
2616        let popover_handle = self.recent_projects_popover_handle.clone();
2617
2618        PopoverMenu::new("sidebar-recent-projects-menu")
2619            .with_handle(popover_handle)
2620            .menu(move |window, cx| {
2621                workspace.as_ref().map(|ws| {
2622                    SidebarRecentProjects::popover(
2623                        ws.clone(),
2624                        sibling_workspace_ids.clone(),
2625                        focus_handle.clone(),
2626                        window,
2627                        cx,
2628                    )
2629                })
2630            })
2631            .trigger_with_tooltip(
2632                IconButton::new("open-project", IconName::OpenFolder)
2633                    .icon_size(IconSize::Small)
2634                    .selected_style(ButtonStyle::Tinted(TintColor::Accent)),
2635                |_window, cx| {
2636                    Tooltip::for_action(
2637                        "Add Project",
2638                        &OpenRecent {
2639                            create_new_window: false,
2640                        },
2641                        cx,
2642                    )
2643                },
2644            )
2645            .offset(gpui::Point {
2646                x: px(-2.0),
2647                y: px(-2.0),
2648            })
2649            .anchor(gpui::Corner::BottomRight)
2650    }
2651
2652    fn render_view_more(
2653        &self,
2654        ix: usize,
2655        path_list: &PathList,
2656        is_fully_expanded: bool,
2657        is_selected: bool,
2658        cx: &mut Context<Self>,
2659    ) -> AnyElement {
2660        let path_list = path_list.clone();
2661        let id = SharedString::from(format!("view-more-{}", ix));
2662
2663        let label: SharedString = if is_fully_expanded {
2664            "Collapse".into()
2665        } else {
2666            "View More".into()
2667        };
2668
2669        ThreadItem::new(id, label)
2670            .focused(is_selected)
2671            .icon_visible(false)
2672            .title_label_color(Color::Muted)
2673            .on_click(cx.listener(move |this, _, _window, cx| {
2674                this.selection = None;
2675                if is_fully_expanded {
2676                    this.expanded_groups.remove(&path_list);
2677                } else {
2678                    let current = this.expanded_groups.get(&path_list).copied().unwrap_or(0);
2679                    this.expanded_groups.insert(path_list.clone(), current + 1);
2680                }
2681                this.update_entries(cx);
2682            }))
2683            .into_any_element()
2684    }
2685
2686    fn new_thread_in_group(
2687        &mut self,
2688        _: &NewThreadInGroup,
2689        window: &mut Window,
2690        cx: &mut Context<Self>,
2691    ) {
2692        // If there is a keyboard selection, walk backwards through
2693        // `project_header_indices` to find the header that owns the selected
2694        // row. Otherwise fall back to the active workspace.
2695        let workspace = if let Some(selected_ix) = self.selection {
2696            self.contents
2697                .project_header_indices
2698                .iter()
2699                .rev()
2700                .find(|&&header_ix| header_ix <= selected_ix)
2701                .and_then(|&header_ix| match &self.contents.entries[header_ix] {
2702                    ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
2703                    _ => None,
2704                })
2705        } else {
2706            // Use the currently active workspace.
2707            self.multi_workspace
2708                .upgrade()
2709                .map(|mw| mw.read(cx).workspace().clone())
2710        };
2711
2712        let Some(workspace) = workspace else {
2713            return;
2714        };
2715
2716        self.create_new_thread(&workspace, window, cx);
2717    }
2718
2719    fn create_new_thread(
2720        &mut self,
2721        workspace: &Entity<Workspace>,
2722        window: &mut Window,
2723        cx: &mut Context<Self>,
2724    ) {
2725        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2726            return;
2727        };
2728
2729        // Clear focused_thread immediately so no existing thread stays
2730        // highlighted while the new blank thread is being shown. Without this,
2731        // if the target workspace is already active (so ActiveWorkspaceChanged
2732        // never fires), the previous thread's highlight would linger.
2733        self.focused_thread = None;
2734
2735        multi_workspace.update(cx, |multi_workspace, cx| {
2736            multi_workspace.activate(workspace.clone(), cx);
2737        });
2738
2739        workspace.update(cx, |workspace, cx| {
2740            if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx) {
2741                agent_panel.update(cx, |panel, cx| {
2742                    panel.new_thread(&NewThread, window, cx);
2743                });
2744            }
2745            workspace.focus_panel::<AgentPanel>(window, cx);
2746        });
2747    }
2748
2749    fn render_new_thread(
2750        &self,
2751        ix: usize,
2752        _path_list: &PathList,
2753        workspace: &Entity<Workspace>,
2754        is_active_draft: bool,
2755        is_selected: bool,
2756        cx: &mut Context<Self>,
2757    ) -> AnyElement {
2758        let is_active = is_active_draft && self.agent_panel_visible && self.active_thread_is_draft;
2759
2760        let label: SharedString = if is_active {
2761            self.active_draft_text(cx)
2762                .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into())
2763        } else {
2764            DEFAULT_THREAD_TITLE.into()
2765        };
2766
2767        let workspace = workspace.clone();
2768        let id = SharedString::from(format!("new-thread-btn-{}", ix));
2769
2770        let thread_item = ThreadItem::new(id, label)
2771            .icon(IconName::Plus)
2772            .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.8)))
2773            .selected(is_active)
2774            .focused(is_selected)
2775            .when(!is_active, |this| {
2776                this.on_click(cx.listener(move |this, _, window, cx| {
2777                    this.selection = None;
2778                    this.create_new_thread(&workspace, window, cx);
2779                }))
2780            });
2781
2782        if is_active {
2783            div()
2784                .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
2785                    cx.stop_propagation();
2786                })
2787                .child(thread_item)
2788                .into_any_element()
2789        } else {
2790            thread_item.into_any_element()
2791        }
2792    }
2793
2794    fn render_no_results(&self, cx: &mut Context<Self>) -> impl IntoElement {
2795        let has_query = self.has_filter_query(cx);
2796        let message = if has_query {
2797            "No threads match your search."
2798        } else {
2799            "No threads yet"
2800        };
2801
2802        v_flex()
2803            .id("sidebar-no-results")
2804            .p_4()
2805            .size_full()
2806            .items_center()
2807            .justify_center()
2808            .child(
2809                Label::new(message)
2810                    .size(LabelSize::Small)
2811                    .color(Color::Muted),
2812            )
2813    }
2814
2815    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
2816        v_flex()
2817            .id("sidebar-empty-state")
2818            .p_4()
2819            .size_full()
2820            .items_center()
2821            .justify_center()
2822            .gap_1()
2823            .track_focus(&self.focus_handle(cx))
2824            .child(
2825                Button::new("open_project", "Open Project")
2826                    .full_width()
2827                    .key_binding(KeyBinding::for_action(&workspace::Open::default(), cx))
2828                    .on_click(|_, window, cx| {
2829                        window.dispatch_action(
2830                            Open {
2831                                create_new_window: false,
2832                            }
2833                            .boxed_clone(),
2834                            cx,
2835                        );
2836                    }),
2837            )
2838            .child(
2839                h_flex()
2840                    .w_1_2()
2841                    .gap_2()
2842                    .child(Divider::horizontal())
2843                    .child(Label::new("or").size(LabelSize::XSmall).color(Color::Muted))
2844                    .child(Divider::horizontal()),
2845            )
2846            .child(
2847                Button::new("clone_repo", "Clone Repository")
2848                    .full_width()
2849                    .on_click(|_, window, cx| {
2850                        window.dispatch_action(git::Clone.boxed_clone(), cx);
2851                    }),
2852            )
2853    }
2854
2855    fn render_sidebar_header(
2856        &self,
2857        no_open_projects: bool,
2858        window: &Window,
2859        cx: &mut Context<Self>,
2860    ) -> impl IntoElement {
2861        let has_query = self.has_filter_query(cx);
2862        let traffic_lights = cfg!(target_os = "macos") && !window.is_fullscreen();
2863        let header_height = platform_title_bar_height(window);
2864
2865        h_flex()
2866            .h(header_height)
2867            .mt_px()
2868            .pb_px()
2869            .map(|this| {
2870                if traffic_lights {
2871                    this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
2872                } else {
2873                    this.pl_1p5()
2874                }
2875            })
2876            .pr_1p5()
2877            .gap_1()
2878            .when(!no_open_projects, |this| {
2879                this.border_b_1()
2880                    .border_color(cx.theme().colors().border)
2881                    .when(traffic_lights, |this| {
2882                        this.child(Divider::vertical().color(ui::DividerColor::Border))
2883                    })
2884                    .child(
2885                        div().ml_1().child(
2886                            Icon::new(IconName::MagnifyingGlass)
2887                                .size(IconSize::Small)
2888                                .color(Color::Muted),
2889                        ),
2890                    )
2891                    .child(self.render_filter_input(cx))
2892                    .child(
2893                        h_flex()
2894                            .gap_1()
2895                            .when(
2896                                self.selection.is_some()
2897                                    && !self.filter_editor.focus_handle(cx).is_focused(window),
2898                                |this| this.child(KeyBinding::for_action(&FocusSidebarFilter, cx)),
2899                            )
2900                            .when(has_query, |this| {
2901                                this.child(
2902                                    IconButton::new("clear_filter", IconName::Close)
2903                                        .icon_size(IconSize::Small)
2904                                        .tooltip(Tooltip::text("Clear Search"))
2905                                        .on_click(cx.listener(|this, _, window, cx| {
2906                                            this.reset_filter_editor_text(window, cx);
2907                                            this.update_entries(cx);
2908                                        })),
2909                                )
2910                            }),
2911                    )
2912            })
2913    }
2914
2915    fn render_sidebar_toggle_button(&self, _cx: &mut Context<Self>) -> impl IntoElement {
2916        IconButton::new("sidebar-close-toggle", IconName::ThreadsSidebarLeftOpen)
2917            .icon_size(IconSize::Small)
2918            .tooltip(Tooltip::element(move |_window, cx| {
2919                v_flex()
2920                    .gap_1()
2921                    .child(
2922                        h_flex()
2923                            .gap_2()
2924                            .justify_between()
2925                            .child(Label::new("Toggle Sidebar"))
2926                            .child(KeyBinding::for_action(&ToggleWorkspaceSidebar, cx)),
2927                    )
2928                    .child(
2929                        h_flex()
2930                            .pt_1()
2931                            .gap_2()
2932                            .border_t_1()
2933                            .border_color(cx.theme().colors().border_variant)
2934                            .justify_between()
2935                            .child(Label::new("Focus Sidebar"))
2936                            .child(KeyBinding::for_action(&FocusWorkspaceSidebar, cx)),
2937                    )
2938                    .into_any_element()
2939            }))
2940            .on_click(|_, window, cx| {
2941                if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
2942                    multi_workspace.update(cx, |multi_workspace, cx| {
2943                        multi_workspace.close_sidebar(window, cx);
2944                    });
2945                }
2946            })
2947    }
2948}
2949
2950impl Sidebar {
2951    fn toggle_archive(&mut self, _: &ToggleArchive, window: &mut Window, cx: &mut Context<Self>) {
2952        match &self.view {
2953            SidebarView::ThreadList => self.show_archive(window, cx),
2954            SidebarView::Archive(_) => self.show_thread_list(window, cx),
2955        }
2956    }
2957
2958    fn show_archive(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2959        let Some(active_workspace) = self.multi_workspace.upgrade().and_then(|w| {
2960            w.read(cx)
2961                .workspaces()
2962                .get(w.read(cx).active_workspace_index())
2963                .cloned()
2964        }) else {
2965            return;
2966        };
2967
2968        let Some(agent_panel) = active_workspace.read(cx).panel::<AgentPanel>(cx) else {
2969            return;
2970        };
2971
2972        let thread_store = agent_panel.read(cx).thread_store().clone();
2973        let fs = active_workspace.read(cx).project().read(cx).fs().clone();
2974        let agent_connection_store = agent_panel.read(cx).connection_store().clone();
2975        let agent_server_store = active_workspace
2976            .read(cx)
2977            .project()
2978            .read(cx)
2979            .agent_server_store()
2980            .clone();
2981
2982        let archive_view = cx.new(|cx| {
2983            ThreadsArchiveView::new(
2984                agent_connection_store,
2985                agent_server_store,
2986                thread_store,
2987                fs,
2988                window,
2989                cx,
2990            )
2991        });
2992        let subscription = cx.subscribe_in(
2993            &archive_view,
2994            window,
2995            |this, _, event: &ThreadsArchiveViewEvent, window, cx| match event {
2996                ThreadsArchiveViewEvent::Close => {
2997                    this.show_thread_list(window, cx);
2998                }
2999                ThreadsArchiveViewEvent::Unarchive {
3000                    agent,
3001                    session_info,
3002                } => {
3003                    this.show_thread_list(window, cx);
3004                    this.activate_archived_thread(agent.clone(), session_info.clone(), window, cx);
3005                }
3006            },
3007        );
3008
3009        self._subscriptions.push(subscription);
3010        self.view = SidebarView::Archive(archive_view.clone());
3011        archive_view.update(cx, |view, cx| view.focus_filter_editor(window, cx));
3012        cx.notify();
3013    }
3014
3015    fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3016        self.view = SidebarView::ThreadList;
3017        self._subscriptions.clear();
3018        let handle = self.filter_editor.read(cx).focus_handle(cx);
3019        handle.focus(window, cx);
3020        cx.notify();
3021    }
3022}
3023
3024impl WorkspaceSidebar for Sidebar {
3025    fn width(&self, _cx: &App) -> Pixels {
3026        self.width
3027    }
3028
3029    fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
3030        self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
3031        cx.notify();
3032    }
3033
3034    fn has_notifications(&self, _cx: &App) -> bool {
3035        !self.contents.notified_threads.is_empty()
3036    }
3037
3038    fn is_threads_list_view_active(&self) -> bool {
3039        matches!(self.view, SidebarView::ThreadList)
3040    }
3041
3042    fn prepare_for_focus(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
3043        self.selection = None;
3044        cx.notify();
3045    }
3046}
3047
3048impl Focusable for Sidebar {
3049    fn focus_handle(&self, _cx: &App) -> FocusHandle {
3050        self.focus_handle.clone()
3051    }
3052}
3053
3054impl Render for Sidebar {
3055    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3056        let _titlebar_height = ui::utils::platform_title_bar_height(window);
3057        let ui_font = theme::setup_ui_font(window, cx);
3058        let sticky_header = self.render_sticky_header(window, cx);
3059
3060        let color = cx.theme().colors();
3061        let bg = color
3062            .title_bar_background
3063            .blend(color.panel_background.opacity(0.32));
3064
3065        let no_open_projects = !self.contents.has_open_projects;
3066        let no_search_results = self.contents.entries.is_empty();
3067
3068        v_flex()
3069            .id("workspace-sidebar")
3070            .key_context("ThreadsSidebar")
3071            .track_focus(&self.focus_handle)
3072            .on_action(cx.listener(Self::select_next))
3073            .on_action(cx.listener(Self::select_previous))
3074            .on_action(cx.listener(Self::editor_move_down))
3075            .on_action(cx.listener(Self::editor_move_up))
3076            .on_action(cx.listener(Self::select_first))
3077            .on_action(cx.listener(Self::select_last))
3078            .on_action(cx.listener(Self::confirm))
3079            .on_action(cx.listener(Self::expand_selected_entry))
3080            .on_action(cx.listener(Self::collapse_selected_entry))
3081            .on_action(cx.listener(Self::toggle_selected_fold))
3082            .on_action(cx.listener(Self::fold_all))
3083            .on_action(cx.listener(Self::unfold_all))
3084            .on_action(cx.listener(Self::cancel))
3085            .on_action(cx.listener(Self::remove_selected_thread))
3086            .on_action(cx.listener(Self::new_thread_in_group))
3087            .on_action(cx.listener(Self::toggle_archive))
3088            .on_action(cx.listener(Self::focus_sidebar_filter))
3089            .on_action(cx.listener(|this, _: &OpenRecent, window, cx| {
3090                this.recent_projects_popover_handle.toggle(window, cx);
3091            }))
3092            .font(ui_font)
3093            .h_full()
3094            .w(self.width)
3095            .bg(bg)
3096            .border_r_1()
3097            .border_color(color.border)
3098            .map(|this| match &self.view {
3099                SidebarView::ThreadList => this
3100                    .child(self.render_sidebar_header(no_open_projects, window, cx))
3101                    .map(|this| {
3102                        if no_open_projects {
3103                            this.child(self.render_empty_state(cx))
3104                        } else {
3105                            this.child(
3106                                v_flex()
3107                                    .relative()
3108                                    .flex_1()
3109                                    .overflow_hidden()
3110                                    .child(
3111                                        list(
3112                                            self.list_state.clone(),
3113                                            cx.processor(Self::render_list_entry),
3114                                        )
3115                                        .flex_1()
3116                                        .size_full(),
3117                                    )
3118                                    .when(no_search_results, |this| {
3119                                        this.child(self.render_no_results(cx))
3120                                    })
3121                                    .when_some(sticky_header, |this, header| this.child(header))
3122                                    .vertical_scrollbar_for(&self.list_state, window, cx),
3123                            )
3124                        }
3125                    }),
3126                SidebarView::Archive(archive_view) => this.child(archive_view.clone()),
3127            })
3128            .child(
3129                h_flex()
3130                    .p_1()
3131                    .gap_1()
3132                    .justify_between()
3133                    .border_t_1()
3134                    .border_color(cx.theme().colors().border)
3135                    .child(self.render_sidebar_toggle_button(cx))
3136                    .child(
3137                        h_flex()
3138                            .gap_1()
3139                            .child(
3140                                IconButton::new("archive", IconName::Archive)
3141                                    .icon_size(IconSize::Small)
3142                                    .toggle_state(matches!(self.view, SidebarView::Archive(..)))
3143                                    .tooltip(move |_, cx| {
3144                                        Tooltip::for_action(
3145                                            "Toggle Archived Threads",
3146                                            &ToggleArchive,
3147                                            cx,
3148                                        )
3149                                    })
3150                                    .on_click(cx.listener(|this, _, window, cx| {
3151                                        this.toggle_archive(&ToggleArchive, window, cx);
3152                                    })),
3153                            )
3154                            .child(self.render_recent_projects_button(cx)),
3155                    ),
3156            )
3157    }
3158}
3159
3160fn all_thread_infos_for_workspace(
3161    workspace: &Entity<Workspace>,
3162    cx: &App,
3163) -> impl Iterator<Item = ActiveThreadInfo> {
3164    enum ThreadInfoIterator<T: Iterator<Item = ActiveThreadInfo>> {
3165        Empty,
3166        Threads(T),
3167    }
3168
3169    impl<T: Iterator<Item = ActiveThreadInfo>> Iterator for ThreadInfoIterator<T> {
3170        type Item = ActiveThreadInfo;
3171
3172        fn next(&mut self) -> Option<Self::Item> {
3173            match self {
3174                ThreadInfoIterator::Empty => None,
3175                ThreadInfoIterator::Threads(threads) => threads.next(),
3176            }
3177        }
3178    }
3179
3180    let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
3181        return ThreadInfoIterator::Empty;
3182    };
3183    let agent_panel = agent_panel.read(cx);
3184
3185    let threads = agent_panel
3186        .parent_threads(cx)
3187        .into_iter()
3188        .map(|thread_view| {
3189            let thread_view_ref = thread_view.read(cx);
3190            let thread = thread_view_ref.thread.read(cx);
3191
3192            let icon = thread_view_ref.agent_icon;
3193            let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone();
3194            let title = thread
3195                .title()
3196                .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into());
3197            let is_native = thread_view_ref.as_native_thread(cx).is_some();
3198            let is_title_generating = is_native && thread.has_provisional_title();
3199            let session_id = thread.session_id().clone();
3200            let is_background = agent_panel.is_background_thread(&session_id);
3201
3202            let status = if thread.is_waiting_for_confirmation() {
3203                AgentThreadStatus::WaitingForConfirmation
3204            } else if thread.had_error() {
3205                AgentThreadStatus::Error
3206            } else {
3207                match thread.status() {
3208                    ThreadStatus::Generating => AgentThreadStatus::Running,
3209                    ThreadStatus::Idle => AgentThreadStatus::Completed,
3210                }
3211            };
3212
3213            let diff_stats = thread.action_log().read(cx).diff_stats(cx);
3214
3215            ActiveThreadInfo {
3216                session_id,
3217                title,
3218                status,
3219                icon,
3220                icon_from_external_svg,
3221                is_background,
3222                is_title_generating,
3223                diff_stats,
3224            }
3225        });
3226
3227    ThreadInfoIterator::Threads(threads)
3228}
3229
3230#[cfg(test)]
3231mod tests {
3232    use super::*;
3233    use acp_thread::StubAgentConnection;
3234    use agent::ThreadStore;
3235    use agent_ui::test_support::{active_session_id, open_thread_with_connection, send_message};
3236    use assistant_text_thread::TextThreadStore;
3237    use chrono::DateTime;
3238    use feature_flags::FeatureFlagAppExt as _;
3239    use fs::FakeFs;
3240    use gpui::TestAppContext;
3241    use pretty_assertions::assert_eq;
3242    use settings::SettingsStore;
3243    use std::{path::PathBuf, sync::Arc};
3244    use util::path_list::PathList;
3245
3246    fn init_test(cx: &mut TestAppContext) {
3247        cx.update(|cx| {
3248            let settings_store = SettingsStore::test(cx);
3249            cx.set_global(settings_store);
3250            theme::init(theme::LoadThemes::JustBase, cx);
3251            editor::init(cx);
3252            cx.update_flags(false, vec!["agent-v2".into()]);
3253            ThreadStore::init_global(cx);
3254            SidebarThreadMetadataStore::init_global(cx);
3255            language_model::LanguageModelRegistry::test(cx);
3256            prompt_store::init(cx);
3257        });
3258    }
3259
3260    fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
3261        sidebar.contents.entries.iter().any(|entry| {
3262            matches!(entry, ListEntry::Thread(t) if &t.session_info.session_id == session_id)
3263        })
3264    }
3265
3266    async fn init_test_project(
3267        worktree_path: &str,
3268        cx: &mut TestAppContext,
3269    ) -> Entity<project::Project> {
3270        init_test(cx);
3271        let fs = FakeFs::new(cx.executor());
3272        fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
3273            .await;
3274        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
3275        project::Project::test(fs, [worktree_path.as_ref()], cx).await
3276    }
3277
3278    fn setup_sidebar(
3279        multi_workspace: &Entity<MultiWorkspace>,
3280        cx: &mut gpui::VisualTestContext,
3281    ) -> Entity<Sidebar> {
3282        let multi_workspace = multi_workspace.clone();
3283        let sidebar =
3284            cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
3285        multi_workspace.update(cx, |mw, cx| {
3286            mw.register_sidebar(sidebar.clone(), cx);
3287        });
3288        cx.run_until_parked();
3289        sidebar
3290    }
3291
3292    async fn save_n_test_threads(
3293        count: u32,
3294        path_list: &PathList,
3295        cx: &mut gpui::VisualTestContext,
3296    ) {
3297        for i in 0..count {
3298            save_thread_metadata(
3299                acp::SessionId::new(Arc::from(format!("thread-{}", i))),
3300                format!("Thread {}", i + 1).into(),
3301                chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
3302                path_list.clone(),
3303                cx,
3304            )
3305            .await;
3306        }
3307        cx.run_until_parked();
3308    }
3309
3310    async fn save_test_thread_metadata(
3311        session_id: &acp::SessionId,
3312        path_list: PathList,
3313        cx: &mut TestAppContext,
3314    ) {
3315        save_thread_metadata(
3316            session_id.clone(),
3317            "Test".into(),
3318            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3319            path_list,
3320            cx,
3321        )
3322        .await;
3323    }
3324
3325    async fn save_named_thread_metadata(
3326        session_id: &str,
3327        title: &str,
3328        path_list: &PathList,
3329        cx: &mut gpui::VisualTestContext,
3330    ) {
3331        save_thread_metadata(
3332            acp::SessionId::new(Arc::from(session_id)),
3333            SharedString::from(title.to_string()),
3334            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3335            path_list.clone(),
3336            cx,
3337        )
3338        .await;
3339        cx.run_until_parked();
3340    }
3341
3342    async fn save_thread_metadata(
3343        session_id: acp::SessionId,
3344        title: SharedString,
3345        updated_at: DateTime<Utc>,
3346        path_list: PathList,
3347        cx: &mut TestAppContext,
3348    ) {
3349        let metadata = ThreadMetadata {
3350            session_id,
3351            agent_id: None,
3352            title,
3353            updated_at,
3354            created_at: None,
3355            folder_paths: path_list,
3356        };
3357        cx.update(|cx| {
3358            SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx))
3359        });
3360        cx.run_until_parked();
3361    }
3362
3363    fn open_and_focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
3364        let multi_workspace = sidebar.read_with(cx, |s, _| s.multi_workspace.upgrade());
3365        if let Some(multi_workspace) = multi_workspace {
3366            multi_workspace.update_in(cx, |mw, window, cx| {
3367                if !mw.sidebar_open() {
3368                    mw.toggle_sidebar(window, cx);
3369                }
3370            });
3371        }
3372        cx.run_until_parked();
3373        sidebar.update_in(cx, |_, window, cx| {
3374            cx.focus_self(window);
3375        });
3376        cx.run_until_parked();
3377    }
3378
3379    fn visible_entries_as_strings(
3380        sidebar: &Entity<Sidebar>,
3381        cx: &mut gpui::VisualTestContext,
3382    ) -> Vec<String> {
3383        sidebar.read_with(cx, |sidebar, _cx| {
3384            sidebar
3385                .contents
3386                .entries
3387                .iter()
3388                .enumerate()
3389                .map(|(ix, entry)| {
3390                    let selected = if sidebar.selection == Some(ix) {
3391                        "  <== selected"
3392                    } else {
3393                        ""
3394                    };
3395                    match entry {
3396                        ListEntry::ProjectHeader {
3397                            label,
3398                            path_list,
3399                            highlight_positions: _,
3400                            ..
3401                        } => {
3402                            let icon = if sidebar.collapsed_groups.contains(path_list) {
3403                                ">"
3404                            } else {
3405                                "v"
3406                            };
3407                            format!("{} [{}]{}", icon, label, selected)
3408                        }
3409                        ListEntry::Thread(thread) => {
3410                            let title = thread
3411                                .session_info
3412                                .title
3413                                .as_ref()
3414                                .map(|s| s.as_ref())
3415                                .unwrap_or("Untitled");
3416                            let active = if thread.is_live { " *" } else { "" };
3417                            let status_str = match thread.status {
3418                                AgentThreadStatus::Running => " (running)",
3419                                AgentThreadStatus::Error => " (error)",
3420                                AgentThreadStatus::WaitingForConfirmation => " (waiting)",
3421                                _ => "",
3422                            };
3423                            let notified = if sidebar
3424                                .contents
3425                                .is_thread_notified(&thread.session_info.session_id)
3426                            {
3427                                " (!)"
3428                            } else {
3429                                ""
3430                            };
3431                            let worktree = thread
3432                                .worktree_name
3433                                .as_ref()
3434                                .map(|name| format!(" {{{}}}", name))
3435                                .unwrap_or_default();
3436                            format!(
3437                                "  {}{}{}{}{}{}",
3438                                title, worktree, active, status_str, notified, selected
3439                            )
3440                        }
3441                        ListEntry::ViewMore {
3442                            is_fully_expanded, ..
3443                        } => {
3444                            if *is_fully_expanded {
3445                                format!("  - Collapse{}", selected)
3446                            } else {
3447                                format!("  + View More{}", selected)
3448                            }
3449                        }
3450                        ListEntry::NewThread { .. } => {
3451                            format!("  [+ New Thread]{}", selected)
3452                        }
3453                    }
3454                })
3455                .collect()
3456        })
3457    }
3458
3459    #[test]
3460    fn test_clean_mention_links() {
3461        // Simple mention link
3462        assert_eq!(
3463            Sidebar::clean_mention_links("check [@Button.tsx](file:///path/to/Button.tsx)"),
3464            "check @Button.tsx"
3465        );
3466
3467        // Multiple mention links
3468        assert_eq!(
3469            Sidebar::clean_mention_links(
3470                "look at [@foo.rs](file:///foo.rs) and [@bar.rs](file:///bar.rs)"
3471            ),
3472            "look at @foo.rs and @bar.rs"
3473        );
3474
3475        // No mention links — passthrough
3476        assert_eq!(
3477            Sidebar::clean_mention_links("plain text with no mentions"),
3478            "plain text with no mentions"
3479        );
3480
3481        // Incomplete link syntax — preserved as-is
3482        assert_eq!(
3483            Sidebar::clean_mention_links("broken [@mention without closing"),
3484            "broken [@mention without closing"
3485        );
3486
3487        // Regular markdown link (no @) — not touched
3488        assert_eq!(
3489            Sidebar::clean_mention_links("see [docs](https://example.com)"),
3490            "see [docs](https://example.com)"
3491        );
3492
3493        // Empty input
3494        assert_eq!(Sidebar::clean_mention_links(""), "");
3495    }
3496
3497    #[gpui::test]
3498    async fn test_entities_released_on_window_close(cx: &mut TestAppContext) {
3499        let project = init_test_project("/my-project", cx).await;
3500        let (multi_workspace, cx) =
3501            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3502        let sidebar = setup_sidebar(&multi_workspace, cx);
3503
3504        let weak_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().downgrade());
3505        let weak_sidebar = sidebar.downgrade();
3506        let weak_multi_workspace = multi_workspace.downgrade();
3507
3508        drop(sidebar);
3509        drop(multi_workspace);
3510        cx.update(|window, _cx| window.remove_window());
3511        cx.run_until_parked();
3512
3513        weak_multi_workspace.assert_released();
3514        weak_sidebar.assert_released();
3515        weak_workspace.assert_released();
3516    }
3517
3518    #[gpui::test]
3519    async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
3520        let project = init_test_project("/my-project", cx).await;
3521        let (multi_workspace, cx) =
3522            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3523        let sidebar = setup_sidebar(&multi_workspace, cx);
3524
3525        assert_eq!(
3526            visible_entries_as_strings(&sidebar, cx),
3527            vec!["v [my-project]", "  [+ New Thread]"]
3528        );
3529    }
3530
3531    #[gpui::test]
3532    async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
3533        let project = init_test_project("/my-project", cx).await;
3534        let (multi_workspace, cx) =
3535            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3536        let sidebar = setup_sidebar(&multi_workspace, cx);
3537
3538        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3539
3540        save_thread_metadata(
3541            acp::SessionId::new(Arc::from("thread-1")),
3542            "Fix crash in project panel".into(),
3543            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
3544            path_list.clone(),
3545            cx,
3546        )
3547        .await;
3548
3549        save_thread_metadata(
3550            acp::SessionId::new(Arc::from("thread-2")),
3551            "Add inline diff view".into(),
3552            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
3553            path_list.clone(),
3554            cx,
3555        )
3556        .await;
3557        cx.run_until_parked();
3558
3559        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3560        cx.run_until_parked();
3561
3562        assert_eq!(
3563            visible_entries_as_strings(&sidebar, cx),
3564            vec![
3565                "v [my-project]",
3566                "  Fix crash in project panel",
3567                "  Add inline diff view",
3568            ]
3569        );
3570    }
3571
3572    #[gpui::test]
3573    async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
3574        let project = init_test_project("/project-a", cx).await;
3575        let (multi_workspace, cx) =
3576            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3577        let sidebar = setup_sidebar(&multi_workspace, cx);
3578
3579        // Single workspace with a thread
3580        let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]);
3581
3582        save_thread_metadata(
3583            acp::SessionId::new(Arc::from("thread-a1")),
3584            "Thread A1".into(),
3585            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3586            path_list.clone(),
3587            cx,
3588        )
3589        .await;
3590        cx.run_until_parked();
3591
3592        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3593        cx.run_until_parked();
3594
3595        assert_eq!(
3596            visible_entries_as_strings(&sidebar, cx),
3597            vec!["v [project-a]", "  Thread A1"]
3598        );
3599
3600        // Add a second workspace
3601        multi_workspace.update_in(cx, |mw, window, cx| {
3602            mw.create_test_workspace(window, cx).detach();
3603        });
3604        cx.run_until_parked();
3605
3606        assert_eq!(
3607            visible_entries_as_strings(&sidebar, cx),
3608            vec!["v [project-a]", "  Thread A1",]
3609        );
3610
3611        // Remove the second workspace
3612        multi_workspace.update_in(cx, |mw, window, cx| {
3613            mw.remove_workspace(1, window, cx);
3614        });
3615        cx.run_until_parked();
3616
3617        assert_eq!(
3618            visible_entries_as_strings(&sidebar, cx),
3619            vec!["v [project-a]", "  Thread A1"]
3620        );
3621    }
3622
3623    #[gpui::test]
3624    async fn test_view_more_pagination(cx: &mut TestAppContext) {
3625        let project = init_test_project("/my-project", cx).await;
3626        let (multi_workspace, cx) =
3627            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3628        let sidebar = setup_sidebar(&multi_workspace, cx);
3629
3630        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3631        save_n_test_threads(12, &path_list, cx).await;
3632
3633        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3634        cx.run_until_parked();
3635
3636        assert_eq!(
3637            visible_entries_as_strings(&sidebar, cx),
3638            vec![
3639                "v [my-project]",
3640                "  Thread 12",
3641                "  Thread 11",
3642                "  Thread 10",
3643                "  Thread 9",
3644                "  Thread 8",
3645                "  + View More",
3646            ]
3647        );
3648    }
3649
3650    #[gpui::test]
3651    async fn test_view_more_batched_expansion(cx: &mut TestAppContext) {
3652        let project = init_test_project("/my-project", cx).await;
3653        let (multi_workspace, cx) =
3654            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3655        let sidebar = setup_sidebar(&multi_workspace, cx);
3656
3657        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3658        // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse
3659        save_n_test_threads(17, &path_list, cx).await;
3660
3661        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3662        cx.run_until_parked();
3663
3664        // Initially shows 5 threads + View More
3665        let entries = visible_entries_as_strings(&sidebar, cx);
3666        assert_eq!(entries.len(), 7); // header + 5 threads + View More
3667        assert!(entries.iter().any(|e| e.contains("View More")));
3668
3669        // Focus and navigate to View More, then confirm to expand by one batch
3670        open_and_focus_sidebar(&sidebar, cx);
3671        for _ in 0..7 {
3672            cx.dispatch_action(SelectNext);
3673        }
3674        cx.dispatch_action(Confirm);
3675        cx.run_until_parked();
3676
3677        // Now shows 10 threads + View More
3678        let entries = visible_entries_as_strings(&sidebar, cx);
3679        assert_eq!(entries.len(), 12); // header + 10 threads + View More
3680        assert!(entries.iter().any(|e| e.contains("View More")));
3681
3682        // Expand again by one batch
3683        sidebar.update_in(cx, |s, _window, cx| {
3684            let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0);
3685            s.expanded_groups.insert(path_list.clone(), current + 1);
3686            s.update_entries(cx);
3687        });
3688        cx.run_until_parked();
3689
3690        // Now shows 15 threads + View More
3691        let entries = visible_entries_as_strings(&sidebar, cx);
3692        assert_eq!(entries.len(), 17); // header + 15 threads + View More
3693        assert!(entries.iter().any(|e| e.contains("View More")));
3694
3695        // Expand one more time - should show all 17 threads with Collapse button
3696        sidebar.update_in(cx, |s, _window, cx| {
3697            let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0);
3698            s.expanded_groups.insert(path_list.clone(), current + 1);
3699            s.update_entries(cx);
3700        });
3701        cx.run_until_parked();
3702
3703        // All 17 threads shown with Collapse button
3704        let entries = visible_entries_as_strings(&sidebar, cx);
3705        assert_eq!(entries.len(), 19); // header + 17 threads + Collapse
3706        assert!(!entries.iter().any(|e| e.contains("View More")));
3707        assert!(entries.iter().any(|e| e.contains("Collapse")));
3708
3709        // Click collapse - should go back to showing 5 threads
3710        sidebar.update_in(cx, |s, _window, cx| {
3711            s.expanded_groups.remove(&path_list);
3712            s.update_entries(cx);
3713        });
3714        cx.run_until_parked();
3715
3716        // Back to initial state: 5 threads + View More
3717        let entries = visible_entries_as_strings(&sidebar, cx);
3718        assert_eq!(entries.len(), 7); // header + 5 threads + View More
3719        assert!(entries.iter().any(|e| e.contains("View More")));
3720    }
3721
3722    #[gpui::test]
3723    async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
3724        let project = init_test_project("/my-project", cx).await;
3725        let (multi_workspace, cx) =
3726            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3727        let sidebar = setup_sidebar(&multi_workspace, cx);
3728
3729        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3730        save_n_test_threads(1, &path_list, cx).await;
3731
3732        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3733        cx.run_until_parked();
3734
3735        assert_eq!(
3736            visible_entries_as_strings(&sidebar, cx),
3737            vec!["v [my-project]", "  Thread 1"]
3738        );
3739
3740        // Collapse
3741        sidebar.update_in(cx, |s, window, cx| {
3742            s.toggle_collapse(&path_list, window, cx);
3743        });
3744        cx.run_until_parked();
3745
3746        assert_eq!(
3747            visible_entries_as_strings(&sidebar, cx),
3748            vec!["> [my-project]"]
3749        );
3750
3751        // Expand
3752        sidebar.update_in(cx, |s, window, cx| {
3753            s.toggle_collapse(&path_list, window, cx);
3754        });
3755        cx.run_until_parked();
3756
3757        assert_eq!(
3758            visible_entries_as_strings(&sidebar, cx),
3759            vec!["v [my-project]", "  Thread 1"]
3760        );
3761    }
3762
3763    #[gpui::test]
3764    async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
3765        let project = init_test_project("/my-project", cx).await;
3766        let (multi_workspace, cx) =
3767            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3768        let sidebar = setup_sidebar(&multi_workspace, cx);
3769
3770        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3771        let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]);
3772        let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]);
3773
3774        sidebar.update_in(cx, |s, _window, _cx| {
3775            s.collapsed_groups.insert(collapsed_path.clone());
3776            s.contents
3777                .notified_threads
3778                .insert(acp::SessionId::new(Arc::from("t-5")));
3779            s.contents.entries = vec![
3780                // Expanded project header
3781                ListEntry::ProjectHeader {
3782                    path_list: expanded_path.clone(),
3783                    label: "expanded-project".into(),
3784                    workspace: workspace.clone(),
3785                    highlight_positions: Vec::new(),
3786                    has_running_threads: false,
3787                    waiting_thread_count: 0,
3788                    is_active: true,
3789                },
3790                ListEntry::Thread(ThreadEntry {
3791                    agent: Agent::NativeAgent,
3792                    session_info: acp_thread::AgentSessionInfo {
3793                        session_id: acp::SessionId::new(Arc::from("t-1")),
3794                        work_dirs: None,
3795                        title: Some("Completed thread".into()),
3796                        updated_at: Some(Utc::now()),
3797                        created_at: Some(Utc::now()),
3798                        meta: None,
3799                    },
3800                    icon: IconName::ZedAgent,
3801                    icon_from_external_svg: None,
3802                    status: AgentThreadStatus::Completed,
3803                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
3804                    is_live: false,
3805                    is_background: false,
3806                    is_title_generating: false,
3807                    highlight_positions: Vec::new(),
3808                    worktree_name: None,
3809                    worktree_full_path: None,
3810                    worktree_highlight_positions: Vec::new(),
3811                    diff_stats: DiffStats::default(),
3812                }),
3813                // Active thread with Running status
3814                ListEntry::Thread(ThreadEntry {
3815                    agent: Agent::NativeAgent,
3816                    session_info: acp_thread::AgentSessionInfo {
3817                        session_id: acp::SessionId::new(Arc::from("t-2")),
3818                        work_dirs: None,
3819                        title: Some("Running thread".into()),
3820                        updated_at: Some(Utc::now()),
3821                        created_at: Some(Utc::now()),
3822                        meta: None,
3823                    },
3824                    icon: IconName::ZedAgent,
3825                    icon_from_external_svg: None,
3826                    status: AgentThreadStatus::Running,
3827                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
3828                    is_live: true,
3829                    is_background: false,
3830                    is_title_generating: false,
3831                    highlight_positions: Vec::new(),
3832                    worktree_name: None,
3833                    worktree_full_path: None,
3834                    worktree_highlight_positions: Vec::new(),
3835                    diff_stats: DiffStats::default(),
3836                }),
3837                // Active thread with Error status
3838                ListEntry::Thread(ThreadEntry {
3839                    agent: Agent::NativeAgent,
3840                    session_info: acp_thread::AgentSessionInfo {
3841                        session_id: acp::SessionId::new(Arc::from("t-3")),
3842                        work_dirs: None,
3843                        title: Some("Error thread".into()),
3844                        updated_at: Some(Utc::now()),
3845                        created_at: Some(Utc::now()),
3846                        meta: None,
3847                    },
3848                    icon: IconName::ZedAgent,
3849                    icon_from_external_svg: None,
3850                    status: AgentThreadStatus::Error,
3851                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
3852                    is_live: true,
3853                    is_background: false,
3854                    is_title_generating: false,
3855                    highlight_positions: Vec::new(),
3856                    worktree_name: None,
3857                    worktree_full_path: None,
3858                    worktree_highlight_positions: Vec::new(),
3859                    diff_stats: DiffStats::default(),
3860                }),
3861                // Thread with WaitingForConfirmation status, not active
3862                ListEntry::Thread(ThreadEntry {
3863                    agent: Agent::NativeAgent,
3864                    session_info: acp_thread::AgentSessionInfo {
3865                        session_id: acp::SessionId::new(Arc::from("t-4")),
3866                        work_dirs: None,
3867                        title: Some("Waiting thread".into()),
3868                        updated_at: Some(Utc::now()),
3869                        created_at: Some(Utc::now()),
3870                        meta: None,
3871                    },
3872                    icon: IconName::ZedAgent,
3873                    icon_from_external_svg: None,
3874                    status: AgentThreadStatus::WaitingForConfirmation,
3875                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
3876                    is_live: false,
3877                    is_background: false,
3878                    is_title_generating: false,
3879                    highlight_positions: Vec::new(),
3880                    worktree_name: None,
3881                    worktree_full_path: None,
3882                    worktree_highlight_positions: Vec::new(),
3883                    diff_stats: DiffStats::default(),
3884                }),
3885                // Background thread that completed (should show notification)
3886                ListEntry::Thread(ThreadEntry {
3887                    agent: Agent::NativeAgent,
3888                    session_info: acp_thread::AgentSessionInfo {
3889                        session_id: acp::SessionId::new(Arc::from("t-5")),
3890                        work_dirs: None,
3891                        title: Some("Notified thread".into()),
3892                        updated_at: Some(Utc::now()),
3893                        created_at: Some(Utc::now()),
3894                        meta: None,
3895                    },
3896                    icon: IconName::ZedAgent,
3897                    icon_from_external_svg: None,
3898                    status: AgentThreadStatus::Completed,
3899                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
3900                    is_live: true,
3901                    is_background: true,
3902                    is_title_generating: false,
3903                    highlight_positions: Vec::new(),
3904                    worktree_name: None,
3905                    worktree_full_path: None,
3906                    worktree_highlight_positions: Vec::new(),
3907                    diff_stats: DiffStats::default(),
3908                }),
3909                // View More entry
3910                ListEntry::ViewMore {
3911                    path_list: expanded_path.clone(),
3912                    is_fully_expanded: false,
3913                },
3914                // Collapsed project header
3915                ListEntry::ProjectHeader {
3916                    path_list: collapsed_path.clone(),
3917                    label: "collapsed-project".into(),
3918                    workspace: workspace.clone(),
3919                    highlight_positions: Vec::new(),
3920                    has_running_threads: false,
3921                    waiting_thread_count: 0,
3922                    is_active: false,
3923                },
3924            ];
3925
3926            // Select the Running thread (index 2)
3927            s.selection = Some(2);
3928        });
3929
3930        assert_eq!(
3931            visible_entries_as_strings(&sidebar, cx),
3932            vec![
3933                "v [expanded-project]",
3934                "  Completed thread",
3935                "  Running thread * (running)  <== selected",
3936                "  Error thread * (error)",
3937                "  Waiting thread (waiting)",
3938                "  Notified thread * (!)",
3939                "  + View More",
3940                "> [collapsed-project]",
3941            ]
3942        );
3943
3944        // Move selection to the collapsed header
3945        sidebar.update_in(cx, |s, _window, _cx| {
3946            s.selection = Some(7);
3947        });
3948
3949        assert_eq!(
3950            visible_entries_as_strings(&sidebar, cx).last().cloned(),
3951            Some("> [collapsed-project]  <== selected".to_string()),
3952        );
3953
3954        // Clear selection
3955        sidebar.update_in(cx, |s, _window, _cx| {
3956            s.selection = None;
3957        });
3958
3959        // No entry should have the selected marker
3960        let entries = visible_entries_as_strings(&sidebar, cx);
3961        for entry in &entries {
3962            assert!(
3963                !entry.contains("<== selected"),
3964                "unexpected selection marker in: {}",
3965                entry
3966            );
3967        }
3968    }
3969
3970    #[gpui::test]
3971    async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
3972        let project = init_test_project("/my-project", cx).await;
3973        let (multi_workspace, cx) =
3974            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3975        let sidebar = setup_sidebar(&multi_workspace, cx);
3976
3977        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3978        save_n_test_threads(3, &path_list, cx).await;
3979
3980        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3981        cx.run_until_parked();
3982
3983        // Entries: [header, thread3, thread2, thread1]
3984        // Focusing the sidebar does not set a selection; select_next/select_previous
3985        // handle None gracefully by starting from the first or last entry.
3986        open_and_focus_sidebar(&sidebar, cx);
3987        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
3988
3989        // First SelectNext from None starts at index 0
3990        cx.dispatch_action(SelectNext);
3991        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
3992
3993        // Move down through remaining entries
3994        cx.dispatch_action(SelectNext);
3995        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
3996
3997        cx.dispatch_action(SelectNext);
3998        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
3999
4000        cx.dispatch_action(SelectNext);
4001        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
4002
4003        // At the end, wraps back to first entry
4004        cx.dispatch_action(SelectNext);
4005        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
4006
4007        // Navigate back to the end
4008        cx.dispatch_action(SelectNext);
4009        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
4010        cx.dispatch_action(SelectNext);
4011        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
4012        cx.dispatch_action(SelectNext);
4013        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
4014
4015        // Move back up
4016        cx.dispatch_action(SelectPrevious);
4017        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
4018
4019        cx.dispatch_action(SelectPrevious);
4020        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
4021
4022        cx.dispatch_action(SelectPrevious);
4023        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
4024
4025        // At the top, selection clears (focus returns to editor)
4026        cx.dispatch_action(SelectPrevious);
4027        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
4028    }
4029
4030    #[gpui::test]
4031    async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
4032        let project = init_test_project("/my-project", cx).await;
4033        let (multi_workspace, cx) =
4034            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4035        let sidebar = setup_sidebar(&multi_workspace, cx);
4036
4037        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4038        save_n_test_threads(3, &path_list, cx).await;
4039        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4040        cx.run_until_parked();
4041
4042        open_and_focus_sidebar(&sidebar, cx);
4043
4044        // SelectLast jumps to the end
4045        cx.dispatch_action(SelectLast);
4046        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
4047
4048        // SelectFirst jumps to the beginning
4049        cx.dispatch_action(SelectFirst);
4050        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
4051    }
4052
4053    #[gpui::test]
4054    async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) {
4055        let project = init_test_project("/my-project", cx).await;
4056        let (multi_workspace, cx) =
4057            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4058        let sidebar = setup_sidebar(&multi_workspace, cx);
4059
4060        // Initially no selection
4061        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
4062
4063        // Open the sidebar so it's rendered, then focus it to trigger focus_in.
4064        // focus_in no longer sets a default selection.
4065        open_and_focus_sidebar(&sidebar, cx);
4066        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
4067
4068        // Manually set a selection, blur, then refocus — selection should be preserved
4069        sidebar.update_in(cx, |sidebar, _window, _cx| {
4070            sidebar.selection = Some(0);
4071        });
4072
4073        cx.update(|window, _cx| {
4074            window.blur();
4075        });
4076        cx.run_until_parked();
4077
4078        sidebar.update_in(cx, |_, window, cx| {
4079            cx.focus_self(window);
4080        });
4081        cx.run_until_parked();
4082        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
4083    }
4084
4085    #[gpui::test]
4086    async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) {
4087        let project = init_test_project("/my-project", cx).await;
4088        let (multi_workspace, cx) =
4089            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4090        let sidebar = setup_sidebar(&multi_workspace, cx);
4091
4092        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4093        save_n_test_threads(1, &path_list, cx).await;
4094        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4095        cx.run_until_parked();
4096
4097        assert_eq!(
4098            visible_entries_as_strings(&sidebar, cx),
4099            vec!["v [my-project]", "  Thread 1"]
4100        );
4101
4102        // Focus the sidebar and select the header (index 0)
4103        open_and_focus_sidebar(&sidebar, cx);
4104        sidebar.update_in(cx, |sidebar, _window, _cx| {
4105            sidebar.selection = Some(0);
4106        });
4107
4108        // Confirm on project header collapses the group
4109        cx.dispatch_action(Confirm);
4110        cx.run_until_parked();
4111
4112        assert_eq!(
4113            visible_entries_as_strings(&sidebar, cx),
4114            vec!["> [my-project]  <== selected"]
4115        );
4116
4117        // Confirm again expands the group
4118        cx.dispatch_action(Confirm);
4119        cx.run_until_parked();
4120
4121        assert_eq!(
4122            visible_entries_as_strings(&sidebar, cx),
4123            vec!["v [my-project]  <== selected", "  Thread 1",]
4124        );
4125    }
4126
4127    #[gpui::test]
4128    async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) {
4129        let project = init_test_project("/my-project", cx).await;
4130        let (multi_workspace, cx) =
4131            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4132        let sidebar = setup_sidebar(&multi_workspace, cx);
4133
4134        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4135        save_n_test_threads(8, &path_list, cx).await;
4136        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4137        cx.run_until_parked();
4138
4139        // Should show header + 5 threads + "View More"
4140        let entries = visible_entries_as_strings(&sidebar, cx);
4141        assert_eq!(entries.len(), 7);
4142        assert!(entries.iter().any(|e| e.contains("View More")));
4143
4144        // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6)
4145        open_and_focus_sidebar(&sidebar, cx);
4146        for _ in 0..7 {
4147            cx.dispatch_action(SelectNext);
4148        }
4149        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6));
4150
4151        // Confirm on "View More" to expand
4152        cx.dispatch_action(Confirm);
4153        cx.run_until_parked();
4154
4155        // All 8 threads should now be visible with a "Collapse" button
4156        let entries = visible_entries_as_strings(&sidebar, cx);
4157        assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button
4158        assert!(!entries.iter().any(|e| e.contains("View More")));
4159        assert!(entries.iter().any(|e| e.contains("Collapse")));
4160    }
4161
4162    #[gpui::test]
4163    async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
4164        let project = init_test_project("/my-project", cx).await;
4165        let (multi_workspace, cx) =
4166            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4167        let sidebar = setup_sidebar(&multi_workspace, cx);
4168
4169        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4170        save_n_test_threads(1, &path_list, cx).await;
4171        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4172        cx.run_until_parked();
4173
4174        assert_eq!(
4175            visible_entries_as_strings(&sidebar, cx),
4176            vec!["v [my-project]", "  Thread 1"]
4177        );
4178
4179        // Focus sidebar and manually select the header (index 0). Press left to collapse.
4180        open_and_focus_sidebar(&sidebar, cx);
4181        sidebar.update_in(cx, |sidebar, _window, _cx| {
4182            sidebar.selection = Some(0);
4183        });
4184
4185        cx.dispatch_action(SelectParent);
4186        cx.run_until_parked();
4187
4188        assert_eq!(
4189            visible_entries_as_strings(&sidebar, cx),
4190            vec!["> [my-project]  <== selected"]
4191        );
4192
4193        // Press right to expand
4194        cx.dispatch_action(SelectChild);
4195        cx.run_until_parked();
4196
4197        assert_eq!(
4198            visible_entries_as_strings(&sidebar, cx),
4199            vec!["v [my-project]  <== selected", "  Thread 1",]
4200        );
4201
4202        // Press right again on already-expanded header moves selection down
4203        cx.dispatch_action(SelectChild);
4204        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
4205    }
4206
4207    #[gpui::test]
4208    async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) {
4209        let project = init_test_project("/my-project", cx).await;
4210        let (multi_workspace, cx) =
4211            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4212        let sidebar = setup_sidebar(&multi_workspace, cx);
4213
4214        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4215        save_n_test_threads(1, &path_list, cx).await;
4216        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4217        cx.run_until_parked();
4218
4219        // Focus sidebar (selection starts at None), then navigate down to the thread (child)
4220        open_and_focus_sidebar(&sidebar, cx);
4221        cx.dispatch_action(SelectNext);
4222        cx.dispatch_action(SelectNext);
4223        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
4224
4225        assert_eq!(
4226            visible_entries_as_strings(&sidebar, cx),
4227            vec!["v [my-project]", "  Thread 1  <== selected",]
4228        );
4229
4230        // Pressing left on a child collapses the parent group and selects it
4231        cx.dispatch_action(SelectParent);
4232        cx.run_until_parked();
4233
4234        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
4235        assert_eq!(
4236            visible_entries_as_strings(&sidebar, cx),
4237            vec!["> [my-project]  <== selected"]
4238        );
4239    }
4240
4241    #[gpui::test]
4242    async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
4243        let project = init_test_project("/empty-project", cx).await;
4244        let (multi_workspace, cx) =
4245            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4246        let sidebar = setup_sidebar(&multi_workspace, cx);
4247
4248        // An empty project has the header and a new thread button.
4249        assert_eq!(
4250            visible_entries_as_strings(&sidebar, cx),
4251            vec!["v [empty-project]", "  [+ New Thread]"]
4252        );
4253
4254        // Focus sidebar — focus_in does not set a selection
4255        open_and_focus_sidebar(&sidebar, cx);
4256        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
4257
4258        // First SelectNext from None starts at index 0 (header)
4259        cx.dispatch_action(SelectNext);
4260        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
4261
4262        // SelectNext moves to the new thread button
4263        cx.dispatch_action(SelectNext);
4264        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
4265
4266        // At the end, wraps back to first entry
4267        cx.dispatch_action(SelectNext);
4268        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
4269
4270        // SelectPrevious from first entry clears selection (returns to editor)
4271        cx.dispatch_action(SelectPrevious);
4272        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
4273    }
4274
4275    #[gpui::test]
4276    async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
4277        let project = init_test_project("/my-project", cx).await;
4278        let (multi_workspace, cx) =
4279            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4280        let sidebar = setup_sidebar(&multi_workspace, cx);
4281
4282        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4283        save_n_test_threads(1, &path_list, cx).await;
4284        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4285        cx.run_until_parked();
4286
4287        // Focus sidebar (selection starts at None), navigate down to the thread (index 1)
4288        open_and_focus_sidebar(&sidebar, cx);
4289        cx.dispatch_action(SelectNext);
4290        cx.dispatch_action(SelectNext);
4291        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
4292
4293        // Collapse the group, which removes the thread from the list
4294        cx.dispatch_action(SelectParent);
4295        cx.run_until_parked();
4296
4297        // Selection should be clamped to the last valid index (0 = header)
4298        let selection = sidebar.read_with(cx, |s, _| s.selection);
4299        let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len());
4300        assert!(
4301            selection.unwrap_or(0) < entry_count,
4302            "selection {} should be within bounds (entries: {})",
4303            selection.unwrap_or(0),
4304            entry_count,
4305        );
4306    }
4307
4308    async fn init_test_project_with_agent_panel(
4309        worktree_path: &str,
4310        cx: &mut TestAppContext,
4311    ) -> Entity<project::Project> {
4312        agent_ui::test_support::init_test(cx);
4313        cx.update(|cx| {
4314            cx.update_flags(false, vec!["agent-v2".into()]);
4315            ThreadStore::init_global(cx);
4316            SidebarThreadMetadataStore::init_global(cx);
4317            language_model::LanguageModelRegistry::test(cx);
4318            prompt_store::init(cx);
4319        });
4320
4321        let fs = FakeFs::new(cx.executor());
4322        fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
4323            .await;
4324        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4325        project::Project::test(fs, [worktree_path.as_ref()], cx).await
4326    }
4327
4328    fn add_agent_panel(
4329        workspace: &Entity<Workspace>,
4330        project: &Entity<project::Project>,
4331        cx: &mut gpui::VisualTestContext,
4332    ) -> Entity<AgentPanel> {
4333        workspace.update_in(cx, |workspace, window, cx| {
4334            let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
4335            let panel = cx.new(|cx| AgentPanel::test_new(workspace, text_thread_store, window, cx));
4336            workspace.add_panel(panel.clone(), window, cx);
4337            panel
4338        })
4339    }
4340
4341    fn setup_sidebar_with_agent_panel(
4342        multi_workspace: &Entity<MultiWorkspace>,
4343        project: &Entity<project::Project>,
4344        cx: &mut gpui::VisualTestContext,
4345    ) -> (Entity<Sidebar>, Entity<AgentPanel>) {
4346        let sidebar = setup_sidebar(multi_workspace, cx);
4347        let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
4348        let panel = add_agent_panel(&workspace, project, cx);
4349        (sidebar, panel)
4350    }
4351
4352    #[gpui::test]
4353    async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
4354        let project = init_test_project_with_agent_panel("/my-project", cx).await;
4355        let (multi_workspace, cx) =
4356            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4357        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
4358
4359        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4360
4361        // Open thread A and keep it generating.
4362        let connection = StubAgentConnection::new();
4363        open_thread_with_connection(&panel, connection.clone(), cx);
4364        send_message(&panel, cx);
4365
4366        let session_id_a = active_session_id(&panel, cx);
4367        save_test_thread_metadata(&session_id_a, path_list.clone(), cx).await;
4368
4369        cx.update(|_, cx| {
4370            connection.send_update(
4371                session_id_a.clone(),
4372                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
4373                cx,
4374            );
4375        });
4376        cx.run_until_parked();
4377
4378        // Open thread B (idle, default response) — thread A goes to background.
4379        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4380            acp::ContentChunk::new("Done".into()),
4381        )]);
4382        open_thread_with_connection(&panel, connection, cx);
4383        send_message(&panel, cx);
4384
4385        let session_id_b = active_session_id(&panel, cx);
4386        save_test_thread_metadata(&session_id_b, path_list.clone(), cx).await;
4387
4388        cx.run_until_parked();
4389
4390        let mut entries = visible_entries_as_strings(&sidebar, cx);
4391        entries[1..].sort();
4392        assert_eq!(
4393            entries,
4394            vec!["v [my-project]", "  Hello *", "  Hello * (running)",]
4395        );
4396    }
4397
4398    #[gpui::test]
4399    async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
4400        let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
4401        let (multi_workspace, cx) = cx
4402            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4403        let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx);
4404
4405        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
4406
4407        // Open thread on workspace A and keep it generating.
4408        let connection_a = StubAgentConnection::new();
4409        open_thread_with_connection(&panel_a, connection_a.clone(), cx);
4410        send_message(&panel_a, cx);
4411
4412        let session_id_a = active_session_id(&panel_a, cx);
4413        save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await;
4414
4415        cx.update(|_, cx| {
4416            connection_a.send_update(
4417                session_id_a.clone(),
4418                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
4419                cx,
4420            );
4421        });
4422        cx.run_until_parked();
4423
4424        // Add a second workspace and activate it (making workspace A the background).
4425        let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
4426        let project_b = project::Project::test(fs, [], cx).await;
4427        multi_workspace.update_in(cx, |mw, window, cx| {
4428            mw.test_add_workspace(project_b, window, cx);
4429        });
4430        cx.run_until_parked();
4431
4432        // Thread A is still running; no notification yet.
4433        assert_eq!(
4434            visible_entries_as_strings(&sidebar, cx),
4435            vec!["v [project-a]", "  Hello * (running)",]
4436        );
4437
4438        // Complete thread A's turn (transition Running → Completed).
4439        connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn);
4440        cx.run_until_parked();
4441
4442        // The completed background thread shows a notification indicator.
4443        assert_eq!(
4444            visible_entries_as_strings(&sidebar, cx),
4445            vec!["v [project-a]", "  Hello * (!)",]
4446        );
4447    }
4448
4449    fn type_in_search(sidebar: &Entity<Sidebar>, query: &str, cx: &mut gpui::VisualTestContext) {
4450        sidebar.update_in(cx, |sidebar, window, cx| {
4451            window.focus(&sidebar.filter_editor.focus_handle(cx), cx);
4452            sidebar.filter_editor.update(cx, |editor, cx| {
4453                editor.set_text(query, window, cx);
4454            });
4455        });
4456        cx.run_until_parked();
4457    }
4458
4459    #[gpui::test]
4460    async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) {
4461        let project = init_test_project("/my-project", cx).await;
4462        let (multi_workspace, cx) =
4463            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4464        let sidebar = setup_sidebar(&multi_workspace, cx);
4465
4466        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4467
4468        for (id, title, hour) in [
4469            ("t-1", "Fix crash in project panel", 3),
4470            ("t-2", "Add inline diff view", 2),
4471            ("t-3", "Refactor settings module", 1),
4472        ] {
4473            save_thread_metadata(
4474                acp::SessionId::new(Arc::from(id)),
4475                title.into(),
4476                chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
4477                path_list.clone(),
4478                cx,
4479            )
4480            .await;
4481        }
4482        cx.run_until_parked();
4483
4484        assert_eq!(
4485            visible_entries_as_strings(&sidebar, cx),
4486            vec![
4487                "v [my-project]",
4488                "  Fix crash in project panel",
4489                "  Add inline diff view",
4490                "  Refactor settings module",
4491            ]
4492        );
4493
4494        // User types "diff" in the search box — only the matching thread remains,
4495        // with its workspace header preserved for context.
4496        type_in_search(&sidebar, "diff", cx);
4497        assert_eq!(
4498            visible_entries_as_strings(&sidebar, cx),
4499            vec!["v [my-project]", "  Add inline diff view  <== selected",]
4500        );
4501
4502        // User changes query to something with no matches — list is empty.
4503        type_in_search(&sidebar, "nonexistent", cx);
4504        assert_eq!(
4505            visible_entries_as_strings(&sidebar, cx),
4506            Vec::<String>::new()
4507        );
4508    }
4509
4510    #[gpui::test]
4511    async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
4512        // Scenario: A user remembers a thread title but not the exact casing.
4513        // Search should match case-insensitively so they can still find it.
4514        let project = init_test_project("/my-project", cx).await;
4515        let (multi_workspace, cx) =
4516            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4517        let sidebar = setup_sidebar(&multi_workspace, cx);
4518
4519        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4520
4521        save_thread_metadata(
4522            acp::SessionId::new(Arc::from("thread-1")),
4523            "Fix Crash In Project Panel".into(),
4524            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4525            path_list.clone(),
4526            cx,
4527        )
4528        .await;
4529        cx.run_until_parked();
4530
4531        // Lowercase query matches mixed-case title.
4532        type_in_search(&sidebar, "fix crash", cx);
4533        assert_eq!(
4534            visible_entries_as_strings(&sidebar, cx),
4535            vec![
4536                "v [my-project]",
4537                "  Fix Crash In Project Panel  <== selected",
4538            ]
4539        );
4540
4541        // Uppercase query also matches the same title.
4542        type_in_search(&sidebar, "FIX CRASH", cx);
4543        assert_eq!(
4544            visible_entries_as_strings(&sidebar, cx),
4545            vec![
4546                "v [my-project]",
4547                "  Fix Crash In Project Panel  <== selected",
4548            ]
4549        );
4550    }
4551
4552    #[gpui::test]
4553    async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) {
4554        // Scenario: A user searches, finds what they need, then presses Escape
4555        // to dismiss the filter and see the full list again.
4556        let project = init_test_project("/my-project", cx).await;
4557        let (multi_workspace, cx) =
4558            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4559        let sidebar = setup_sidebar(&multi_workspace, cx);
4560
4561        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4562
4563        for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] {
4564            save_thread_metadata(
4565                acp::SessionId::new(Arc::from(id)),
4566                title.into(),
4567                chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
4568                path_list.clone(),
4569                cx,
4570            )
4571            .await;
4572        }
4573        cx.run_until_parked();
4574
4575        // Confirm the full list is showing.
4576        assert_eq!(
4577            visible_entries_as_strings(&sidebar, cx),
4578            vec!["v [my-project]", "  Alpha thread", "  Beta thread",]
4579        );
4580
4581        // User types a search query to filter down.
4582        open_and_focus_sidebar(&sidebar, cx);
4583        type_in_search(&sidebar, "alpha", cx);
4584        assert_eq!(
4585            visible_entries_as_strings(&sidebar, cx),
4586            vec!["v [my-project]", "  Alpha thread  <== selected",]
4587        );
4588
4589        // User presses Escape — filter clears, full list is restored.
4590        // The selection index (1) now points at the first thread entry.
4591        cx.dispatch_action(Cancel);
4592        cx.run_until_parked();
4593        assert_eq!(
4594            visible_entries_as_strings(&sidebar, cx),
4595            vec![
4596                "v [my-project]",
4597                "  Alpha thread  <== selected",
4598                "  Beta thread",
4599            ]
4600        );
4601    }
4602
4603    #[gpui::test]
4604    async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
4605        let project_a = init_test_project("/project-a", cx).await;
4606        let (multi_workspace, cx) =
4607            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4608        let sidebar = setup_sidebar(&multi_workspace, cx);
4609
4610        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
4611
4612        for (id, title, hour) in [
4613            ("a1", "Fix bug in sidebar", 2),
4614            ("a2", "Add tests for editor", 1),
4615        ] {
4616            save_thread_metadata(
4617                acp::SessionId::new(Arc::from(id)),
4618                title.into(),
4619                chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
4620                path_list_a.clone(),
4621                cx,
4622            )
4623            .await;
4624        }
4625
4626        // Add a second workspace.
4627        multi_workspace.update_in(cx, |mw, window, cx| {
4628            mw.create_test_workspace(window, cx).detach();
4629        });
4630        cx.run_until_parked();
4631
4632        let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
4633
4634        for (id, title, hour) in [
4635            ("b1", "Refactor sidebar layout", 3),
4636            ("b2", "Fix typo in README", 1),
4637        ] {
4638            save_thread_metadata(
4639                acp::SessionId::new(Arc::from(id)),
4640                title.into(),
4641                chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
4642                path_list_b.clone(),
4643                cx,
4644            )
4645            .await;
4646        }
4647        cx.run_until_parked();
4648
4649        assert_eq!(
4650            visible_entries_as_strings(&sidebar, cx),
4651            vec![
4652                "v [project-a]",
4653                "  Fix bug in sidebar",
4654                "  Add tests for editor",
4655            ]
4656        );
4657
4658        // "sidebar" matches a thread in each workspace — both headers stay visible.
4659        type_in_search(&sidebar, "sidebar", cx);
4660        assert_eq!(
4661            visible_entries_as_strings(&sidebar, cx),
4662            vec!["v [project-a]", "  Fix bug in sidebar  <== selected",]
4663        );
4664
4665        // "typo" only matches in the second workspace — the first header disappears.
4666        type_in_search(&sidebar, "typo", cx);
4667        assert_eq!(
4668            visible_entries_as_strings(&sidebar, cx),
4669            Vec::<String>::new()
4670        );
4671
4672        // "project-a" matches the first workspace name — the header appears
4673        // with all child threads included.
4674        type_in_search(&sidebar, "project-a", cx);
4675        assert_eq!(
4676            visible_entries_as_strings(&sidebar, cx),
4677            vec![
4678                "v [project-a]",
4679                "  Fix bug in sidebar  <== selected",
4680                "  Add tests for editor",
4681            ]
4682        );
4683    }
4684
4685    #[gpui::test]
4686    async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
4687        let project_a = init_test_project("/alpha-project", cx).await;
4688        let (multi_workspace, cx) =
4689            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4690        let sidebar = setup_sidebar(&multi_workspace, cx);
4691
4692        let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]);
4693
4694        for (id, title, hour) in [
4695            ("a1", "Fix bug in sidebar", 2),
4696            ("a2", "Add tests for editor", 1),
4697        ] {
4698            save_thread_metadata(
4699                acp::SessionId::new(Arc::from(id)),
4700                title.into(),
4701                chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
4702                path_list_a.clone(),
4703                cx,
4704            )
4705            .await;
4706        }
4707
4708        // Add a second workspace.
4709        multi_workspace.update_in(cx, |mw, window, cx| {
4710            mw.create_test_workspace(window, cx).detach();
4711        });
4712        cx.run_until_parked();
4713
4714        let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
4715
4716        for (id, title, hour) in [
4717            ("b1", "Refactor sidebar layout", 3),
4718            ("b2", "Fix typo in README", 1),
4719        ] {
4720            save_thread_metadata(
4721                acp::SessionId::new(Arc::from(id)),
4722                title.into(),
4723                chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
4724                path_list_b.clone(),
4725                cx,
4726            )
4727            .await;
4728        }
4729        cx.run_until_parked();
4730
4731        // "alpha" matches the workspace name "alpha-project" but no thread titles.
4732        // The workspace header should appear with all child threads included.
4733        type_in_search(&sidebar, "alpha", cx);
4734        assert_eq!(
4735            visible_entries_as_strings(&sidebar, cx),
4736            vec![
4737                "v [alpha-project]",
4738                "  Fix bug in sidebar  <== selected",
4739                "  Add tests for editor",
4740            ]
4741        );
4742
4743        // "sidebar" matches thread titles in both workspaces but not workspace names.
4744        // Both headers appear with their matching threads.
4745        type_in_search(&sidebar, "sidebar", cx);
4746        assert_eq!(
4747            visible_entries_as_strings(&sidebar, cx),
4748            vec!["v [alpha-project]", "  Fix bug in sidebar  <== selected",]
4749        );
4750
4751        // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r
4752        // doesn't match) — but does not match either workspace name or any thread.
4753        // Actually let's test something simpler: a query that matches both a workspace
4754        // name AND some threads in that workspace. Matching threads should still appear.
4755        type_in_search(&sidebar, "fix", cx);
4756        assert_eq!(
4757            visible_entries_as_strings(&sidebar, cx),
4758            vec!["v [alpha-project]", "  Fix bug in sidebar  <== selected",]
4759        );
4760
4761        // A query that matches a workspace name AND a thread in that same workspace.
4762        // Both the header (highlighted) and all child threads should appear.
4763        type_in_search(&sidebar, "alpha", cx);
4764        assert_eq!(
4765            visible_entries_as_strings(&sidebar, cx),
4766            vec![
4767                "v [alpha-project]",
4768                "  Fix bug in sidebar  <== selected",
4769                "  Add tests for editor",
4770            ]
4771        );
4772
4773        // Now search for something that matches only a workspace name when there
4774        // are also threads with matching titles — the non-matching workspace's
4775        // threads should still appear if their titles match.
4776        type_in_search(&sidebar, "alp", cx);
4777        assert_eq!(
4778            visible_entries_as_strings(&sidebar, cx),
4779            vec![
4780                "v [alpha-project]",
4781                "  Fix bug in sidebar  <== selected",
4782                "  Add tests for editor",
4783            ]
4784        );
4785    }
4786
4787    #[gpui::test]
4788    async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) {
4789        let project = init_test_project("/my-project", cx).await;
4790        let (multi_workspace, cx) =
4791            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4792        let sidebar = setup_sidebar(&multi_workspace, cx);
4793
4794        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4795
4796        // Create 8 threads. The oldest one has a unique name and will be
4797        // behind View More (only 5 shown by default).
4798        for i in 0..8u32 {
4799            let title = if i == 0 {
4800                "Hidden gem thread".to_string()
4801            } else {
4802                format!("Thread {}", i + 1)
4803            };
4804            save_thread_metadata(
4805                acp::SessionId::new(Arc::from(format!("thread-{}", i))),
4806                title.into(),
4807                chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
4808                path_list.clone(),
4809                cx,
4810            )
4811            .await;
4812        }
4813        cx.run_until_parked();
4814
4815        // Confirm the thread is not visible and View More is shown.
4816        let entries = visible_entries_as_strings(&sidebar, cx);
4817        assert!(
4818            entries.iter().any(|e| e.contains("View More")),
4819            "should have View More button"
4820        );
4821        assert!(
4822            !entries.iter().any(|e| e.contains("Hidden gem")),
4823            "Hidden gem should be behind View More"
4824        );
4825
4826        // User searches for the hidden thread — it appears, and View More is gone.
4827        type_in_search(&sidebar, "hidden gem", cx);
4828        let filtered = visible_entries_as_strings(&sidebar, cx);
4829        assert_eq!(
4830            filtered,
4831            vec!["v [my-project]", "  Hidden gem thread  <== selected",]
4832        );
4833        assert!(
4834            !filtered.iter().any(|e| e.contains("View More")),
4835            "View More should not appear when filtering"
4836        );
4837    }
4838
4839    #[gpui::test]
4840    async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
4841        let project = init_test_project("/my-project", cx).await;
4842        let (multi_workspace, cx) =
4843            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4844        let sidebar = setup_sidebar(&multi_workspace, cx);
4845
4846        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4847
4848        save_thread_metadata(
4849            acp::SessionId::new(Arc::from("thread-1")),
4850            "Important thread".into(),
4851            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4852            path_list.clone(),
4853            cx,
4854        )
4855        .await;
4856        cx.run_until_parked();
4857
4858        // User focuses the sidebar and collapses the group using keyboard:
4859        // manually select the header, then press SelectParent to collapse.
4860        open_and_focus_sidebar(&sidebar, cx);
4861        sidebar.update_in(cx, |sidebar, _window, _cx| {
4862            sidebar.selection = Some(0);
4863        });
4864        cx.dispatch_action(SelectParent);
4865        cx.run_until_parked();
4866
4867        assert_eq!(
4868            visible_entries_as_strings(&sidebar, cx),
4869            vec!["> [my-project]  <== selected"]
4870        );
4871
4872        // User types a search — the thread appears even though its group is collapsed.
4873        type_in_search(&sidebar, "important", cx);
4874        assert_eq!(
4875            visible_entries_as_strings(&sidebar, cx),
4876            vec!["> [my-project]", "  Important thread  <== selected",]
4877        );
4878    }
4879
4880    #[gpui::test]
4881    async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
4882        let project = init_test_project("/my-project", cx).await;
4883        let (multi_workspace, cx) =
4884            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4885        let sidebar = setup_sidebar(&multi_workspace, cx);
4886
4887        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4888
4889        for (id, title, hour) in [
4890            ("t-1", "Fix crash in panel", 3),
4891            ("t-2", "Fix lint warnings", 2),
4892            ("t-3", "Add new feature", 1),
4893        ] {
4894            save_thread_metadata(
4895                acp::SessionId::new(Arc::from(id)),
4896                title.into(),
4897                chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
4898                path_list.clone(),
4899                cx,
4900            )
4901            .await;
4902        }
4903        cx.run_until_parked();
4904
4905        open_and_focus_sidebar(&sidebar, cx);
4906
4907        // User types "fix" — two threads match.
4908        type_in_search(&sidebar, "fix", cx);
4909        assert_eq!(
4910            visible_entries_as_strings(&sidebar, cx),
4911            vec![
4912                "v [my-project]",
4913                "  Fix crash in panel  <== selected",
4914                "  Fix lint warnings",
4915            ]
4916        );
4917
4918        // Selection starts on the first matching thread. User presses
4919        // SelectNext to move to the second match.
4920        cx.dispatch_action(SelectNext);
4921        assert_eq!(
4922            visible_entries_as_strings(&sidebar, cx),
4923            vec![
4924                "v [my-project]",
4925                "  Fix crash in panel",
4926                "  Fix lint warnings  <== selected",
4927            ]
4928        );
4929
4930        // User can also jump back with SelectPrevious.
4931        cx.dispatch_action(SelectPrevious);
4932        assert_eq!(
4933            visible_entries_as_strings(&sidebar, cx),
4934            vec![
4935                "v [my-project]",
4936                "  Fix crash in panel  <== selected",
4937                "  Fix lint warnings",
4938            ]
4939        );
4940    }
4941
4942    #[gpui::test]
4943    async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) {
4944        let project = init_test_project("/my-project", cx).await;
4945        let (multi_workspace, cx) =
4946            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
4947        let sidebar = setup_sidebar(&multi_workspace, cx);
4948
4949        multi_workspace.update_in(cx, |mw, window, cx| {
4950            mw.create_test_workspace(window, cx).detach();
4951        });
4952        cx.run_until_parked();
4953
4954        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
4955
4956        save_thread_metadata(
4957            acp::SessionId::new(Arc::from("hist-1")),
4958            "Historical Thread".into(),
4959            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
4960            path_list.clone(),
4961            cx,
4962        )
4963        .await;
4964        cx.run_until_parked();
4965        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4966        cx.run_until_parked();
4967
4968        assert_eq!(
4969            visible_entries_as_strings(&sidebar, cx),
4970            vec!["v [my-project]", "  Historical Thread",]
4971        );
4972
4973        // Switch to workspace 1 so we can verify the confirm switches back.
4974        multi_workspace.update_in(cx, |mw, window, cx| {
4975            mw.activate_index(1, window, cx);
4976        });
4977        cx.run_until_parked();
4978        assert_eq!(
4979            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4980            1
4981        );
4982
4983        // Confirm on the historical (non-live) thread at index 1.
4984        // Before a previous fix, the workspace field was Option<usize> and
4985        // historical threads had None, so activate_thread early-returned
4986        // without switching the workspace.
4987        sidebar.update_in(cx, |sidebar, window, cx| {
4988            sidebar.selection = Some(1);
4989            sidebar.confirm(&Confirm, window, cx);
4990        });
4991        cx.run_until_parked();
4992
4993        assert_eq!(
4994            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4995            0
4996        );
4997    }
4998
4999    #[gpui::test]
5000    async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
5001        let project = init_test_project("/my-project", cx).await;
5002        let (multi_workspace, cx) =
5003            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
5004        let sidebar = setup_sidebar(&multi_workspace, cx);
5005
5006        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
5007
5008        save_thread_metadata(
5009            acp::SessionId::new(Arc::from("t-1")),
5010            "Thread A".into(),
5011            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
5012            path_list.clone(),
5013            cx,
5014        )
5015        .await;
5016
5017        save_thread_metadata(
5018            acp::SessionId::new(Arc::from("t-2")),
5019            "Thread B".into(),
5020            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
5021            path_list.clone(),
5022            cx,
5023        )
5024        .await;
5025
5026        cx.run_until_parked();
5027        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5028        cx.run_until_parked();
5029
5030        assert_eq!(
5031            visible_entries_as_strings(&sidebar, cx),
5032            vec!["v [my-project]", "  Thread A", "  Thread B",]
5033        );
5034
5035        // Keyboard confirm preserves selection.
5036        sidebar.update_in(cx, |sidebar, window, cx| {
5037            sidebar.selection = Some(1);
5038            sidebar.confirm(&Confirm, window, cx);
5039        });
5040        assert_eq!(
5041            sidebar.read_with(cx, |sidebar, _| sidebar.selection),
5042            Some(1)
5043        );
5044
5045        // Click handlers clear selection to None so no highlight lingers
5046        // after a click regardless of focus state. The hover style provides
5047        // visual feedback during mouse interaction instead.
5048        sidebar.update_in(cx, |sidebar, window, cx| {
5049            sidebar.selection = None;
5050            let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
5051            sidebar.toggle_collapse(&path_list, window, cx);
5052        });
5053        assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
5054
5055        // When the user tabs back into the sidebar, focus_in no longer
5056        // restores selection — it stays None.
5057        sidebar.update_in(cx, |sidebar, window, cx| {
5058            sidebar.focus_in(window, cx);
5059        });
5060        assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
5061    }
5062
5063    #[gpui::test]
5064    async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
5065        let project = init_test_project_with_agent_panel("/my-project", cx).await;
5066        let (multi_workspace, cx) =
5067            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5068        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
5069
5070        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
5071
5072        let connection = StubAgentConnection::new();
5073        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5074            acp::ContentChunk::new("Hi there!".into()),
5075        )]);
5076        open_thread_with_connection(&panel, connection, cx);
5077        send_message(&panel, cx);
5078
5079        let session_id = active_session_id(&panel, cx);
5080        save_test_thread_metadata(&session_id, path_list.clone(), cx).await;
5081        cx.run_until_parked();
5082
5083        assert_eq!(
5084            visible_entries_as_strings(&sidebar, cx),
5085            vec!["v [my-project]", "  Hello *"]
5086        );
5087
5088        // Simulate the agent generating a title. The notification chain is:
5089        // AcpThread::set_title emits TitleUpdated →
5090        // ConnectionView::handle_thread_event calls cx.notify() →
5091        // AgentPanel observer fires and emits AgentPanelEvent →
5092        // Sidebar subscription calls update_entries / rebuild_contents.
5093        //
5094        // Before the fix, handle_thread_event did NOT call cx.notify() for
5095        // TitleUpdated, so the AgentPanel observer never fired and the
5096        // sidebar kept showing the old title.
5097        let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap());
5098        thread.update(cx, |thread, cx| {
5099            thread
5100                .set_title("Friendly Greeting with AI".into(), cx)
5101                .detach();
5102        });
5103        cx.run_until_parked();
5104
5105        assert_eq!(
5106            visible_entries_as_strings(&sidebar, cx),
5107            vec!["v [my-project]", "  Friendly Greeting with AI *"]
5108        );
5109    }
5110
5111    #[gpui::test]
5112    async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
5113        let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
5114        let (multi_workspace, cx) = cx
5115            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
5116        let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx);
5117
5118        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
5119
5120        // Save a thread so it appears in the list.
5121        let connection_a = StubAgentConnection::new();
5122        connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5123            acp::ContentChunk::new("Done".into()),
5124        )]);
5125        open_thread_with_connection(&panel_a, connection_a, cx);
5126        send_message(&panel_a, cx);
5127        let session_id_a = active_session_id(&panel_a, cx);
5128        save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await;
5129
5130        // Add a second workspace with its own agent panel.
5131        let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
5132        fs.as_fake()
5133            .insert_tree("/project-b", serde_json::json!({ "src": {} }))
5134            .await;
5135        let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
5136        let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
5137            mw.test_add_workspace(project_b.clone(), window, cx)
5138        });
5139        let panel_b = add_agent_panel(&workspace_b, &project_b, cx);
5140        cx.run_until_parked();
5141
5142        let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone());
5143
5144        // ── 1. Initial state: focused thread derived from active panel ─────
5145        sidebar.read_with(cx, |sidebar, _cx| {
5146            assert_eq!(
5147                sidebar.focused_thread.as_ref(),
5148                Some(&session_id_a),
5149                "The active panel's thread should be focused on startup"
5150            );
5151        });
5152
5153        sidebar.update_in(cx, |sidebar, window, cx| {
5154            sidebar.activate_thread(
5155                Agent::NativeAgent,
5156                acp_thread::AgentSessionInfo {
5157                    session_id: session_id_a.clone(),
5158                    work_dirs: None,
5159                    title: Some("Test".into()),
5160                    updated_at: None,
5161                    created_at: None,
5162                    meta: None,
5163                },
5164                &workspace_a,
5165                window,
5166                cx,
5167            );
5168        });
5169        cx.run_until_parked();
5170
5171        sidebar.read_with(cx, |sidebar, _cx| {
5172            assert_eq!(
5173                sidebar.focused_thread.as_ref(),
5174                Some(&session_id_a),
5175                "After clicking a thread, it should be the focused thread"
5176            );
5177            assert!(
5178                has_thread_entry(sidebar, &session_id_a),
5179                "The clicked thread should be present in the entries"
5180            );
5181        });
5182
5183        workspace_a.read_with(cx, |workspace, cx| {
5184            assert!(
5185                workspace.panel::<AgentPanel>(cx).is_some(),
5186                "Agent panel should exist"
5187            );
5188            let dock = workspace.right_dock().read(cx);
5189            assert!(
5190                dock.is_open(),
5191                "Clicking a thread should open the agent panel dock"
5192            );
5193        });
5194
5195        let connection_b = StubAgentConnection::new();
5196        connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5197            acp::ContentChunk::new("Thread B".into()),
5198        )]);
5199        open_thread_with_connection(&panel_b, connection_b, cx);
5200        send_message(&panel_b, cx);
5201        let session_id_b = active_session_id(&panel_b, cx);
5202        let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
5203        save_test_thread_metadata(&session_id_b, path_list_b.clone(), cx).await;
5204        cx.run_until_parked();
5205
5206        // Workspace A is currently active. Click a thread in workspace B,
5207        // which also triggers a workspace switch.
5208        sidebar.update_in(cx, |sidebar, window, cx| {
5209            sidebar.activate_thread(
5210                Agent::NativeAgent,
5211                acp_thread::AgentSessionInfo {
5212                    session_id: session_id_b.clone(),
5213                    work_dirs: None,
5214                    title: Some("Thread B".into()),
5215                    updated_at: None,
5216                    created_at: None,
5217                    meta: None,
5218                },
5219                &workspace_b,
5220                window,
5221                cx,
5222            );
5223        });
5224        cx.run_until_parked();
5225
5226        sidebar.read_with(cx, |sidebar, _cx| {
5227            assert_eq!(
5228                sidebar.focused_thread.as_ref(),
5229                Some(&session_id_b),
5230                "Clicking a thread in another workspace should focus that thread"
5231            );
5232            assert!(
5233                has_thread_entry(sidebar, &session_id_b),
5234                "The cross-workspace thread should be present in the entries"
5235            );
5236        });
5237
5238        multi_workspace.update_in(cx, |mw, window, cx| {
5239            mw.activate_index(0, window, cx);
5240        });
5241        cx.run_until_parked();
5242
5243        sidebar.read_with(cx, |sidebar, _cx| {
5244            assert_eq!(
5245                sidebar.focused_thread.as_ref(),
5246                Some(&session_id_a),
5247                "Switching workspace should seed focused_thread from the new active panel"
5248            );
5249            assert!(
5250                has_thread_entry(sidebar, &session_id_a),
5251                "The seeded thread should be present in the entries"
5252            );
5253        });
5254
5255        let connection_b2 = StubAgentConnection::new();
5256        connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5257            acp::ContentChunk::new(DEFAULT_THREAD_TITLE.into()),
5258        )]);
5259        open_thread_with_connection(&panel_b, connection_b2, cx);
5260        send_message(&panel_b, cx);
5261        let session_id_b2 = active_session_id(&panel_b, cx);
5262        save_test_thread_metadata(&session_id_b2, path_list_b.clone(), cx).await;
5263        cx.run_until_parked();
5264
5265        // Panel B is not the active workspace's panel (workspace A is
5266        // active), so opening a thread there should not change focused_thread.
5267        // This prevents running threads in background workspaces from causing
5268        // the selection highlight to jump around.
5269        sidebar.read_with(cx, |sidebar, _cx| {
5270            assert_eq!(
5271                sidebar.focused_thread.as_ref(),
5272                Some(&session_id_a),
5273                "Opening a thread in a non-active panel should not change focused_thread"
5274            );
5275        });
5276
5277        workspace_b.update_in(cx, |workspace, window, cx| {
5278            workspace.focus_handle(cx).focus(window, cx);
5279        });
5280        cx.run_until_parked();
5281
5282        sidebar.read_with(cx, |sidebar, _cx| {
5283            assert_eq!(
5284                sidebar.focused_thread.as_ref(),
5285                Some(&session_id_a),
5286                "Defocusing the sidebar should not change focused_thread"
5287            );
5288        });
5289
5290        // Switching workspaces via the multi_workspace (simulates clicking
5291        // a workspace header) should clear focused_thread.
5292        multi_workspace.update_in(cx, |mw, window, cx| {
5293            if let Some(index) = mw.workspaces().iter().position(|w| w == &workspace_b) {
5294                mw.activate_index(index, window, cx);
5295            }
5296        });
5297        cx.run_until_parked();
5298
5299        sidebar.read_with(cx, |sidebar, _cx| {
5300            assert_eq!(
5301                sidebar.focused_thread.as_ref(),
5302                Some(&session_id_b2),
5303                "Switching workspace should seed focused_thread from the new active panel"
5304            );
5305            assert!(
5306                has_thread_entry(sidebar, &session_id_b2),
5307                "The seeded thread should be present in the entries"
5308            );
5309        });
5310
5311        // ── 8. Focusing the agent panel thread keeps focused_thread ────
5312        // Workspace B still has session_id_b2 loaded in the agent panel.
5313        // Clicking into the thread (simulated by focusing its view) should
5314        // keep focused_thread since it was already seeded on workspace switch.
5315        panel_b.update_in(cx, |panel, window, cx| {
5316            if let Some(thread_view) = panel.active_conversation_view() {
5317                thread_view.read(cx).focus_handle(cx).focus(window, cx);
5318            }
5319        });
5320        cx.run_until_parked();
5321
5322        sidebar.read_with(cx, |sidebar, _cx| {
5323            assert_eq!(
5324                sidebar.focused_thread.as_ref(),
5325                Some(&session_id_b2),
5326                "Focusing the agent panel thread should set focused_thread"
5327            );
5328            assert!(
5329                has_thread_entry(sidebar, &session_id_b2),
5330                "The focused thread should be present in the entries"
5331            );
5332        });
5333    }
5334
5335    #[gpui::test]
5336    async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) {
5337        let project = init_test_project_with_agent_panel("/project-a", cx).await;
5338        let fs = cx.update(|cx| <dyn fs::Fs>::global(cx));
5339        let (multi_workspace, cx) =
5340            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5341        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
5342
5343        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
5344
5345        // Start a thread and send a message so it has history.
5346        let connection = StubAgentConnection::new();
5347        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5348            acp::ContentChunk::new("Done".into()),
5349        )]);
5350        open_thread_with_connection(&panel, connection, cx);
5351        send_message(&panel, cx);
5352        let session_id = active_session_id(&panel, cx);
5353        save_test_thread_metadata(&session_id, path_list_a.clone(), cx).await;
5354        cx.run_until_parked();
5355
5356        // Verify the thread appears in the sidebar.
5357        assert_eq!(
5358            visible_entries_as_strings(&sidebar, cx),
5359            vec!["v [project-a]", "  Hello *",]
5360        );
5361
5362        // The "New Thread" button should NOT be in "active/draft" state
5363        // because the panel has a thread with messages.
5364        sidebar.read_with(cx, |sidebar, _cx| {
5365            assert!(
5366                !sidebar.active_thread_is_draft,
5367                "Panel has a thread with messages, so it should not be a draft"
5368            );
5369        });
5370
5371        // Now add a second folder to the workspace, changing the path_list.
5372        fs.as_fake()
5373            .insert_tree("/project-b", serde_json::json!({ "src": {} }))
5374            .await;
5375        project
5376            .update(cx, |project, cx| {
5377                project.find_or_create_worktree("/project-b", true, cx)
5378            })
5379            .await
5380            .expect("should add worktree");
5381        cx.run_until_parked();
5382
5383        // The workspace path_list is now [project-a, project-b]. The old
5384        // thread was stored under [project-a], so it no longer appears in
5385        // the sidebar list for this workspace.
5386        let entries = visible_entries_as_strings(&sidebar, cx);
5387        assert!(
5388            !entries.iter().any(|e| e.contains("Hello")),
5389            "Thread stored under the old path_list should not appear: {:?}",
5390            entries
5391        );
5392
5393        // The "New Thread" button must still be clickable (not stuck in
5394        // "active/draft" state). Verify that `active_thread_is_draft` is
5395        // false — the panel still has the old thread with messages.
5396        sidebar.read_with(cx, |sidebar, _cx| {
5397            assert!(
5398                !sidebar.active_thread_is_draft,
5399                "After adding a folder the panel still has a thread with messages, \
5400                 so active_thread_is_draft should be false"
5401            );
5402        });
5403
5404        // Actually click "New Thread" by calling create_new_thread and
5405        // verify a new draft is created.
5406        let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
5407        sidebar.update_in(cx, |sidebar, window, cx| {
5408            sidebar.create_new_thread(&workspace, window, cx);
5409        });
5410        cx.run_until_parked();
5411
5412        // After creating a new thread, the panel should now be in draft
5413        // state (no messages on the new thread).
5414        sidebar.read_with(cx, |sidebar, _cx| {
5415            assert!(
5416                sidebar.active_thread_is_draft,
5417                "After creating a new thread the panel should be in draft state"
5418            );
5419        });
5420    }
5421
5422    #[gpui::test]
5423    async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
5424        // When the user presses Cmd-N (NewThread action) while viewing a
5425        // non-empty thread, the sidebar should show the "New Thread" entry.
5426        // This exercises the same code path as the workspace action handler
5427        // (which bypasses the sidebar's create_new_thread method).
5428        let project = init_test_project_with_agent_panel("/my-project", cx).await;
5429        let (multi_workspace, cx) =
5430            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5431        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
5432
5433        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
5434
5435        // Create a non-empty thread (has messages).
5436        let connection = StubAgentConnection::new();
5437        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5438            acp::ContentChunk::new("Done".into()),
5439        )]);
5440        open_thread_with_connection(&panel, connection, cx);
5441        send_message(&panel, cx);
5442
5443        let session_id = active_session_id(&panel, cx);
5444        save_test_thread_metadata(&session_id, path_list.clone(), cx).await;
5445        cx.run_until_parked();
5446
5447        assert_eq!(
5448            visible_entries_as_strings(&sidebar, cx),
5449            vec!["v [my-project]", "  Hello *"]
5450        );
5451
5452        // Simulate cmd-n
5453        let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
5454        panel.update_in(cx, |panel, window, cx| {
5455            panel.new_thread(&NewThread, window, cx);
5456        });
5457        workspace.update_in(cx, |workspace, window, cx| {
5458            workspace.focus_panel::<AgentPanel>(window, cx);
5459        });
5460        cx.run_until_parked();
5461
5462        assert_eq!(
5463            visible_entries_as_strings(&sidebar, cx),
5464            vec!["v [my-project]", "  [+ New Thread]", "  Hello *"],
5465            "After Cmd-N the sidebar should show a highlighted New Thread entry"
5466        );
5467
5468        sidebar.read_with(cx, |sidebar, _cx| {
5469            assert!(
5470                sidebar.focused_thread.is_none(),
5471                "focused_thread should be cleared after Cmd-N"
5472            );
5473            assert!(
5474                sidebar.active_thread_is_draft,
5475                "the new blank thread should be a draft"
5476            );
5477        });
5478    }
5479
5480    #[gpui::test]
5481    async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) {
5482        // When the active workspace is an absorbed git worktree, cmd-n
5483        // should still show the "New Thread" entry under the main repo's
5484        // header and highlight it as active.
5485        agent_ui::test_support::init_test(cx);
5486        cx.update(|cx| {
5487            cx.update_flags(false, vec!["agent-v2".into()]);
5488            ThreadStore::init_global(cx);
5489            SidebarThreadMetadataStore::init_global(cx);
5490            language_model::LanguageModelRegistry::test(cx);
5491            prompt_store::init(cx);
5492        });
5493
5494        let fs = FakeFs::new(cx.executor());
5495
5496        // Main repo with a linked worktree.
5497        fs.insert_tree(
5498            "/project",
5499            serde_json::json!({
5500                ".git": {
5501                    "worktrees": {
5502                        "feature-a": {
5503                            "commondir": "../../",
5504                            "HEAD": "ref: refs/heads/feature-a",
5505                        },
5506                    },
5507                },
5508                "src": {},
5509            }),
5510        )
5511        .await;
5512
5513        // Worktree checkout pointing back to the main repo.
5514        fs.insert_tree(
5515            "/wt-feature-a",
5516            serde_json::json!({
5517                ".git": "gitdir: /project/.git/worktrees/feature-a",
5518                "src": {},
5519            }),
5520        )
5521        .await;
5522
5523        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
5524            state.worktrees.push(git::repository::Worktree {
5525                path: std::path::PathBuf::from("/wt-feature-a"),
5526                ref_name: Some("refs/heads/feature-a".into()),
5527                sha: "aaa".into(),
5528            });
5529        })
5530        .unwrap();
5531
5532        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5533
5534        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5535        let worktree_project =
5536            project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5537
5538        main_project
5539            .update(cx, |p, cx| p.git_scans_complete(cx))
5540            .await;
5541        worktree_project
5542            .update(cx, |p, cx| p.git_scans_complete(cx))
5543            .await;
5544
5545        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
5546            MultiWorkspace::test_new(main_project.clone(), window, cx)
5547        });
5548
5549        let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5550            mw.test_add_workspace(worktree_project.clone(), window, cx)
5551        });
5552
5553        let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx);
5554
5555        // Switch to the worktree workspace.
5556        multi_workspace.update_in(cx, |mw, window, cx| {
5557            mw.activate_index(1, window, cx);
5558        });
5559
5560        let sidebar = setup_sidebar(&multi_workspace, cx);
5561
5562        // Create a non-empty thread in the worktree workspace.
5563        let connection = StubAgentConnection::new();
5564        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5565            acp::ContentChunk::new("Done".into()),
5566        )]);
5567        open_thread_with_connection(&worktree_panel, connection, cx);
5568        send_message(&worktree_panel, cx);
5569
5570        let session_id = active_session_id(&worktree_panel, cx);
5571        let wt_path_list = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
5572        save_test_thread_metadata(&session_id, wt_path_list, cx).await;
5573        cx.run_until_parked();
5574
5575        assert_eq!(
5576            visible_entries_as_strings(&sidebar, cx),
5577            vec!["v [project]", "  Hello {wt-feature-a} *"]
5578        );
5579
5580        // Simulate Cmd-N in the worktree workspace.
5581        worktree_panel.update_in(cx, |panel, window, cx| {
5582            panel.new_thread(&NewThread, window, cx);
5583        });
5584        worktree_workspace.update_in(cx, |workspace, window, cx| {
5585            workspace.focus_panel::<AgentPanel>(window, cx);
5586        });
5587        cx.run_until_parked();
5588
5589        assert_eq!(
5590            visible_entries_as_strings(&sidebar, cx),
5591            vec![
5592                "v [project]",
5593                "  [+ New Thread]",
5594                "  Hello {wt-feature-a} *"
5595            ],
5596            "After Cmd-N in an absorbed worktree, the sidebar should show \
5597             a highlighted New Thread entry under the main repo header"
5598        );
5599
5600        sidebar.read_with(cx, |sidebar, _cx| {
5601            assert!(
5602                sidebar.focused_thread.is_none(),
5603                "focused_thread should be cleared after Cmd-N"
5604            );
5605            assert!(
5606                sidebar.active_thread_is_draft,
5607                "the new blank thread should be a draft"
5608            );
5609        });
5610    }
5611
5612    async fn init_test_project_with_git(
5613        worktree_path: &str,
5614        cx: &mut TestAppContext,
5615    ) -> (Entity<project::Project>, Arc<dyn fs::Fs>) {
5616        init_test(cx);
5617        let fs = FakeFs::new(cx.executor());
5618        fs.insert_tree(
5619            worktree_path,
5620            serde_json::json!({
5621                ".git": {},
5622                "src": {},
5623            }),
5624        )
5625        .await;
5626        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5627        let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await;
5628        (project, fs)
5629    }
5630
5631    #[gpui::test]
5632    async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
5633        let (project, fs) = init_test_project_with_git("/project", cx).await;
5634
5635        fs.as_fake()
5636            .with_git_state(std::path::Path::new("/project/.git"), false, |state| {
5637                state.worktrees.push(git::repository::Worktree {
5638                    path: std::path::PathBuf::from("/wt/rosewood"),
5639                    ref_name: Some("refs/heads/rosewood".into()),
5640                    sha: "abc".into(),
5641                });
5642            })
5643            .unwrap();
5644
5645        project
5646            .update(cx, |project, cx| project.git_scans_complete(cx))
5647            .await;
5648
5649        let (multi_workspace, cx) =
5650            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5651        let sidebar = setup_sidebar(&multi_workspace, cx);
5652
5653        let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]);
5654        let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
5655        save_named_thread_metadata("main-t", "Unrelated Thread", &main_paths, cx).await;
5656        save_named_thread_metadata("wt-t", "Fix Bug", &wt_paths, cx).await;
5657
5658        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5659        cx.run_until_parked();
5660
5661        // Search for "rosewood" — should match the worktree name, not the title.
5662        type_in_search(&sidebar, "rosewood", cx);
5663
5664        assert_eq!(
5665            visible_entries_as_strings(&sidebar, cx),
5666            vec!["v [project]", "  Fix Bug {rosewood}  <== selected"],
5667        );
5668    }
5669
5670    #[gpui::test]
5671    async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
5672        let (project, fs) = init_test_project_with_git("/project", cx).await;
5673
5674        project
5675            .update(cx, |project, cx| project.git_scans_complete(cx))
5676            .await;
5677
5678        let (multi_workspace, cx) =
5679            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5680        let sidebar = setup_sidebar(&multi_workspace, cx);
5681
5682        // Save a thread against a worktree path that doesn't exist yet.
5683        let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
5684        save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await;
5685
5686        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5687        cx.run_until_parked();
5688
5689        // Thread is not visible yet — no worktree knows about this path.
5690        assert_eq!(
5691            visible_entries_as_strings(&sidebar, cx),
5692            vec!["v [project]", "  [+ New Thread]"]
5693        );
5694
5695        // Now add the worktree to the git state and trigger a rescan.
5696        fs.as_fake()
5697            .with_git_state(std::path::Path::new("/project/.git"), true, |state| {
5698                state.worktrees.push(git::repository::Worktree {
5699                    path: std::path::PathBuf::from("/wt/rosewood"),
5700                    ref_name: Some("refs/heads/rosewood".into()),
5701                    sha: "abc".into(),
5702                });
5703            })
5704            .unwrap();
5705
5706        cx.run_until_parked();
5707
5708        assert_eq!(
5709            visible_entries_as_strings(&sidebar, cx),
5710            vec!["v [project]", "  Worktree Thread {rosewood}",]
5711        );
5712    }
5713
5714    #[gpui::test]
5715    async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) {
5716        init_test(cx);
5717        let fs = FakeFs::new(cx.executor());
5718
5719        // Create the main repo directory (not opened as a workspace yet).
5720        fs.insert_tree(
5721            "/project",
5722            serde_json::json!({
5723                ".git": {
5724                    "worktrees": {
5725                        "feature-a": {
5726                            "commondir": "../../",
5727                            "HEAD": "ref: refs/heads/feature-a",
5728                        },
5729                        "feature-b": {
5730                            "commondir": "../../",
5731                            "HEAD": "ref: refs/heads/feature-b",
5732                        },
5733                    },
5734                },
5735                "src": {},
5736            }),
5737        )
5738        .await;
5739
5740        // Two worktree checkouts whose .git files point back to the main repo.
5741        fs.insert_tree(
5742            "/wt-feature-a",
5743            serde_json::json!({
5744                ".git": "gitdir: /project/.git/worktrees/feature-a",
5745                "src": {},
5746            }),
5747        )
5748        .await;
5749        fs.insert_tree(
5750            "/wt-feature-b",
5751            serde_json::json!({
5752                ".git": "gitdir: /project/.git/worktrees/feature-b",
5753                "src": {},
5754            }),
5755        )
5756        .await;
5757
5758        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5759
5760        let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5761        let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
5762
5763        project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
5764        project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
5765
5766        // Open both worktrees as workspaces — no main repo yet.
5767        let (multi_workspace, cx) = cx
5768            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
5769        multi_workspace.update_in(cx, |mw, window, cx| {
5770            mw.test_add_workspace(project_b.clone(), window, cx);
5771        });
5772        let sidebar = setup_sidebar(&multi_workspace, cx);
5773
5774        let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
5775        let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]);
5776        save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await;
5777        save_named_thread_metadata("thread-b", "Thread B", &paths_b, cx).await;
5778
5779        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
5780        cx.run_until_parked();
5781
5782        // Without the main repo, each worktree has its own header.
5783        assert_eq!(
5784            visible_entries_as_strings(&sidebar, cx),
5785            vec![
5786                "v [wt-feature-a]",
5787                "  Thread A",
5788                "v [wt-feature-b]",
5789                "  Thread B",
5790            ]
5791        );
5792
5793        // Configure the main repo to list both worktrees before opening
5794        // it so the initial git scan picks them up.
5795        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
5796            state.worktrees.push(git::repository::Worktree {
5797                path: std::path::PathBuf::from("/wt-feature-a"),
5798                ref_name: Some("refs/heads/feature-a".into()),
5799                sha: "aaa".into(),
5800            });
5801            state.worktrees.push(git::repository::Worktree {
5802                path: std::path::PathBuf::from("/wt-feature-b"),
5803                ref_name: Some("refs/heads/feature-b".into()),
5804                sha: "bbb".into(),
5805            });
5806        })
5807        .unwrap();
5808
5809        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5810        main_project
5811            .update(cx, |p, cx| p.git_scans_complete(cx))
5812            .await;
5813
5814        multi_workspace.update_in(cx, |mw, window, cx| {
5815            mw.test_add_workspace(main_project.clone(), window, cx);
5816        });
5817        cx.run_until_parked();
5818
5819        // Both worktree workspaces should now be absorbed under the main
5820        // repo header, with worktree chips.
5821        assert_eq!(
5822            visible_entries_as_strings(&sidebar, cx),
5823            vec![
5824                "v [project]",
5825                "  Thread A {wt-feature-a}",
5826                "  Thread B {wt-feature-b}",
5827            ]
5828        );
5829
5830        // Remove feature-b from the main repo's linked worktrees.
5831        // The feature-b workspace should be pruned automatically.
5832        fs.with_git_state(std::path::Path::new("/project/.git"), true, |state| {
5833            state
5834                .worktrees
5835                .retain(|wt| wt.path != std::path::Path::new("/wt-feature-b"));
5836        })
5837        .unwrap();
5838
5839        cx.run_until_parked();
5840
5841        // feature-b's workspace is pruned; feature-a remains absorbed
5842        // under the main repo.
5843        assert_eq!(
5844            visible_entries_as_strings(&sidebar, cx),
5845            vec!["v [project]", "  Thread A {wt-feature-a}",]
5846        );
5847    }
5848
5849    #[gpui::test]
5850    async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) {
5851        // When a worktree workspace is absorbed under the main repo, a
5852        // running thread in the worktree's agent panel should still show
5853        // live status (spinner + "(running)") in the sidebar.
5854        agent_ui::test_support::init_test(cx);
5855        cx.update(|cx| {
5856            cx.update_flags(false, vec!["agent-v2".into()]);
5857            ThreadStore::init_global(cx);
5858            SidebarThreadMetadataStore::init_global(cx);
5859            language_model::LanguageModelRegistry::test(cx);
5860            prompt_store::init(cx);
5861        });
5862
5863        let fs = FakeFs::new(cx.executor());
5864
5865        // Main repo with a linked worktree.
5866        fs.insert_tree(
5867            "/project",
5868            serde_json::json!({
5869                ".git": {
5870                    "worktrees": {
5871                        "feature-a": {
5872                            "commondir": "../../",
5873                            "HEAD": "ref: refs/heads/feature-a",
5874                        },
5875                    },
5876                },
5877                "src": {},
5878            }),
5879        )
5880        .await;
5881
5882        // Worktree checkout pointing back to the main repo.
5883        fs.insert_tree(
5884            "/wt-feature-a",
5885            serde_json::json!({
5886                ".git": "gitdir: /project/.git/worktrees/feature-a",
5887                "src": {},
5888            }),
5889        )
5890        .await;
5891
5892        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
5893            state.worktrees.push(git::repository::Worktree {
5894                path: std::path::PathBuf::from("/wt-feature-a"),
5895                ref_name: Some("refs/heads/feature-a".into()),
5896                sha: "aaa".into(),
5897            });
5898        })
5899        .unwrap();
5900
5901        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
5902
5903        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
5904        let worktree_project =
5905            project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
5906
5907        main_project
5908            .update(cx, |p, cx| p.git_scans_complete(cx))
5909            .await;
5910        worktree_project
5911            .update(cx, |p, cx| p.git_scans_complete(cx))
5912            .await;
5913
5914        // Create the MultiWorkspace with both projects.
5915        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
5916            MultiWorkspace::test_new(main_project.clone(), window, cx)
5917        });
5918
5919        let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
5920            mw.test_add_workspace(worktree_project.clone(), window, cx)
5921        });
5922
5923        // Add an agent panel to the worktree workspace so we can run a
5924        // thread inside it.
5925        let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx);
5926
5927        // Switch back to the main workspace before setting up the sidebar.
5928        multi_workspace.update_in(cx, |mw, window, cx| {
5929            mw.activate_index(0, window, cx);
5930        });
5931
5932        let sidebar = setup_sidebar(&multi_workspace, cx);
5933
5934        // Start a thread in the worktree workspace's panel and keep it
5935        // generating (don't resolve it).
5936        let connection = StubAgentConnection::new();
5937        open_thread_with_connection(&worktree_panel, connection.clone(), cx);
5938        send_message(&worktree_panel, cx);
5939
5940        let session_id = active_session_id(&worktree_panel, cx);
5941
5942        // Save metadata so the sidebar knows about this thread.
5943        let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
5944        save_test_thread_metadata(&session_id, wt_paths, cx).await;
5945
5946        // Keep the thread generating by sending a chunk without ending
5947        // the turn.
5948        cx.update(|_, cx| {
5949            connection.send_update(
5950                session_id.clone(),
5951                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
5952                cx,
5953            );
5954        });
5955        cx.run_until_parked();
5956
5957        // The worktree thread should be absorbed under the main project
5958        // and show live running status.
5959        let entries = visible_entries_as_strings(&sidebar, cx);
5960        assert_eq!(
5961            entries,
5962            vec!["v [project]", "  Hello {wt-feature-a} * (running)",]
5963        );
5964    }
5965
5966    #[gpui::test]
5967    async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAppContext) {
5968        agent_ui::test_support::init_test(cx);
5969        cx.update(|cx| {
5970            cx.update_flags(false, vec!["agent-v2".into()]);
5971            ThreadStore::init_global(cx);
5972            SidebarThreadMetadataStore::init_global(cx);
5973            language_model::LanguageModelRegistry::test(cx);
5974            prompt_store::init(cx);
5975        });
5976
5977        let fs = FakeFs::new(cx.executor());
5978
5979        fs.insert_tree(
5980            "/project",
5981            serde_json::json!({
5982                ".git": {
5983                    "worktrees": {
5984                        "feature-a": {
5985                            "commondir": "../../",
5986                            "HEAD": "ref: refs/heads/feature-a",
5987                        },
5988                    },
5989                },
5990                "src": {},
5991            }),
5992        )
5993        .await;
5994
5995        fs.insert_tree(
5996            "/wt-feature-a",
5997            serde_json::json!({
5998                ".git": "gitdir: /project/.git/worktrees/feature-a",
5999                "src": {},
6000            }),
6001        )
6002        .await;
6003
6004        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
6005            state.worktrees.push(git::repository::Worktree {
6006                path: std::path::PathBuf::from("/wt-feature-a"),
6007                ref_name: Some("refs/heads/feature-a".into()),
6008                sha: "aaa".into(),
6009            });
6010        })
6011        .unwrap();
6012
6013        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6014
6015        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
6016        let worktree_project =
6017            project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
6018
6019        main_project
6020            .update(cx, |p, cx| p.git_scans_complete(cx))
6021            .await;
6022        worktree_project
6023            .update(cx, |p, cx| p.git_scans_complete(cx))
6024            .await;
6025
6026        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
6027            MultiWorkspace::test_new(main_project.clone(), window, cx)
6028        });
6029
6030        let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
6031            mw.test_add_workspace(worktree_project.clone(), window, cx)
6032        });
6033
6034        let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx);
6035
6036        multi_workspace.update_in(cx, |mw, window, cx| {
6037            mw.activate_index(0, window, cx);
6038        });
6039
6040        let sidebar = setup_sidebar(&multi_workspace, cx);
6041
6042        let connection = StubAgentConnection::new();
6043        open_thread_with_connection(&worktree_panel, connection.clone(), cx);
6044        send_message(&worktree_panel, cx);
6045
6046        let session_id = active_session_id(&worktree_panel, cx);
6047        let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
6048        save_test_thread_metadata(&session_id, wt_paths, cx).await;
6049
6050        cx.update(|_, cx| {
6051            connection.send_update(
6052                session_id.clone(),
6053                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
6054                cx,
6055            );
6056        });
6057        cx.run_until_parked();
6058
6059        assert_eq!(
6060            visible_entries_as_strings(&sidebar, cx),
6061            vec!["v [project]", "  Hello {wt-feature-a} * (running)",]
6062        );
6063
6064        connection.end_turn(session_id, acp::StopReason::EndTurn);
6065        cx.run_until_parked();
6066
6067        assert_eq!(
6068            visible_entries_as_strings(&sidebar, cx),
6069            vec!["v [project]", "  Hello {wt-feature-a} * (!)",]
6070        );
6071    }
6072
6073    #[gpui::test]
6074    async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(
6075        cx: &mut TestAppContext,
6076    ) {
6077        init_test(cx);
6078        let fs = FakeFs::new(cx.executor());
6079
6080        fs.insert_tree(
6081            "/project",
6082            serde_json::json!({
6083                ".git": {
6084                    "worktrees": {
6085                        "feature-a": {
6086                            "commondir": "../../",
6087                            "HEAD": "ref: refs/heads/feature-a",
6088                        },
6089                    },
6090                },
6091                "src": {},
6092            }),
6093        )
6094        .await;
6095
6096        fs.insert_tree(
6097            "/wt-feature-a",
6098            serde_json::json!({
6099                ".git": "gitdir: /project/.git/worktrees/feature-a",
6100                "src": {},
6101            }),
6102        )
6103        .await;
6104
6105        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
6106            state.worktrees.push(git::repository::Worktree {
6107                path: std::path::PathBuf::from("/wt-feature-a"),
6108                ref_name: Some("refs/heads/feature-a".into()),
6109                sha: "aaa".into(),
6110            });
6111        })
6112        .unwrap();
6113
6114        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6115
6116        // Only open the main repo — no workspace for the worktree.
6117        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
6118        main_project
6119            .update(cx, |p, cx| p.git_scans_complete(cx))
6120            .await;
6121
6122        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
6123            MultiWorkspace::test_new(main_project.clone(), window, cx)
6124        });
6125        let sidebar = setup_sidebar(&multi_workspace, cx);
6126
6127        // Save a thread for the worktree path (no workspace for it).
6128        let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
6129        save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await;
6130
6131        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
6132        cx.run_until_parked();
6133
6134        // Thread should appear under the main repo with a worktree chip.
6135        assert_eq!(
6136            visible_entries_as_strings(&sidebar, cx),
6137            vec!["v [project]", "  WT Thread {wt-feature-a}"],
6138        );
6139
6140        // Only 1 workspace should exist.
6141        assert_eq!(
6142            multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
6143            1,
6144        );
6145
6146        // Focus the sidebar and select the worktree thread.
6147        open_and_focus_sidebar(&sidebar, cx);
6148        sidebar.update_in(cx, |sidebar, _window, _cx| {
6149            sidebar.selection = Some(1); // index 0 is header, 1 is the thread
6150        });
6151
6152        // Confirm to open the worktree thread.
6153        cx.dispatch_action(Confirm);
6154        cx.run_until_parked();
6155
6156        // A new workspace should have been created for the worktree path.
6157        let new_workspace = multi_workspace.read_with(cx, |mw, _| {
6158            assert_eq!(
6159                mw.workspaces().len(),
6160                2,
6161                "confirming a worktree thread without a workspace should open one",
6162            );
6163            mw.workspaces()[1].clone()
6164        });
6165
6166        let new_path_list =
6167            new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
6168        assert_eq!(
6169            new_path_list,
6170            PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
6171            "the new workspace should have been opened for the worktree path",
6172        );
6173    }
6174
6175    #[gpui::test]
6176    async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_project(
6177        cx: &mut TestAppContext,
6178    ) {
6179        init_test(cx);
6180        let fs = FakeFs::new(cx.executor());
6181
6182        fs.insert_tree(
6183            "/project",
6184            serde_json::json!({
6185                ".git": {
6186                    "worktrees": {
6187                        "feature-a": {
6188                            "commondir": "../../",
6189                            "HEAD": "ref: refs/heads/feature-a",
6190                        },
6191                    },
6192                },
6193                "src": {},
6194            }),
6195        )
6196        .await;
6197
6198        fs.insert_tree(
6199            "/wt-feature-a",
6200            serde_json::json!({
6201                ".git": "gitdir: /project/.git/worktrees/feature-a",
6202                "src": {},
6203            }),
6204        )
6205        .await;
6206
6207        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
6208            state.worktrees.push(git::repository::Worktree {
6209                path: std::path::PathBuf::from("/wt-feature-a"),
6210                ref_name: Some("refs/heads/feature-a".into()),
6211                sha: "aaa".into(),
6212            });
6213        })
6214        .unwrap();
6215
6216        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6217
6218        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
6219        main_project
6220            .update(cx, |p, cx| p.git_scans_complete(cx))
6221            .await;
6222
6223        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
6224            MultiWorkspace::test_new(main_project.clone(), window, cx)
6225        });
6226        let sidebar = setup_sidebar(&multi_workspace, cx);
6227
6228        let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
6229        save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await;
6230
6231        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
6232        cx.run_until_parked();
6233
6234        assert_eq!(
6235            visible_entries_as_strings(&sidebar, cx),
6236            vec!["v [project]", "  WT Thread {wt-feature-a}"],
6237        );
6238
6239        open_and_focus_sidebar(&sidebar, cx);
6240        sidebar.update_in(cx, |sidebar, _window, _cx| {
6241            sidebar.selection = Some(1);
6242        });
6243
6244        let assert_sidebar_state = |sidebar: &mut Sidebar, _cx: &mut Context<Sidebar>| {
6245            let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| {
6246                if let ListEntry::ProjectHeader { label, .. } = entry {
6247                    Some(label.as_ref())
6248                } else {
6249                    None
6250                }
6251            });
6252
6253            let Some(project_header) = project_headers.next() else {
6254                panic!("expected exactly one sidebar project header named `project`, found none");
6255            };
6256            assert_eq!(
6257                project_header, "project",
6258                "expected the only sidebar project header to be `project`"
6259            );
6260            if let Some(unexpected_header) = project_headers.next() {
6261                panic!(
6262                    "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`"
6263                );
6264            }
6265
6266            let mut saw_expected_thread = false;
6267            for entry in &sidebar.contents.entries {
6268                match entry {
6269                    ListEntry::ProjectHeader { label, .. } => {
6270                        assert_eq!(
6271                            label.as_ref(),
6272                            "project",
6273                            "expected the only sidebar project header to be `project`"
6274                        );
6275                    }
6276                    ListEntry::Thread(thread)
6277                        if thread
6278                            .session_info
6279                            .title
6280                            .as_ref()
6281                            .map(|title| title.as_ref())
6282                            == Some("WT Thread")
6283                            && thread.worktree_name.as_ref().map(|name| name.as_ref())
6284                                == Some("wt-feature-a") =>
6285                    {
6286                        saw_expected_thread = true;
6287                    }
6288                    ListEntry::Thread(thread) => {
6289                        let title = thread
6290                            .session_info
6291                            .title
6292                            .as_ref()
6293                            .map(|title| title.as_ref())
6294                            .unwrap_or("Untitled");
6295                        let worktree_name = thread
6296                            .worktree_name
6297                            .as_ref()
6298                            .map(|name| name.as_ref())
6299                            .unwrap_or("<none>");
6300                        panic!(
6301                            "unexpected sidebar thread while opening linked worktree thread: title=`{title}`, worktree=`{worktree_name}`"
6302                        );
6303                    }
6304                    ListEntry::ViewMore { .. } => {
6305                        panic!("unexpected `View More` entry while opening linked worktree thread");
6306                    }
6307                    ListEntry::NewThread { .. } => {
6308                        panic!(
6309                            "unexpected `New Thread` entry while opening linked worktree thread"
6310                        );
6311                    }
6312                }
6313            }
6314
6315            assert!(
6316                saw_expected_thread,
6317                "expected the sidebar to keep showing `WT Thread {{wt-feature-a}}` under `project`"
6318            );
6319        };
6320
6321        sidebar
6322            .update(cx, |_, cx| cx.observe_self(assert_sidebar_state))
6323            .detach();
6324
6325        let window = cx.windows()[0];
6326        cx.update_window(window, |_, window, cx| {
6327            window.dispatch_action(Confirm.boxed_clone(), cx);
6328        })
6329        .unwrap();
6330
6331        cx.run_until_parked();
6332
6333        sidebar.update(cx, assert_sidebar_state);
6334    }
6335
6336    #[gpui::test]
6337    async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
6338        cx: &mut TestAppContext,
6339    ) {
6340        init_test(cx);
6341        let fs = FakeFs::new(cx.executor());
6342
6343        fs.insert_tree(
6344            "/project",
6345            serde_json::json!({
6346                ".git": {
6347                    "worktrees": {
6348                        "feature-a": {
6349                            "commondir": "../../",
6350                            "HEAD": "ref: refs/heads/feature-a",
6351                        },
6352                    },
6353                },
6354                "src": {},
6355            }),
6356        )
6357        .await;
6358
6359        fs.insert_tree(
6360            "/wt-feature-a",
6361            serde_json::json!({
6362                ".git": "gitdir: /project/.git/worktrees/feature-a",
6363                "src": {},
6364            }),
6365        )
6366        .await;
6367
6368        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
6369            state.worktrees.push(git::repository::Worktree {
6370                path: std::path::PathBuf::from("/wt-feature-a"),
6371                ref_name: Some("refs/heads/feature-a".into()),
6372                sha: "aaa".into(),
6373            });
6374        })
6375        .unwrap();
6376
6377        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6378
6379        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
6380        let worktree_project =
6381            project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
6382
6383        main_project
6384            .update(cx, |p, cx| p.git_scans_complete(cx))
6385            .await;
6386        worktree_project
6387            .update(cx, |p, cx| p.git_scans_complete(cx))
6388            .await;
6389
6390        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
6391            MultiWorkspace::test_new(main_project.clone(), window, cx)
6392        });
6393
6394        let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
6395            mw.test_add_workspace(worktree_project.clone(), window, cx)
6396        });
6397
6398        // Activate the main workspace before setting up the sidebar.
6399        multi_workspace.update_in(cx, |mw, window, cx| {
6400            mw.activate_index(0, window, cx);
6401        });
6402
6403        let sidebar = setup_sidebar(&multi_workspace, cx);
6404
6405        let paths_main = PathList::new(&[std::path::PathBuf::from("/project")]);
6406        let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
6407        save_named_thread_metadata("thread-main", "Main Thread", &paths_main, cx).await;
6408        save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await;
6409
6410        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
6411        cx.run_until_parked();
6412
6413        // The worktree workspace should be absorbed under the main repo.
6414        let entries = visible_entries_as_strings(&sidebar, cx);
6415        assert_eq!(entries.len(), 3);
6416        assert_eq!(entries[0], "v [project]");
6417        assert!(entries.contains(&"  Main Thread".to_string()));
6418        assert!(entries.contains(&"  WT Thread {wt-feature-a}".to_string()));
6419
6420        let wt_thread_index = entries
6421            .iter()
6422            .position(|e| e.contains("WT Thread"))
6423            .expect("should find the worktree thread entry");
6424
6425        assert_eq!(
6426            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6427            0,
6428            "main workspace should be active initially"
6429        );
6430
6431        // Focus the sidebar and select the absorbed worktree thread.
6432        open_and_focus_sidebar(&sidebar, cx);
6433        sidebar.update_in(cx, |sidebar, _window, _cx| {
6434            sidebar.selection = Some(wt_thread_index);
6435        });
6436
6437        // Confirm to activate the worktree thread.
6438        cx.dispatch_action(Confirm);
6439        cx.run_until_parked();
6440
6441        // The worktree workspace should now be active, not the main one.
6442        let active_workspace = multi_workspace.read_with(cx, |mw, _| {
6443            mw.workspaces()[mw.active_workspace_index()].clone()
6444        });
6445        assert_eq!(
6446            active_workspace, worktree_workspace,
6447            "clicking an absorbed worktree thread should activate the worktree workspace"
6448        );
6449    }
6450
6451    #[gpui::test]
6452    async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace(
6453        cx: &mut TestAppContext,
6454    ) {
6455        // Thread has saved metadata in ThreadStore. A matching workspace is
6456        // already open. Expected: activates the matching workspace.
6457        init_test(cx);
6458        let fs = FakeFs::new(cx.executor());
6459        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6460            .await;
6461        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6462            .await;
6463        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6464
6465        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6466        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6467
6468        let (multi_workspace, cx) =
6469            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6470
6471        multi_workspace.update_in(cx, |mw, window, cx| {
6472            mw.test_add_workspace(project_b, window, cx);
6473        });
6474
6475        let sidebar = setup_sidebar(&multi_workspace, cx);
6476
6477        // Save a thread with path_list pointing to project-b.
6478        let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
6479        let session_id = acp::SessionId::new(Arc::from("archived-1"));
6480        save_test_thread_metadata(&session_id, path_list_b.clone(), cx).await;
6481
6482        // Ensure workspace A is active.
6483        multi_workspace.update_in(cx, |mw, window, cx| {
6484            mw.activate_index(0, window, cx);
6485        });
6486        cx.run_until_parked();
6487        assert_eq!(
6488            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6489            0
6490        );
6491
6492        // Call activate_archived_thread – should resolve saved paths and
6493        // switch to the workspace for project-b.
6494        sidebar.update_in(cx, |sidebar, window, cx| {
6495            sidebar.activate_archived_thread(
6496                Agent::NativeAgent,
6497                acp_thread::AgentSessionInfo {
6498                    session_id: session_id.clone(),
6499                    work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])),
6500                    title: Some("Archived Thread".into()),
6501                    updated_at: None,
6502                    created_at: None,
6503                    meta: None,
6504                },
6505                window,
6506                cx,
6507            );
6508        });
6509        cx.run_until_parked();
6510
6511        assert_eq!(
6512            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6513            1,
6514            "should have activated the workspace matching the saved path_list"
6515        );
6516    }
6517
6518    #[gpui::test]
6519    async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
6520        cx: &mut TestAppContext,
6521    ) {
6522        // Thread has no saved metadata but session_info has cwd. A matching
6523        // workspace is open. Expected: uses cwd to find and activate it.
6524        init_test(cx);
6525        let fs = FakeFs::new(cx.executor());
6526        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6527            .await;
6528        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6529            .await;
6530        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6531
6532        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6533        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6534
6535        let (multi_workspace, cx) =
6536            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6537
6538        multi_workspace.update_in(cx, |mw, window, cx| {
6539            mw.test_add_workspace(project_b, window, cx);
6540        });
6541
6542        let sidebar = setup_sidebar(&multi_workspace, cx);
6543
6544        // Start with workspace A active.
6545        multi_workspace.update_in(cx, |mw, window, cx| {
6546            mw.activate_index(0, window, cx);
6547        });
6548        cx.run_until_parked();
6549        assert_eq!(
6550            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6551            0
6552        );
6553
6554        // No thread saved to the store – cwd is the only path hint.
6555        sidebar.update_in(cx, |sidebar, window, cx| {
6556            sidebar.activate_archived_thread(
6557                Agent::NativeAgent,
6558                acp_thread::AgentSessionInfo {
6559                    session_id: acp::SessionId::new(Arc::from("unknown-session")),
6560                    work_dirs: Some(PathList::new(&[std::path::PathBuf::from("/project-b")])),
6561                    title: Some("CWD Thread".into()),
6562                    updated_at: None,
6563                    created_at: None,
6564                    meta: None,
6565                },
6566                window,
6567                cx,
6568            );
6569        });
6570        cx.run_until_parked();
6571
6572        assert_eq!(
6573            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6574            1,
6575            "should have activated the workspace matching the cwd"
6576        );
6577    }
6578
6579    #[gpui::test]
6580    async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
6581        cx: &mut TestAppContext,
6582    ) {
6583        // Thread has no saved metadata and no cwd. Expected: falls back to
6584        // the currently active workspace.
6585        init_test(cx);
6586        let fs = FakeFs::new(cx.executor());
6587        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6588            .await;
6589        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6590            .await;
6591        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6592
6593        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6594        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6595
6596        let (multi_workspace, cx) =
6597            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6598
6599        multi_workspace.update_in(cx, |mw, window, cx| {
6600            mw.test_add_workspace(project_b, window, cx);
6601        });
6602
6603        let sidebar = setup_sidebar(&multi_workspace, cx);
6604
6605        // Activate workspace B (index 1) to make it the active one.
6606        multi_workspace.update_in(cx, |mw, window, cx| {
6607            mw.activate_index(1, window, cx);
6608        });
6609        cx.run_until_parked();
6610        assert_eq!(
6611            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6612            1
6613        );
6614
6615        // No saved thread, no cwd – should fall back to the active workspace.
6616        sidebar.update_in(cx, |sidebar, window, cx| {
6617            sidebar.activate_archived_thread(
6618                Agent::NativeAgent,
6619                acp_thread::AgentSessionInfo {
6620                    session_id: acp::SessionId::new(Arc::from("no-context-session")),
6621                    work_dirs: None,
6622                    title: Some("Contextless Thread".into()),
6623                    updated_at: None,
6624                    created_at: None,
6625                    meta: None,
6626                },
6627                window,
6628                cx,
6629            );
6630        });
6631        cx.run_until_parked();
6632
6633        assert_eq!(
6634            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
6635            1,
6636            "should have stayed on the active workspace when no path info is available"
6637        );
6638    }
6639
6640    #[gpui::test]
6641    async fn test_activate_archived_thread_saved_paths_opens_new_workspace(
6642        cx: &mut TestAppContext,
6643    ) {
6644        // Thread has saved metadata pointing to a path with no open workspace.
6645        // Expected: opens a new workspace for that path.
6646        init_test(cx);
6647        let fs = FakeFs::new(cx.executor());
6648        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6649            .await;
6650        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6651            .await;
6652        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6653
6654        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6655
6656        let (multi_workspace, cx) =
6657            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6658
6659        let sidebar = setup_sidebar(&multi_workspace, cx);
6660
6661        // Save a thread with path_list pointing to project-b – which has no
6662        // open workspace.
6663        let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
6664        let session_id = acp::SessionId::new(Arc::from("archived-new-ws"));
6665
6666        assert_eq!(
6667            multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
6668            1,
6669            "should start with one workspace"
6670        );
6671
6672        sidebar.update_in(cx, |sidebar, window, cx| {
6673            sidebar.activate_archived_thread(
6674                Agent::NativeAgent,
6675                acp_thread::AgentSessionInfo {
6676                    session_id: session_id.clone(),
6677                    work_dirs: Some(path_list_b),
6678                    title: Some("New WS Thread".into()),
6679                    updated_at: None,
6680                    created_at: None,
6681                    meta: None,
6682                },
6683                window,
6684                cx,
6685            );
6686        });
6687        cx.run_until_parked();
6688
6689        assert_eq!(
6690            multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
6691            2,
6692            "should have opened a second workspace for the archived thread's saved paths"
6693        );
6694    }
6695
6696    #[gpui::test]
6697    async fn test_activate_archived_thread_reuses_workspace_in_another_window(
6698        cx: &mut TestAppContext,
6699    ) {
6700        init_test(cx);
6701        let fs = FakeFs::new(cx.executor());
6702        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6703            .await;
6704        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6705            .await;
6706        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6707
6708        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6709        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6710
6711        let multi_workspace_a =
6712            cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6713        let multi_workspace_b =
6714            cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
6715
6716        let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
6717
6718        let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
6719        let sidebar = setup_sidebar(&multi_workspace_a_entity, cx_a);
6720
6721        let session_id = acp::SessionId::new(Arc::from("archived-cross-window"));
6722
6723        sidebar.update_in(cx_a, |sidebar, window, cx| {
6724            sidebar.activate_archived_thread(
6725                Agent::NativeAgent,
6726                acp_thread::AgentSessionInfo {
6727                    session_id: session_id.clone(),
6728                    work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])),
6729                    title: Some("Cross Window Thread".into()),
6730                    updated_at: None,
6731                    created_at: None,
6732                    meta: None,
6733                },
6734                window,
6735                cx,
6736            );
6737        });
6738        cx_a.run_until_parked();
6739
6740        assert_eq!(
6741            multi_workspace_a
6742                .read_with(cx_a, |mw, _| mw.workspaces().len())
6743                .unwrap(),
6744            1,
6745            "should not add the other window's workspace into the current window"
6746        );
6747        assert_eq!(
6748            multi_workspace_b
6749                .read_with(cx_a, |mw, _| mw.workspaces().len())
6750                .unwrap(),
6751            1,
6752            "should reuse the existing workspace in the other window"
6753        );
6754        assert!(
6755            cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
6756            "should activate the window that already owns the matching workspace"
6757        );
6758        sidebar.read_with(cx_a, |sidebar, _| {
6759            assert_eq!(
6760                sidebar.focused_thread, None,
6761                "source window's sidebar should not eagerly claim focus for a thread opened in another window"
6762            );
6763        });
6764    }
6765
6766    #[gpui::test]
6767    async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_target_sidebar(
6768        cx: &mut TestAppContext,
6769    ) {
6770        init_test(cx);
6771        let fs = FakeFs::new(cx.executor());
6772        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6773            .await;
6774        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
6775            .await;
6776        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6777
6778        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6779        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
6780
6781        let multi_workspace_a =
6782            cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6783        let multi_workspace_b =
6784            cx.add_window(|window, cx| MultiWorkspace::test_new(project_b.clone(), window, cx));
6785
6786        let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
6787        let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap();
6788
6789        let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
6790        let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
6791
6792        let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx);
6793        let sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b);
6794        let workspace_b = multi_workspace_b_entity.read_with(cx_b, |mw, _| mw.workspace().clone());
6795        let _panel_b = add_agent_panel(&workspace_b, &project_b, cx_b);
6796
6797        let session_id = acp::SessionId::new(Arc::from("archived-cross-window-with-sidebar"));
6798
6799        sidebar_a.update_in(cx_a, |sidebar, window, cx| {
6800            sidebar.activate_archived_thread(
6801                Agent::NativeAgent,
6802                acp_thread::AgentSessionInfo {
6803                    session_id: session_id.clone(),
6804                    work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])),
6805                    title: Some("Cross Window Thread".into()),
6806                    updated_at: None,
6807                    created_at: None,
6808                    meta: None,
6809                },
6810                window,
6811                cx,
6812            );
6813        });
6814        cx_a.run_until_parked();
6815
6816        assert_eq!(
6817            multi_workspace_a
6818                .read_with(cx_a, |mw, _| mw.workspaces().len())
6819                .unwrap(),
6820            1,
6821            "should not add the other window's workspace into the current window"
6822        );
6823        assert_eq!(
6824            multi_workspace_b
6825                .read_with(cx_a, |mw, _| mw.workspaces().len())
6826                .unwrap(),
6827            1,
6828            "should reuse the existing workspace in the other window"
6829        );
6830        assert!(
6831            cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b,
6832            "should activate the window that already owns the matching workspace"
6833        );
6834        sidebar_a.read_with(cx_a, |sidebar, _| {
6835            assert_eq!(
6836                sidebar.focused_thread, None,
6837                "source window's sidebar should not eagerly claim focus for a thread opened in another window"
6838            );
6839        });
6840        sidebar_b.read_with(cx_b, |sidebar, _| {
6841            assert_eq!(
6842                sidebar.focused_thread.as_ref(),
6843                Some(&session_id),
6844                "target window's sidebar should eagerly focus the activated archived thread"
6845            );
6846        });
6847    }
6848
6849    #[gpui::test]
6850    async fn test_activate_archived_thread_prefers_current_window_for_matching_paths(
6851        cx: &mut TestAppContext,
6852    ) {
6853        init_test(cx);
6854        let fs = FakeFs::new(cx.executor());
6855        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
6856            .await;
6857        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6858
6859        let project_b = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6860        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
6861
6862        let multi_workspace_b =
6863            cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx));
6864        let multi_workspace_a =
6865            cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
6866
6867        let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap();
6868
6869        let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx);
6870        let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a);
6871
6872        let session_id = acp::SessionId::new(Arc::from("archived-current-window"));
6873
6874        sidebar_a.update_in(cx_a, |sidebar, window, cx| {
6875            sidebar.activate_archived_thread(
6876                Agent::NativeAgent,
6877                acp_thread::AgentSessionInfo {
6878                    session_id: session_id.clone(),
6879                    work_dirs: Some(PathList::new(&[PathBuf::from("/project-a")])),
6880                    title: Some("Current Window Thread".into()),
6881                    updated_at: None,
6882                    created_at: None,
6883                    meta: None,
6884                },
6885                window,
6886                cx,
6887            );
6888        });
6889        cx_a.run_until_parked();
6890
6891        assert!(
6892            cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_a,
6893            "should keep activation in the current window when it already has a matching workspace"
6894        );
6895        sidebar_a.read_with(cx_a, |sidebar, _| {
6896            assert_eq!(
6897                sidebar.focused_thread.as_ref(),
6898                Some(&session_id),
6899                "current window's sidebar should eagerly focus the activated archived thread"
6900            );
6901        });
6902        assert_eq!(
6903            multi_workspace_a
6904                .read_with(cx_a, |mw, _| mw.workspaces().len())
6905                .unwrap(),
6906            1,
6907            "current window should continue reusing its existing workspace"
6908        );
6909        assert_eq!(
6910            multi_workspace_b
6911                .read_with(cx_a, |mw, _| mw.workspaces().len())
6912                .unwrap(),
6913            1,
6914            "other windows should not be activated just because they also match the saved paths"
6915        );
6916    }
6917
6918    #[gpui::test]
6919    async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppContext) {
6920        // Regression test: archive_thread previously always loaded the next thread
6921        // through group_workspace (the main workspace's ProjectHeader), even when
6922        // the next thread belonged to an absorbed linked-worktree workspace. That
6923        // caused the worktree thread to be loaded in the main panel, which bound it
6924        // to the main project and corrupted its stored folder_paths.
6925        //
6926        // The fix: use next.workspace (ThreadEntryWorkspace::Open) when available,
6927        // falling back to group_workspace only for Closed workspaces.
6928        agent_ui::test_support::init_test(cx);
6929        cx.update(|cx| {
6930            cx.update_flags(false, vec!["agent-v2".into()]);
6931            ThreadStore::init_global(cx);
6932            SidebarThreadMetadataStore::init_global(cx);
6933            language_model::LanguageModelRegistry::test(cx);
6934            prompt_store::init(cx);
6935        });
6936
6937        let fs = FakeFs::new(cx.executor());
6938
6939        fs.insert_tree(
6940            "/project",
6941            serde_json::json!({
6942                ".git": {
6943                    "worktrees": {
6944                        "feature-a": {
6945                            "commondir": "../../",
6946                            "HEAD": "ref: refs/heads/feature-a",
6947                        },
6948                    },
6949                },
6950                "src": {},
6951            }),
6952        )
6953        .await;
6954
6955        fs.insert_tree(
6956            "/wt-feature-a",
6957            serde_json::json!({
6958                ".git": "gitdir: /project/.git/worktrees/feature-a",
6959                "src": {},
6960            }),
6961        )
6962        .await;
6963
6964        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
6965            state.worktrees.push(git::repository::Worktree {
6966                path: std::path::PathBuf::from("/wt-feature-a"),
6967                ref_name: Some("refs/heads/feature-a".into()),
6968                sha: "aaa".into(),
6969            });
6970        })
6971        .unwrap();
6972
6973        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
6974
6975        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
6976        let worktree_project =
6977            project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
6978
6979        main_project
6980            .update(cx, |p, cx| p.git_scans_complete(cx))
6981            .await;
6982        worktree_project
6983            .update(cx, |p, cx| p.git_scans_complete(cx))
6984            .await;
6985
6986        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
6987            MultiWorkspace::test_new(main_project.clone(), window, cx)
6988        });
6989
6990        let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
6991            mw.test_add_workspace(worktree_project.clone(), window, cx)
6992        });
6993
6994        // Activate main workspace so the sidebar tracks the main panel.
6995        multi_workspace.update_in(cx, |mw, window, cx| {
6996            mw.activate_index(0, window, cx);
6997        });
6998
6999        let sidebar = setup_sidebar(&multi_workspace, cx);
7000
7001        let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspaces()[0].clone());
7002        let main_panel = add_agent_panel(&main_workspace, &main_project, cx);
7003        let _worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx);
7004
7005        // Open Thread 2 in the main panel and keep it running.
7006        let connection = StubAgentConnection::new();
7007        open_thread_with_connection(&main_panel, connection.clone(), cx);
7008        send_message(&main_panel, cx);
7009
7010        let thread2_session_id = active_session_id(&main_panel, cx);
7011
7012        cx.update(|_, cx| {
7013            connection.send_update(
7014                thread2_session_id.clone(),
7015                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
7016                cx,
7017            );
7018        });
7019
7020        // Save thread 2's metadata with a newer timestamp so it sorts above thread 1.
7021        save_thread_metadata(
7022            thread2_session_id.clone(),
7023            "Thread 2".into(),
7024            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
7025            PathList::new(&[std::path::PathBuf::from("/project")]),
7026            cx,
7027        )
7028        .await;
7029
7030        // Save thread 1's metadata with the worktree path and an older timestamp so
7031        // it sorts below thread 2. archive_thread will find it as the "next" candidate.
7032        let thread1_session_id = acp::SessionId::new(Arc::from("thread1-worktree-session"));
7033        save_thread_metadata(
7034            thread1_session_id.clone(),
7035            "Thread 1".into(),
7036            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
7037            PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
7038            cx,
7039        )
7040        .await;
7041
7042        cx.run_until_parked();
7043
7044        // Verify the sidebar absorbed thread 1 under [project] with the worktree chip.
7045        let entries_before = visible_entries_as_strings(&sidebar, cx);
7046        assert!(
7047            entries_before.iter().any(|s| s.contains("{wt-feature-a}")),
7048            "Thread 1 should appear with the linked-worktree chip before archiving: {:?}",
7049            entries_before
7050        );
7051
7052        // The sidebar should track T2 as the focused thread (derived from the
7053        // main panel's active view).
7054        let focused = sidebar.read_with(cx, |s, _| s.focused_thread.clone());
7055        assert_eq!(
7056            focused,
7057            Some(thread2_session_id.clone()),
7058            "focused thread should be Thread 2 before archiving: {:?}",
7059            focused
7060        );
7061
7062        // Archive thread 2.
7063        sidebar.update_in(cx, |sidebar, window, cx| {
7064            sidebar.archive_thread(&thread2_session_id, window, cx);
7065        });
7066
7067        cx.run_until_parked();
7068
7069        // The main panel's active thread must still be thread 2.
7070        let main_active = main_panel.read_with(cx, |panel, cx| {
7071            panel
7072                .active_agent_thread(cx)
7073                .map(|t| t.read(cx).session_id().clone())
7074        });
7075        assert_eq!(
7076            main_active,
7077            Some(thread2_session_id.clone()),
7078            "main panel should not have been taken over by loading the linked-worktree thread T1; \
7079             before the fix, archive_thread used group_workspace instead of next.workspace, \
7080             causing T1 to be loaded in the wrong panel"
7081        );
7082
7083        // Thread 1 should still appear in the sidebar with its worktree chip
7084        // (Thread 2 was archived so it is gone from the list).
7085        let entries_after = visible_entries_as_strings(&sidebar, cx);
7086        assert!(
7087            entries_after.iter().any(|s| s.contains("{wt-feature-a}")),
7088            "T1 should still carry its linked-worktree chip after archiving T2: {:?}",
7089            entries_after
7090        );
7091    }
7092}