sidebar.rs

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