sidebar.rs

   1use crate::threads_archive_view::{ThreadsArchiveView, ThreadsArchiveViewEvent};
   2use crate::{Agent, AgentPanel, AgentPanelEvent, NewThread};
   3use acp_thread::ThreadStatus;
   4use action_log::DiffStats;
   5use agent::ThreadStore;
   6use agent_client_protocol as acp;
   7use agent_settings::AgentSettings;
   8use chrono::Utc;
   9use db::kvp::KEY_VALUE_STORE;
  10use editor::Editor;
  11use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _};
  12use gpui::{
  13    Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, ListState, Pixels,
  14    Render, SharedString, WeakEntity, Window, actions, list, prelude::*, px,
  15};
  16use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
  17use project::Event as ProjectEvent;
  18use settings::Settings;
  19use std::collections::{HashMap, HashSet};
  20use std::mem;
  21use std::path::Path;
  22use std::sync::Arc;
  23use theme::ActiveTheme;
  24use ui::{
  25    AgentThreadStatus, ButtonStyle, HighlightedLabel, IconButtonShape, ListItem, Tab, ThreadItem,
  26    Tooltip, WithScrollbar, prelude::*,
  27};
  28use util::ResultExt as _;
  29use util::path_list::PathList;
  30use workspace::{
  31    MultiWorkspace, MultiWorkspaceEvent, ToggleWorkspaceSidebar, Workspace, multi_workspace_enabled,
  32};
  33use zed_actions::editor::{MoveDown, MoveUp};
  34
  35actions!(
  36    agents_sidebar,
  37    [
  38        /// Collapses the selected entry in the workspace sidebar.
  39        CollapseSelectedEntry,
  40        /// Expands the selected entry in the workspace sidebar.
  41        ExpandSelectedEntry,
  42    ]
  43);
  44
  45const DEFAULT_WIDTH: Pixels = px(320.0);
  46const MIN_WIDTH: Pixels = px(200.0);
  47const MAX_WIDTH: Pixels = px(800.0);
  48const DEFAULT_THREADS_SHOWN: usize = 5;
  49const SIDEBAR_STATE_KEY: &str = "sidebar_state";
  50
  51#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
  52enum SidebarView {
  53    #[default]
  54    ThreadList,
  55    Archive,
  56}
  57
  58fn read_sidebar_open_state(multi_workspace_id: u64) -> bool {
  59    KEY_VALUE_STORE
  60        .scoped(SIDEBAR_STATE_KEY)
  61        .read(&multi_workspace_id.to_string())
  62        .log_err()
  63        .flatten()
  64        .and_then(|json| serde_json::from_str::<bool>(&json).ok())
  65        .unwrap_or(false)
  66}
  67
  68async fn save_sidebar_open_state(multi_workspace_id: u64, is_open: bool) {
  69    if let Ok(json) = serde_json::to_string(&is_open) {
  70        KEY_VALUE_STORE
  71            .scoped(SIDEBAR_STATE_KEY)
  72            .write(multi_workspace_id.to_string(), json)
  73            .await
  74            .log_err();
  75    }
  76}
  77
  78#[derive(Clone, Debug)]
  79struct ActiveThreadInfo {
  80    session_id: acp::SessionId,
  81    title: SharedString,
  82    status: AgentThreadStatus,
  83    icon: IconName,
  84    icon_from_external_svg: Option<SharedString>,
  85    is_background: bool,
  86    diff_stats: DiffStats,
  87}
  88
  89impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo {
  90    fn from(info: &ActiveThreadInfo) -> Self {
  91        Self {
  92            session_id: info.session_id.clone(),
  93            cwd: None,
  94            title: Some(info.title.clone()),
  95            updated_at: Some(Utc::now()),
  96            created_at: Some(Utc::now()),
  97            meta: None,
  98        }
  99    }
 100}
 101
 102#[derive(Clone)]
 103enum ThreadEntryWorkspace {
 104    Open(Entity<Workspace>),
 105    Closed(PathList),
 106}
 107
 108#[derive(Clone)]
 109struct ThreadEntry {
 110    agent: Agent,
 111    session_info: acp_thread::AgentSessionInfo,
 112    icon: IconName,
 113    icon_from_external_svg: Option<SharedString>,
 114    status: AgentThreadStatus,
 115    workspace: ThreadEntryWorkspace,
 116    is_live: bool,
 117    is_background: bool,
 118    highlight_positions: Vec<usize>,
 119    worktree_name: Option<SharedString>,
 120    worktree_highlight_positions: Vec<usize>,
 121    diff_stats: DiffStats,
 122}
 123
 124#[derive(Clone)]
 125enum ListEntry {
 126    ProjectHeader {
 127        path_list: PathList,
 128        label: SharedString,
 129        workspace: Entity<Workspace>,
 130        highlight_positions: Vec<usize>,
 131        has_threads: bool,
 132    },
 133    Thread(ThreadEntry),
 134    ViewMore {
 135        path_list: PathList,
 136        remaining_count: usize,
 137        is_fully_expanded: bool,
 138    },
 139    NewThread {
 140        path_list: PathList,
 141        workspace: Entity<Workspace>,
 142    },
 143}
 144
 145impl From<ThreadEntry> for ListEntry {
 146    fn from(thread: ThreadEntry) -> Self {
 147        ListEntry::Thread(thread)
 148    }
 149}
 150
 151#[derive(Default)]
 152struct SidebarContents {
 153    entries: Vec<ListEntry>,
 154    notified_threads: HashSet<acp::SessionId>,
 155    project_header_indices: Vec<usize>,
 156}
 157
 158impl SidebarContents {
 159    fn is_thread_notified(&self, session_id: &acp::SessionId) -> bool {
 160        self.notified_threads.contains(session_id)
 161    }
 162}
 163
 164fn fuzzy_match_positions(query: &str, candidate: &str) -> Option<Vec<usize>> {
 165    let mut positions = Vec::new();
 166    let mut query_chars = query.chars().peekable();
 167
 168    for (byte_idx, candidate_char) in candidate.char_indices() {
 169        if let Some(&query_char) = query_chars.peek() {
 170            if candidate_char.eq_ignore_ascii_case(&query_char) {
 171                positions.push(byte_idx);
 172                query_chars.next();
 173            }
 174        } else {
 175            break;
 176        }
 177    }
 178
 179    if query_chars.peek().is_none() {
 180        Some(positions)
 181    } else {
 182        None
 183    }
 184}
 185
 186// TODO: The mapping from workspace root paths to git repositories needs a
 187// unified approach across the codebase: this function, `AgentPanel::classify_worktrees`,
 188// thread persistence (which PathList is saved to the database), and thread
 189// querying (which PathList is used to read threads back). All of these need
 190// to agree on how repos are resolved for a given workspace, especially in
 191// multi-root and nested-repo configurations.
 192fn root_repository_snapshots(
 193    workspace: &Entity<Workspace>,
 194    cx: &App,
 195) -> Vec<project::git_store::RepositorySnapshot> {
 196    let path_list = workspace_path_list(workspace, cx);
 197    let project = workspace.read(cx).project().read(cx);
 198    project
 199        .repositories(cx)
 200        .values()
 201        .filter_map(|repo| {
 202            let snapshot = repo.read(cx).snapshot();
 203            let is_root = path_list
 204                .paths()
 205                .iter()
 206                .any(|p| p.as_path() == snapshot.work_directory_abs_path.as_ref());
 207            is_root.then_some(snapshot)
 208        })
 209        .collect()
 210}
 211
 212fn workspace_path_list(workspace: &Entity<Workspace>, cx: &App) -> PathList {
 213    PathList::new(&workspace.read(cx).root_paths(cx))
 214}
 215
 216fn workspace_label_from_path_list(path_list: &PathList) -> SharedString {
 217    let mut names = Vec::with_capacity(path_list.paths().len());
 218    for abs_path in path_list.paths() {
 219        if let Some(name) = abs_path.file_name() {
 220            names.push(name.to_string_lossy().to_string());
 221        }
 222    }
 223    if names.is_empty() {
 224        // TODO: Can we do something better in this case?
 225        "Empty Workspace".into()
 226    } else {
 227        names.join(", ").into()
 228    }
 229}
 230
 231pub struct Sidebar {
 232    multi_workspace: WeakEntity<MultiWorkspace>,
 233    persistence_key: Option<u64>,
 234    is_open: bool,
 235    width: Pixels,
 236    focus_handle: FocusHandle,
 237    filter_editor: Entity<Editor>,
 238    list_state: ListState,
 239    contents: SidebarContents,
 240    /// The index of the list item that currently has the keyboard focus
 241    ///
 242    /// Note: This is NOT the same as the active item.
 243    selection: Option<usize>,
 244    focused_thread: Option<acp::SessionId>,
 245    active_entry_index: Option<usize>,
 246    collapsed_groups: HashSet<PathList>,
 247    expanded_groups: HashMap<PathList, usize>,
 248    view: SidebarView,
 249    archive_view: Option<Entity<ThreadsArchiveView>>,
 250    _subscriptions: Vec<gpui::Subscription>,
 251}
 252
 253impl Sidebar {
 254    pub fn new(
 255        multi_workspace: Entity<MultiWorkspace>,
 256        window: &mut Window,
 257        cx: &mut Context<Self>,
 258    ) -> Self {
 259        let focus_handle = cx.focus_handle();
 260        cx.on_focus_in(&focus_handle, window, Self::focus_in)
 261            .detach();
 262
 263        let filter_editor = cx.new(|cx| {
 264            let mut editor = Editor::single_line(window, cx);
 265            editor.set_placeholder_text("Search…", window, cx);
 266            editor
 267        });
 268
 269        cx.subscribe_in(
 270            &multi_workspace,
 271            window,
 272            |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
 273                MultiWorkspaceEvent::ActiveWorkspaceChanged => {
 274                    this.update_entries(cx);
 275                }
 276                MultiWorkspaceEvent::WorkspaceAdded(workspace) => {
 277                    this.subscribe_to_workspace(workspace, window, cx);
 278                    this.update_entries(cx);
 279                }
 280                MultiWorkspaceEvent::WorkspaceRemoved(_) => {
 281                    this.update_entries(cx);
 282                }
 283            },
 284        )
 285        .detach();
 286
 287        cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
 288            if let editor::EditorEvent::BufferEdited = event {
 289                let query = this.filter_editor.read(cx).text(cx);
 290                if !query.is_empty() {
 291                    this.selection.take();
 292                }
 293                this.update_entries(cx);
 294                if !query.is_empty() {
 295                    this.selection = this
 296                        .contents
 297                        .entries
 298                        .iter()
 299                        .position(|entry| matches!(entry, ListEntry::Thread(_)))
 300                        .or_else(|| {
 301                            if this.contents.entries.is_empty() {
 302                                None
 303                            } else {
 304                                Some(0)
 305                            }
 306                        });
 307                }
 308            }
 309        })
 310        .detach();
 311
 312        let thread_store = ThreadStore::global(cx);
 313        cx.observe_in(&thread_store, window, |this, _, _window, cx| {
 314            this.update_entries(cx);
 315        })
 316        .detach();
 317
 318        cx.observe_flag::<AgentV2FeatureFlag, _>(window, |_is_enabled, this, _window, cx| {
 319            this.update_entries(cx);
 320        })
 321        .detach();
 322
 323        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
 324        cx.defer_in(window, move |this, window, cx| {
 325            for workspace in &workspaces {
 326                this.subscribe_to_workspace(workspace, window, cx);
 327            }
 328            this.update_entries(cx);
 329        });
 330
 331        let persistence_key = multi_workspace.read(cx).database_id().map(|id| id.0);
 332        let is_open = persistence_key
 333            .map(read_sidebar_open_state)
 334            .unwrap_or(false);
 335
 336        Self {
 337            multi_workspace: multi_workspace.downgrade(),
 338            persistence_key,
 339            is_open,
 340            width: DEFAULT_WIDTH,
 341            focus_handle,
 342            filter_editor,
 343            list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
 344            contents: SidebarContents::default(),
 345            selection: None,
 346            focused_thread: None,
 347            active_entry_index: None,
 348            collapsed_groups: HashSet::new(),
 349            expanded_groups: HashMap::new(),
 350            view: SidebarView::default(),
 351            archive_view: None,
 352            _subscriptions: Vec::new(),
 353        }
 354    }
 355
 356    fn subscribe_to_workspace(
 357        &self,
 358        workspace: &Entity<Workspace>,
 359        window: &mut Window,
 360        cx: &mut Context<Self>,
 361    ) {
 362        let project = workspace.read(cx).project().clone();
 363        cx.subscribe_in(
 364            &project,
 365            window,
 366            |this, _project, event, _window, cx| match event {
 367                ProjectEvent::WorktreeAdded(_)
 368                | ProjectEvent::WorktreeRemoved(_)
 369                | ProjectEvent::WorktreeOrderChanged => {
 370                    this.update_entries(cx);
 371                }
 372                _ => {}
 373            },
 374        )
 375        .detach();
 376
 377        let git_store = workspace.read(cx).project().read(cx).git_store().clone();
 378        cx.subscribe_in(
 379            &git_store,
 380            window,
 381            |this, _, event: &project::git_store::GitStoreEvent, window, cx| {
 382                if matches!(
 383                    event,
 384                    project::git_store::GitStoreEvent::RepositoryUpdated(
 385                        _,
 386                        project::git_store::RepositoryEvent::GitWorktreeListChanged,
 387                        _,
 388                    )
 389                ) {
 390                    this.prune_stale_worktree_workspaces(window, cx);
 391                    this.update_entries(cx);
 392                }
 393            },
 394        )
 395        .detach();
 396
 397        cx.subscribe_in(
 398            workspace,
 399            window,
 400            |this, _workspace, event: &workspace::Event, window, cx| {
 401                if let workspace::Event::PanelAdded(view) = event {
 402                    if let Ok(agent_panel) = view.clone().downcast::<AgentPanel>() {
 403                        this.subscribe_to_agent_panel(&agent_panel, window, cx);
 404                    }
 405                }
 406            },
 407        )
 408        .detach();
 409
 410        if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
 411            self.subscribe_to_agent_panel(&agent_panel, window, cx);
 412        }
 413    }
 414
 415    fn subscribe_to_agent_panel(
 416        &self,
 417        agent_panel: &Entity<AgentPanel>,
 418        window: &mut Window,
 419        cx: &mut Context<Self>,
 420    ) {
 421        cx.subscribe_in(
 422            agent_panel,
 423            window,
 424            |this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event {
 425                AgentPanelEvent::ActiveViewChanged
 426                | AgentPanelEvent::ThreadFocused
 427                | AgentPanelEvent::BackgroundThreadChanged => {
 428                    this.update_entries(cx);
 429                }
 430            },
 431        )
 432        .detach();
 433    }
 434
 435    fn all_thread_infos_for_workspace(
 436        workspace: &Entity<Workspace>,
 437        cx: &App,
 438    ) -> Vec<ActiveThreadInfo> {
 439        let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
 440            return Vec::new();
 441        };
 442        let agent_panel_ref = agent_panel.read(cx);
 443
 444        agent_panel_ref
 445            .parent_threads(cx)
 446            .into_iter()
 447            .map(|thread_view| {
 448                let thread_view_ref = thread_view.read(cx);
 449                let thread = thread_view_ref.thread.read(cx);
 450
 451                let icon = thread_view_ref.agent_icon;
 452                let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone();
 453                let title = thread.title();
 454                let session_id = thread.session_id().clone();
 455                let is_background = agent_panel_ref.is_background_thread(&session_id);
 456
 457                let status = if thread.is_waiting_for_confirmation() {
 458                    AgentThreadStatus::WaitingForConfirmation
 459                } else if thread.had_error() {
 460                    AgentThreadStatus::Error
 461                } else {
 462                    match thread.status() {
 463                        ThreadStatus::Generating => AgentThreadStatus::Running,
 464                        ThreadStatus::Idle => AgentThreadStatus::Completed,
 465                    }
 466                };
 467
 468                let diff_stats = thread.action_log().read(cx).diff_stats(cx);
 469
 470                ActiveThreadInfo {
 471                    session_id,
 472                    title,
 473                    status,
 474                    icon,
 475                    icon_from_external_svg,
 476                    is_background,
 477                    diff_stats,
 478                }
 479            })
 480            .collect()
 481    }
 482
 483    fn rebuild_contents(&mut self, cx: &App) {
 484        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
 485            return;
 486        };
 487        let mw = multi_workspace.read(cx);
 488        let workspaces = mw.workspaces().to_vec();
 489        let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned();
 490
 491        self.focused_thread = active_workspace
 492            .as_ref()
 493            .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
 494            .and_then(|panel| panel.read(cx).active_connection_view().cloned())
 495            .and_then(|cv| cv.read(cx).parent_id(cx));
 496
 497        let thread_store = ThreadStore::try_global(cx);
 498        let query = self.filter_editor.read(cx).text(cx);
 499
 500        let previous = mem::take(&mut self.contents);
 501
 502        let old_statuses: HashMap<acp::SessionId, AgentThreadStatus> = previous
 503            .entries
 504            .iter()
 505            .filter_map(|entry| match entry {
 506                ListEntry::Thread(thread) if thread.is_live => {
 507                    Some((thread.session_info.session_id.clone(), thread.status))
 508                }
 509                _ => None,
 510            })
 511            .collect();
 512
 513        let mut entries = Vec::new();
 514        let mut notified_threads = previous.notified_threads;
 515        // Track all session IDs we add to entries so we can prune stale
 516        // notifications without a separate pass at the end.
 517        let mut current_session_ids: HashSet<acp::SessionId> = HashSet::new();
 518        // Compute active_entry_index inline during the build pass.
 519        let mut active_entry_index: Option<usize> = None;
 520
 521        // Identify absorbed workspaces in a single pass. A workspace is
 522        // "absorbed" when it points at a git worktree checkout whose main
 523        // repo is open as another workspace — its threads appear under the
 524        // main repo's header instead of getting their own.
 525        let mut main_repo_workspace: HashMap<Arc<Path>, usize> = HashMap::new();
 526        let mut absorbed: HashMap<usize, (usize, SharedString)> = HashMap::new();
 527        let mut pending: HashMap<Arc<Path>, Vec<(usize, SharedString, Arc<Path>)>> = HashMap::new();
 528        let mut absorbed_workspace_by_path: HashMap<Arc<Path>, usize> = HashMap::new();
 529
 530        for (i, workspace) in workspaces.iter().enumerate() {
 531            for snapshot in root_repository_snapshots(workspace, cx) {
 532                if snapshot.work_directory_abs_path == snapshot.original_repo_abs_path {
 533                    main_repo_workspace
 534                        .entry(snapshot.work_directory_abs_path.clone())
 535                        .or_insert(i);
 536                    if let Some(waiting) = pending.remove(&snapshot.work_directory_abs_path) {
 537                        for (ws_idx, name, ws_path) in waiting {
 538                            absorbed.insert(ws_idx, (i, name));
 539                            absorbed_workspace_by_path.insert(ws_path, ws_idx);
 540                        }
 541                    }
 542                } else {
 543                    let name: SharedString = snapshot
 544                        .work_directory_abs_path
 545                        .file_name()
 546                        .unwrap_or_default()
 547                        .to_string_lossy()
 548                        .to_string()
 549                        .into();
 550                    if let Some(&main_idx) =
 551                        main_repo_workspace.get(&snapshot.original_repo_abs_path)
 552                    {
 553                        absorbed.insert(i, (main_idx, name));
 554                        absorbed_workspace_by_path
 555                            .insert(snapshot.work_directory_abs_path.clone(), i);
 556                    } else {
 557                        pending
 558                            .entry(snapshot.original_repo_abs_path.clone())
 559                            .or_default()
 560                            .push((i, name, snapshot.work_directory_abs_path.clone()));
 561                    }
 562                }
 563            }
 564        }
 565
 566        for (ws_index, workspace) in workspaces.iter().enumerate() {
 567            if absorbed.contains_key(&ws_index) {
 568                continue;
 569            }
 570
 571            let path_list = workspace_path_list(workspace, cx);
 572            let label = workspace_label_from_path_list(&path_list);
 573
 574            let is_collapsed = self.collapsed_groups.contains(&path_list);
 575            let should_load_threads = !is_collapsed || !query.is_empty();
 576
 577            let mut threads: Vec<ThreadEntry> = Vec::new();
 578
 579            if should_load_threads {
 580                let mut seen_session_ids: HashSet<acp::SessionId> = HashSet::new();
 581
 582                if let Some(ref thread_store) = thread_store {
 583                    for meta in thread_store.read(cx).threads_for_paths(&path_list) {
 584                        seen_session_ids.insert(meta.id.clone());
 585                        threads.push(ThreadEntry {
 586                            agent: Agent::NativeAgent,
 587                            session_info: meta.into(),
 588                            icon: IconName::ZedAgent,
 589                            icon_from_external_svg: None,
 590                            status: AgentThreadStatus::default(),
 591                            workspace: ThreadEntryWorkspace::Open(workspace.clone()),
 592                            is_live: false,
 593                            is_background: false,
 594                            highlight_positions: Vec::new(),
 595                            worktree_name: None,
 596                            worktree_highlight_positions: Vec::new(),
 597                            diff_stats: DiffStats::default(),
 598                        });
 599                    }
 600                }
 601
 602                // Load threads from linked git worktrees of this workspace's repos.
 603                if let Some(ref thread_store) = thread_store {
 604                    let mut linked_worktree_queries: Vec<(PathList, SharedString, Arc<Path>)> =
 605                        Vec::new();
 606                    for snapshot in root_repository_snapshots(workspace, cx) {
 607                        if snapshot.work_directory_abs_path != snapshot.original_repo_abs_path {
 608                            continue;
 609                        }
 610                        for git_worktree in snapshot.linked_worktrees() {
 611                            let name = git_worktree
 612                                .path
 613                                .file_name()
 614                                .unwrap_or_default()
 615                                .to_string_lossy()
 616                                .to_string();
 617                            linked_worktree_queries.push((
 618                                PathList::new(std::slice::from_ref(&git_worktree.path)),
 619                                name.into(),
 620                                Arc::from(git_worktree.path.as_path()),
 621                            ));
 622                        }
 623                    }
 624
 625                    for (worktree_path_list, worktree_name, worktree_path) in
 626                        &linked_worktree_queries
 627                    {
 628                        let target_workspace =
 629                            match absorbed_workspace_by_path.get(worktree_path.as_ref()) {
 630                                Some(&idx) => ThreadEntryWorkspace::Open(workspaces[idx].clone()),
 631                                None => ThreadEntryWorkspace::Closed(worktree_path_list.clone()),
 632                            };
 633
 634                        for meta in thread_store.read(cx).threads_for_paths(worktree_path_list) {
 635                            if !seen_session_ids.insert(meta.id.clone()) {
 636                                continue;
 637                            }
 638                            threads.push(ThreadEntry {
 639                                agent: Agent::NativeAgent,
 640                                session_info: meta.into(),
 641                                icon: IconName::ZedAgent,
 642                                icon_from_external_svg: None,
 643                                status: AgentThreadStatus::default(),
 644                                workspace: target_workspace.clone(),
 645                                is_live: false,
 646                                is_background: false,
 647                                highlight_positions: Vec::new(),
 648                                worktree_name: Some(worktree_name.clone()),
 649                                worktree_highlight_positions: Vec::new(),
 650                                diff_stats: DiffStats::default(),
 651                            });
 652                        }
 653                    }
 654                }
 655
 656                let live_infos = Self::all_thread_infos_for_workspace(workspace, cx);
 657
 658                if !live_infos.is_empty() {
 659                    let thread_index_by_session: HashMap<acp::SessionId, usize> = threads
 660                        .iter()
 661                        .enumerate()
 662                        .map(|(i, t)| (t.session_info.session_id.clone(), i))
 663                        .collect();
 664
 665                    for info in &live_infos {
 666                        let Some(&idx) = thread_index_by_session.get(&info.session_id) else {
 667                            continue;
 668                        };
 669
 670                        let thread = &mut threads[idx];
 671                        thread.session_info.title = Some(info.title.clone());
 672                        thread.status = info.status;
 673                        thread.icon = info.icon;
 674                        thread.icon_from_external_svg = info.icon_from_external_svg.clone();
 675                        thread.is_live = true;
 676                        thread.is_background = info.is_background;
 677                        thread.diff_stats = info.diff_stats;
 678                    }
 679                }
 680
 681                // Update notification state for live threads in the same pass.
 682                let is_active_workspace = active_workspace
 683                    .as_ref()
 684                    .is_some_and(|active| active == workspace);
 685
 686                for thread in &threads {
 687                    let session_id = &thread.session_info.session_id;
 688                    if thread.is_background && thread.status == AgentThreadStatus::Completed {
 689                        notified_threads.insert(session_id.clone());
 690                    } else if thread.status == AgentThreadStatus::Completed
 691                        && !is_active_workspace
 692                        && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running)
 693                    {
 694                        notified_threads.insert(session_id.clone());
 695                    }
 696
 697                    if is_active_workspace && !thread.is_background {
 698                        notified_threads.remove(session_id);
 699                    }
 700                }
 701
 702                // Sort by created_at (newest first), falling back to updated_at
 703                // for threads without a created_at (e.g., ACP sessions).
 704                threads.sort_by(|a, b| {
 705                    let a_time = a.session_info.created_at.or(a.session_info.updated_at);
 706                    let b_time = b.session_info.created_at.or(b.session_info.updated_at);
 707                    b_time.cmp(&a_time)
 708                });
 709            }
 710
 711            if !query.is_empty() {
 712                let has_threads = !threads.is_empty();
 713
 714                let workspace_highlight_positions =
 715                    fuzzy_match_positions(&query, &label).unwrap_or_default();
 716                let workspace_matched = !workspace_highlight_positions.is_empty();
 717
 718                let mut matched_threads: Vec<ThreadEntry> = Vec::new();
 719                for mut thread in threads {
 720                    let title = thread
 721                        .session_info
 722                        .title
 723                        .as_ref()
 724                        .map(|s| s.as_ref())
 725                        .unwrap_or("");
 726                    if let Some(positions) = fuzzy_match_positions(&query, title) {
 727                        thread.highlight_positions = positions;
 728                    }
 729                    if let Some(worktree_name) = &thread.worktree_name {
 730                        if let Some(positions) = fuzzy_match_positions(&query, worktree_name) {
 731                            thread.worktree_highlight_positions = positions;
 732                        }
 733                    }
 734                    let worktree_matched = !thread.worktree_highlight_positions.is_empty();
 735                    if workspace_matched
 736                        || !thread.highlight_positions.is_empty()
 737                        || worktree_matched
 738                    {
 739                        matched_threads.push(thread);
 740                    }
 741                }
 742
 743                if matched_threads.is_empty() && !workspace_matched {
 744                    continue;
 745                }
 746
 747                if active_entry_index.is_none()
 748                    && self.focused_thread.is_none()
 749                    && active_workspace
 750                        .as_ref()
 751                        .is_some_and(|active| active == workspace)
 752                {
 753                    active_entry_index = Some(entries.len());
 754                }
 755
 756                entries.push(ListEntry::ProjectHeader {
 757                    path_list: path_list.clone(),
 758                    label,
 759                    workspace: workspace.clone(),
 760                    highlight_positions: workspace_highlight_positions,
 761                    has_threads,
 762                });
 763
 764                // Track session IDs and compute active_entry_index as we add
 765                // thread entries.
 766                for thread in matched_threads {
 767                    current_session_ids.insert(thread.session_info.session_id.clone());
 768                    if active_entry_index.is_none() {
 769                        if let Some(focused) = &self.focused_thread {
 770                            if &thread.session_info.session_id == focused {
 771                                active_entry_index = Some(entries.len());
 772                            }
 773                        }
 774                    }
 775                    entries.push(thread.into());
 776                }
 777            } else {
 778                let has_threads = !threads.is_empty();
 779
 780                // Check if this header is the active entry before pushing it.
 781                if active_entry_index.is_none()
 782                    && self.focused_thread.is_none()
 783                    && active_workspace
 784                        .as_ref()
 785                        .is_some_and(|active| active == workspace)
 786                {
 787                    active_entry_index = Some(entries.len());
 788                }
 789
 790                entries.push(ListEntry::ProjectHeader {
 791                    path_list: path_list.clone(),
 792                    label,
 793                    workspace: workspace.clone(),
 794                    highlight_positions: Vec::new(),
 795                    has_threads,
 796                });
 797
 798                if is_collapsed {
 799                    continue;
 800                }
 801
 802                let total = threads.len();
 803
 804                let extra_batches = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
 805                let threads_to_show =
 806                    DEFAULT_THREADS_SHOWN + (extra_batches * DEFAULT_THREADS_SHOWN);
 807                let count = threads_to_show.min(total);
 808                let is_fully_expanded = count >= total;
 809
 810                // Track session IDs and compute active_entry_index as we add
 811                // thread entries.
 812                for thread in threads.into_iter().take(count) {
 813                    current_session_ids.insert(thread.session_info.session_id.clone());
 814                    if active_entry_index.is_none() {
 815                        if let Some(focused) = &self.focused_thread {
 816                            if &thread.session_info.session_id == focused {
 817                                active_entry_index = Some(entries.len());
 818                            }
 819                        }
 820                    }
 821                    entries.push(thread.into());
 822                }
 823
 824                if total > DEFAULT_THREADS_SHOWN {
 825                    entries.push(ListEntry::ViewMore {
 826                        path_list: path_list.clone(),
 827                        remaining_count: total.saturating_sub(count),
 828                        is_fully_expanded,
 829                    });
 830                }
 831
 832                if total == 0 {
 833                    entries.push(ListEntry::NewThread {
 834                        path_list: path_list.clone(),
 835                        workspace: workspace.clone(),
 836                    });
 837                }
 838            }
 839        }
 840
 841        // Prune stale notifications using the session IDs we collected during
 842        // the build pass (no extra scan needed).
 843        notified_threads.retain(|id| current_session_ids.contains(id));
 844
 845        let project_header_indices = entries
 846            .iter()
 847            .enumerate()
 848            .filter_map(|(i, e)| matches!(e, ListEntry::ProjectHeader { .. }).then_some(i))
 849            .collect();
 850
 851        self.active_entry_index = active_entry_index;
 852        self.contents = SidebarContents {
 853            entries,
 854            notified_threads,
 855            project_header_indices,
 856        };
 857    }
 858
 859    fn update_entries(&mut self, cx: &mut Context<Self>) {
 860        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
 861            return;
 862        };
 863        if !multi_workspace_enabled(cx) {
 864            return;
 865        }
 866
 867        let had_notifications = self.has_notifications(cx);
 868
 869        let scroll_position = self.list_state.logical_scroll_top();
 870
 871        self.rebuild_contents(cx);
 872
 873        self.list_state.reset(self.contents.entries.len());
 874        self.list_state.scroll_to(scroll_position);
 875
 876        if had_notifications != self.has_notifications(cx) {
 877            multi_workspace.update(cx, |_, cx| {
 878                cx.notify();
 879            });
 880        }
 881
 882        cx.notify();
 883    }
 884
 885    fn render_list_entry(
 886        &mut self,
 887        ix: usize,
 888        window: &mut Window,
 889        cx: &mut Context<Self>,
 890    ) -> AnyElement {
 891        let Some(entry) = self.contents.entries.get(ix) else {
 892            return div().into_any_element();
 893        };
 894        let is_focused = self.focus_handle.is_focused(window)
 895            || self.filter_editor.focus_handle(cx).is_focused(window);
 896        // is_selected means the keyboard selector is here.
 897        let is_selected = is_focused && self.selection == Some(ix);
 898
 899        let is_group_header_after_first =
 900            ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. });
 901
 902        let docked_right = AgentSettings::get_global(cx).dock == settings::DockPosition::Right;
 903
 904        let rendered = match entry {
 905            ListEntry::ProjectHeader {
 906                path_list,
 907                label,
 908                workspace,
 909                highlight_positions,
 910                has_threads,
 911            } => self.render_project_header(
 912                ix,
 913                false,
 914                path_list,
 915                label,
 916                workspace,
 917                highlight_positions,
 918                *has_threads,
 919                is_selected,
 920                docked_right,
 921                cx,
 922            ),
 923            ListEntry::Thread(thread) => {
 924                self.render_thread(ix, thread, is_selected, docked_right, cx)
 925            }
 926            ListEntry::ViewMore {
 927                path_list,
 928                remaining_count,
 929                is_fully_expanded,
 930            } => self.render_view_more(
 931                ix,
 932                path_list,
 933                *remaining_count,
 934                *is_fully_expanded,
 935                is_selected,
 936                cx,
 937            ),
 938            ListEntry::NewThread {
 939                path_list,
 940                workspace,
 941            } => self.render_new_thread(ix, path_list, workspace, is_selected, cx),
 942        };
 943
 944        if is_group_header_after_first {
 945            v_flex()
 946                .w_full()
 947                .border_t_1()
 948                .border_color(cx.theme().colors().border_variant)
 949                .child(rendered)
 950                .into_any_element()
 951        } else {
 952            rendered
 953        }
 954    }
 955
 956    fn render_project_header(
 957        &self,
 958        ix: usize,
 959        is_sticky: bool,
 960        path_list: &PathList,
 961        label: &SharedString,
 962        workspace: &Entity<Workspace>,
 963        highlight_positions: &[usize],
 964        has_threads: bool,
 965        is_selected: bool,
 966        docked_right: bool,
 967        cx: &mut Context<Self>,
 968    ) -> AnyElement {
 969        let id_prefix = if is_sticky { "sticky-" } else { "" };
 970        let id = SharedString::from(format!("{id_prefix}project-header-{ix}"));
 971        let group_name = SharedString::from(format!("{id_prefix}header-group-{ix}"));
 972        let ib_id = SharedString::from(format!("{id_prefix}project-header-new-thread-{ix}"));
 973
 974        let is_collapsed = self.collapsed_groups.contains(path_list);
 975        let disclosure_icon = if is_collapsed {
 976            IconName::ChevronRight
 977        } else {
 978            IconName::ChevronDown
 979        };
 980        let workspace_for_new_thread = workspace.clone();
 981        let workspace_for_remove = workspace.clone();
 982        // let workspace_for_activate = workspace.clone();
 983
 984        let path_list_for_toggle = path_list.clone();
 985        let path_list_for_collapse = path_list.clone();
 986        let view_more_expanded = self.expanded_groups.contains_key(path_list);
 987
 988        let multi_workspace = self.multi_workspace.upgrade();
 989        let workspace_count = multi_workspace
 990            .as_ref()
 991            .map_or(0, |mw| mw.read(cx).workspaces().len());
 992        let is_active_workspace = self.focused_thread.is_none()
 993            && multi_workspace
 994                .as_ref()
 995                .is_some_and(|mw| mw.read(cx).workspace() == workspace);
 996
 997        let label = if highlight_positions.is_empty() {
 998            Label::new(label.clone())
 999                .size(LabelSize::Small)
1000                .color(Color::Muted)
1001                .into_any_element()
1002        } else {
1003            HighlightedLabel::new(label.clone(), highlight_positions.to_vec())
1004                .size(LabelSize::Small)
1005                .color(Color::Muted)
1006                .into_any_element()
1007        };
1008
1009        ListItem::new(id)
1010            .group_name(group_name)
1011            .toggle_state(is_active_workspace)
1012            .focused(is_selected)
1013            .docked_right(docked_right)
1014            .child(
1015                h_flex()
1016                    .relative()
1017                    .min_w_0()
1018                    .w_full()
1019                    .py_1()
1020                    .gap_1p5()
1021                    .child(
1022                        Icon::new(disclosure_icon)
1023                            .size(IconSize::Small)
1024                            .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.6))),
1025                    )
1026                    .child(label),
1027            )
1028            .end_hover_gradient_overlay(true)
1029            .end_hover_slot(
1030                h_flex()
1031                    .when(workspace_count > 1, |this| {
1032                        this.child(
1033                            IconButton::new(
1034                                SharedString::from(format!(
1035                                    "{id_prefix}project-header-remove-{ix}",
1036                                )),
1037                                IconName::Close,
1038                            )
1039                            .icon_size(IconSize::Small)
1040                            .icon_color(Color::Muted)
1041                            .tooltip(Tooltip::text("Remove Project"))
1042                            .on_click(cx.listener(
1043                                move |this, _, window, cx| {
1044                                    this.remove_workspace(&workspace_for_remove, window, cx);
1045                                },
1046                            )),
1047                        )
1048                    })
1049                    .when(view_more_expanded && !is_collapsed, |this| {
1050                        this.child(
1051                            IconButton::new(
1052                                SharedString::from(format!(
1053                                    "{id_prefix}project-header-collapse-{ix}",
1054                                )),
1055                                IconName::ListCollapse,
1056                            )
1057                            .icon_size(IconSize::Small)
1058                            .icon_color(Color::Muted)
1059                            .tooltip(Tooltip::text("Collapse Displayed Threads"))
1060                            .on_click(cx.listener({
1061                                let path_list_for_collapse = path_list_for_collapse.clone();
1062                                move |this, _, _window, cx| {
1063                                    this.selection = None;
1064                                    this.expanded_groups.remove(&path_list_for_collapse);
1065                                    this.update_entries(cx);
1066                                }
1067                            })),
1068                        )
1069                    })
1070                    .when(has_threads, |this| {
1071                        this.child(
1072                            IconButton::new(ib_id, IconName::NewThread)
1073                                .icon_size(IconSize::Small)
1074                                .icon_color(Color::Muted)
1075                                .tooltip(Tooltip::text("New Thread"))
1076                                .on_click(cx.listener(move |this, _, window, cx| {
1077                                    this.selection = None;
1078                                    this.create_new_thread(&workspace_for_new_thread, window, cx);
1079                                })),
1080                        )
1081                    }),
1082            )
1083            .on_click(cx.listener(move |this, _, window, cx| {
1084                this.selection = None;
1085                this.toggle_collapse(&path_list_for_toggle, window, cx);
1086            }))
1087            // TODO: Decide if we really want the header to be activating different workspaces
1088            // .on_click(cx.listener(move |this, _, window, cx| {
1089            //     this.selection = None;
1090            //     this.activate_workspace(&workspace_for_activate, window, cx);
1091            // }))
1092            .into_any_element()
1093    }
1094
1095    fn render_sticky_header(
1096        &self,
1097        docked_right: bool,
1098        window: &mut Window,
1099        cx: &mut Context<Self>,
1100    ) -> Option<AnyElement> {
1101        let scroll_top = self.list_state.logical_scroll_top();
1102
1103        let &header_idx = self
1104            .contents
1105            .project_header_indices
1106            .iter()
1107            .rev()
1108            .find(|&&idx| idx <= scroll_top.item_ix)?;
1109
1110        let needs_sticky = header_idx < scroll_top.item_ix
1111            || (header_idx == scroll_top.item_ix && scroll_top.offset_in_item > px(0.));
1112
1113        if !needs_sticky {
1114            return None;
1115        }
1116
1117        let ListEntry::ProjectHeader {
1118            path_list,
1119            label,
1120            workspace,
1121            highlight_positions,
1122            has_threads,
1123        } = self.contents.entries.get(header_idx)?
1124        else {
1125            return None;
1126        };
1127
1128        let is_focused = self.focus_handle.is_focused(window)
1129            || self.filter_editor.focus_handle(cx).is_focused(window);
1130        let is_selected = is_focused && self.selection == Some(header_idx);
1131
1132        let header_element = self.render_project_header(
1133            header_idx,
1134            true,
1135            &path_list,
1136            &label,
1137            &workspace,
1138            &highlight_positions,
1139            *has_threads,
1140            is_selected,
1141            docked_right,
1142            cx,
1143        );
1144
1145        let top_offset = self
1146            .contents
1147            .project_header_indices
1148            .iter()
1149            .find(|&&idx| idx > header_idx)
1150            .and_then(|&next_idx| {
1151                let bounds = self.list_state.bounds_for_item(next_idx)?;
1152                let viewport = self.list_state.viewport_bounds();
1153                let y_in_viewport = bounds.origin.y - viewport.origin.y;
1154                let header_height = bounds.size.height;
1155                (y_in_viewport < header_height).then_some(y_in_viewport - header_height)
1156            })
1157            .unwrap_or(px(0.));
1158
1159        let element = v_flex()
1160            .absolute()
1161            .top(top_offset)
1162            .left_0()
1163            .w_full()
1164            .bg(cx.theme().colors().surface_background)
1165            .border_b_1()
1166            .border_color(cx.theme().colors().border_variant)
1167            .child(header_element)
1168            .into_any_element();
1169
1170        Some(element)
1171    }
1172
1173    fn activate_workspace(
1174        &mut self,
1175        workspace: &Entity<Workspace>,
1176        window: &mut Window,
1177        cx: &mut Context<Self>,
1178    ) {
1179        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1180            return;
1181        };
1182
1183        multi_workspace.update(cx, |multi_workspace, cx| {
1184            multi_workspace.activate(workspace.clone(), cx);
1185        });
1186
1187        multi_workspace.update(cx, |multi_workspace, cx| {
1188            multi_workspace.focus_active_workspace(window, cx);
1189        });
1190    }
1191
1192    fn prune_stale_worktree_workspaces(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1193        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1194            return;
1195        };
1196        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
1197
1198        // Collect all worktree paths that are currently listed by any main
1199        // repo open in any workspace.
1200        let mut known_worktree_paths: HashSet<std::path::PathBuf> = HashSet::new();
1201        for workspace in &workspaces {
1202            for snapshot in root_repository_snapshots(workspace, cx) {
1203                if snapshot.work_directory_abs_path != snapshot.original_repo_abs_path {
1204                    continue;
1205                }
1206                for git_worktree in snapshot.linked_worktrees() {
1207                    known_worktree_paths.insert(git_worktree.path.to_path_buf());
1208                }
1209            }
1210        }
1211
1212        // Find workspaces that consist of exactly one root folder which is a
1213        // stale worktree checkout. Multi-root workspaces are never pruned —
1214        // losing one worktree shouldn't destroy a workspace that also
1215        // contains other folders.
1216        let mut to_remove: Vec<Entity<Workspace>> = Vec::new();
1217        for workspace in &workspaces {
1218            let path_list = workspace_path_list(workspace, cx);
1219            if path_list.paths().len() != 1 {
1220                continue;
1221            }
1222            let should_prune = root_repository_snapshots(workspace, cx)
1223                .iter()
1224                .any(|snapshot| {
1225                    snapshot.work_directory_abs_path != snapshot.original_repo_abs_path
1226                        && !known_worktree_paths.contains(snapshot.work_directory_abs_path.as_ref())
1227                });
1228            if should_prune {
1229                to_remove.push(workspace.clone());
1230            }
1231        }
1232
1233        for workspace in &to_remove {
1234            self.remove_workspace(workspace, window, cx);
1235        }
1236    }
1237
1238    fn remove_workspace(
1239        &mut self,
1240        workspace: &Entity<Workspace>,
1241        window: &mut Window,
1242        cx: &mut Context<Self>,
1243    ) {
1244        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1245            return;
1246        };
1247
1248        multi_workspace.update(cx, |multi_workspace, cx| {
1249            let Some(index) = multi_workspace
1250                .workspaces()
1251                .iter()
1252                .position(|w| w == workspace)
1253            else {
1254                return;
1255            };
1256            multi_workspace.remove_workspace(index, window, cx);
1257        });
1258    }
1259
1260    fn toggle_collapse(
1261        &mut self,
1262        path_list: &PathList,
1263        _window: &mut Window,
1264        cx: &mut Context<Self>,
1265    ) {
1266        if self.collapsed_groups.contains(path_list) {
1267            self.collapsed_groups.remove(path_list);
1268        } else {
1269            self.collapsed_groups.insert(path_list.clone());
1270        }
1271        self.update_entries(cx);
1272    }
1273
1274    fn focus_in(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
1275
1276    fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
1277        if self.reset_filter_editor_text(window, cx) {
1278            self.update_entries(cx);
1279        } else {
1280            self.focus_handle.focus(window, cx);
1281        }
1282    }
1283
1284    fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1285        self.filter_editor.update(cx, |editor, cx| {
1286            if editor.buffer().read(cx).len(cx).0 > 0 {
1287                editor.set_text("", window, cx);
1288                true
1289            } else {
1290                false
1291            }
1292        })
1293    }
1294
1295    fn has_filter_query(&self, cx: &App) -> bool {
1296        !self.filter_editor.read(cx).text(cx).is_empty()
1297    }
1298
1299    fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
1300        self.select_next(&SelectNext, window, cx);
1301    }
1302
1303    fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
1304        self.select_previous(&SelectPrevious, window, cx);
1305    }
1306
1307    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
1308        let next = match self.selection {
1309            Some(ix) if ix + 1 < self.contents.entries.len() => ix + 1,
1310            None if !self.contents.entries.is_empty() => 0,
1311            _ => return,
1312        };
1313        self.selection = Some(next);
1314        self.list_state.scroll_to_reveal_item(next);
1315        cx.notify();
1316    }
1317
1318    fn select_previous(
1319        &mut self,
1320        _: &SelectPrevious,
1321        _window: &mut Window,
1322        cx: &mut Context<Self>,
1323    ) {
1324        let prev = match self.selection {
1325            Some(ix) if ix > 0 => ix - 1,
1326            None if !self.contents.entries.is_empty() => self.contents.entries.len() - 1,
1327            _ => return,
1328        };
1329        self.selection = Some(prev);
1330        self.list_state.scroll_to_reveal_item(prev);
1331        cx.notify();
1332    }
1333
1334    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
1335        if !self.contents.entries.is_empty() {
1336            self.selection = Some(0);
1337            self.list_state.scroll_to_reveal_item(0);
1338            cx.notify();
1339        }
1340    }
1341
1342    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
1343        if let Some(last) = self.contents.entries.len().checked_sub(1) {
1344            self.selection = Some(last);
1345            self.list_state.scroll_to_reveal_item(last);
1346            cx.notify();
1347        }
1348    }
1349
1350    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1351        let Some(ix) = self.selection else { return };
1352        let Some(entry) = self.contents.entries.get(ix) else {
1353            return;
1354        };
1355
1356        match entry {
1357            ListEntry::ProjectHeader { workspace, .. } => {
1358                let workspace = workspace.clone();
1359                self.activate_workspace(&workspace, window, cx);
1360            }
1361            ListEntry::Thread(thread) => {
1362                let session_info = thread.session_info.clone();
1363                match &thread.workspace {
1364                    ThreadEntryWorkspace::Open(workspace) => {
1365                        let workspace = workspace.clone();
1366                        self.activate_thread(
1367                            thread.agent.clone(),
1368                            session_info,
1369                            &workspace,
1370                            window,
1371                            cx,
1372                        );
1373                    }
1374                    ThreadEntryWorkspace::Closed(path_list) => {
1375                        self.open_workspace_and_activate_thread(
1376                            thread.agent.clone(),
1377                            session_info,
1378                            path_list.clone(),
1379                            window,
1380                            cx,
1381                        );
1382                    }
1383                }
1384            }
1385            ListEntry::ViewMore {
1386                path_list,
1387                is_fully_expanded,
1388                ..
1389            } => {
1390                let path_list = path_list.clone();
1391                if *is_fully_expanded {
1392                    self.expanded_groups.remove(&path_list);
1393                } else {
1394                    let current = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
1395                    self.expanded_groups.insert(path_list, current + 1);
1396                }
1397                self.update_entries(cx);
1398            }
1399            ListEntry::NewThread { workspace, .. } => {
1400                let workspace = workspace.clone();
1401                self.create_new_thread(&workspace, window, cx);
1402            }
1403        }
1404    }
1405
1406    fn activate_thread(
1407        &mut self,
1408        agent: Agent,
1409        session_info: acp_thread::AgentSessionInfo,
1410        workspace: &Entity<Workspace>,
1411        window: &mut Window,
1412        cx: &mut Context<Self>,
1413    ) {
1414        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1415            return;
1416        };
1417
1418        multi_workspace.update(cx, |multi_workspace, cx| {
1419            multi_workspace.activate(workspace.clone(), cx);
1420        });
1421
1422        workspace.update(cx, |workspace, cx| {
1423            workspace.open_panel::<AgentPanel>(window, cx);
1424        });
1425
1426        if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
1427            agent_panel.update(cx, |panel, cx| {
1428                panel.load_agent_thread(
1429                    agent,
1430                    session_info.session_id,
1431                    session_info.cwd,
1432                    session_info.title,
1433                    true,
1434                    window,
1435                    cx,
1436                );
1437            });
1438        }
1439
1440        self.update_entries(cx);
1441    }
1442
1443    fn open_workspace_and_activate_thread(
1444        &mut self,
1445        agent: Agent,
1446        session_info: acp_thread::AgentSessionInfo,
1447        path_list: PathList,
1448        window: &mut Window,
1449        cx: &mut Context<Self>,
1450    ) {
1451        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1452            return;
1453        };
1454
1455        let paths: Vec<std::path::PathBuf> =
1456            path_list.paths().iter().map(|p| p.to_path_buf()).collect();
1457
1458        let open_task = multi_workspace.update(cx, |mw, cx| mw.open_project(paths, window, cx));
1459
1460        cx.spawn_in(window, async move |this, cx| {
1461            let workspace = open_task.await?;
1462            this.update_in(cx, |this, window, cx| {
1463                this.activate_thread(agent, session_info, &workspace, window, cx);
1464            })?;
1465            anyhow::Ok(())
1466        })
1467        .detach_and_log_err(cx);
1468    }
1469
1470    fn find_open_workspace_for_path_list(
1471        &self,
1472        path_list: &PathList,
1473        cx: &App,
1474    ) -> Option<Entity<Workspace>> {
1475        let multi_workspace = self.multi_workspace.upgrade()?;
1476        multi_workspace
1477            .read(cx)
1478            .workspaces()
1479            .iter()
1480            .find(|workspace| workspace_path_list(workspace, cx).paths() == path_list.paths())
1481            .cloned()
1482    }
1483
1484    fn activate_archived_thread(
1485        &mut self,
1486        agent: Agent,
1487        session_info: acp_thread::AgentSessionInfo,
1488        window: &mut Window,
1489        cx: &mut Context<Self>,
1490    ) {
1491        let saved_path_list = ThreadStore::try_global(cx).and_then(|thread_store| {
1492            thread_store
1493                .read(cx)
1494                .thread_from_session_id(&session_info.session_id)
1495                .map(|thread| thread.folder_paths.clone())
1496        });
1497        let path_list = saved_path_list.or_else(|| {
1498            // we don't have saved metadata, so create path list based on the cwd
1499            session_info
1500                .cwd
1501                .as_ref()
1502                .map(|cwd| PathList::new(&[cwd.to_path_buf()]))
1503        });
1504
1505        if let Some(path_list) = path_list {
1506            if let Some(workspace) = self.find_open_workspace_for_path_list(&path_list, cx) {
1507                self.activate_thread(agent, session_info, &workspace, window, cx);
1508            } else {
1509                self.open_workspace_and_activate_thread(agent, session_info, path_list, window, cx);
1510            }
1511            return;
1512        }
1513
1514        let active_workspace = self.multi_workspace.upgrade().and_then(|w| {
1515            w.read(cx)
1516                .workspaces()
1517                .get(w.read(cx).active_workspace_index())
1518                .cloned()
1519        });
1520
1521        if let Some(workspace) = active_workspace {
1522            self.activate_thread(agent, session_info, &workspace, window, cx);
1523        }
1524    }
1525
1526    fn expand_selected_entry(
1527        &mut self,
1528        _: &ExpandSelectedEntry,
1529        _window: &mut Window,
1530        cx: &mut Context<Self>,
1531    ) {
1532        let Some(ix) = self.selection else { return };
1533
1534        match self.contents.entries.get(ix) {
1535            Some(ListEntry::ProjectHeader { path_list, .. }) => {
1536                if self.collapsed_groups.contains(path_list) {
1537                    let path_list = path_list.clone();
1538                    self.collapsed_groups.remove(&path_list);
1539                    self.update_entries(cx);
1540                } else if ix + 1 < self.contents.entries.len() {
1541                    self.selection = Some(ix + 1);
1542                    self.list_state.scroll_to_reveal_item(ix + 1);
1543                    cx.notify();
1544                }
1545            }
1546            _ => {}
1547        }
1548    }
1549
1550    fn collapse_selected_entry(
1551        &mut self,
1552        _: &CollapseSelectedEntry,
1553        _window: &mut Window,
1554        cx: &mut Context<Self>,
1555    ) {
1556        let Some(ix) = self.selection else { return };
1557
1558        match self.contents.entries.get(ix) {
1559            Some(ListEntry::ProjectHeader { path_list, .. }) => {
1560                if !self.collapsed_groups.contains(path_list) {
1561                    let path_list = path_list.clone();
1562                    self.collapsed_groups.insert(path_list);
1563                    self.update_entries(cx);
1564                }
1565            }
1566            Some(
1567                ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
1568            ) => {
1569                for i in (0..ix).rev() {
1570                    if let Some(ListEntry::ProjectHeader { path_list, .. }) =
1571                        self.contents.entries.get(i)
1572                    {
1573                        let path_list = path_list.clone();
1574                        self.selection = Some(i);
1575                        self.collapsed_groups.insert(path_list);
1576                        self.update_entries(cx);
1577                        break;
1578                    }
1579                }
1580            }
1581            None => {}
1582        }
1583    }
1584
1585    fn render_thread(
1586        &self,
1587        ix: usize,
1588        thread: &ThreadEntry,
1589        is_selected: bool,
1590        docked_right: bool,
1591        cx: &mut Context<Self>,
1592    ) -> AnyElement {
1593        let has_notification = self
1594            .contents
1595            .is_thread_notified(&thread.session_info.session_id);
1596
1597        let title: SharedString = thread
1598            .session_info
1599            .title
1600            .clone()
1601            .unwrap_or_else(|| "Untitled".into());
1602        let session_info = thread.session_info.clone();
1603        let thread_workspace = thread.workspace.clone();
1604
1605        let id = SharedString::from(format!("thread-entry-{}", ix));
1606
1607        let timestamp = thread
1608            .session_info
1609            .created_at
1610            .or(thread.session_info.updated_at)
1611            .map(|entry_time| {
1612                let now = Utc::now();
1613                let duration = now.signed_duration_since(entry_time);
1614
1615                let minutes = duration.num_minutes();
1616                let hours = duration.num_hours();
1617                let days = duration.num_days();
1618                let weeks = days / 7;
1619                let months = days / 30;
1620
1621                if minutes < 60 {
1622                    format!("{}m", minutes.max(1))
1623                } else if hours < 24 {
1624                    format!("{}h", hours)
1625                } else if weeks < 4 {
1626                    format!("{}w", weeks.max(1))
1627                } else {
1628                    format!("{}mo", months.max(1))
1629                }
1630            });
1631
1632        ThreadItem::new(id, title)
1633            .icon(thread.icon)
1634            .when_some(thread.icon_from_external_svg.clone(), |this, svg| {
1635                this.custom_icon_from_external_svg(svg)
1636            })
1637            .when_some(thread.worktree_name.clone(), |this, name| {
1638                this.worktree(name)
1639            })
1640            .worktree_highlight_positions(thread.worktree_highlight_positions.clone())
1641            .when_some(timestamp, |this, ts| this.timestamp(ts))
1642            .highlight_positions(thread.highlight_positions.to_vec())
1643            .status(thread.status)
1644            .notified(has_notification)
1645            .when(thread.diff_stats.lines_added > 0, |this| {
1646                this.added(thread.diff_stats.lines_added as usize)
1647            })
1648            .when(thread.diff_stats.lines_removed > 0, |this| {
1649                this.removed(thread.diff_stats.lines_removed as usize)
1650            })
1651            .selected(self.focused_thread.as_ref() == Some(&session_info.session_id))
1652            .focused(is_selected)
1653            .docked_right(docked_right)
1654            .on_click({
1655                let agent = thread.agent.clone();
1656                cx.listener(move |this, _, window, cx| {
1657                    this.selection = None;
1658                    match &thread_workspace {
1659                        ThreadEntryWorkspace::Open(workspace) => {
1660                            this.activate_thread(
1661                                agent.clone(),
1662                                session_info.clone(),
1663                                workspace,
1664                                window,
1665                                cx,
1666                            );
1667                        }
1668                        ThreadEntryWorkspace::Closed(path_list) => {
1669                            this.open_workspace_and_activate_thread(
1670                                agent.clone(),
1671                                session_info.clone(),
1672                                path_list.clone(),
1673                                window,
1674                                cx,
1675                            );
1676                        }
1677                    }
1678                })
1679            })
1680            .into_any_element()
1681    }
1682
1683    fn render_filter_input(&self) -> impl IntoElement {
1684        self.filter_editor.clone()
1685    }
1686
1687    fn render_view_more(
1688        &self,
1689        ix: usize,
1690        path_list: &PathList,
1691        remaining_count: usize,
1692        is_fully_expanded: bool,
1693        is_selected: bool,
1694        cx: &mut Context<Self>,
1695    ) -> AnyElement {
1696        let path_list = path_list.clone();
1697        let id = SharedString::from(format!("view-more-{}", ix));
1698
1699        let (icon, label) = if is_fully_expanded {
1700            (IconName::ListCollapse, "Collapse")
1701        } else {
1702            (IconName::Plus, "View More")
1703        };
1704
1705        ListItem::new(id)
1706            .focused(is_selected)
1707            .child(
1708                h_flex()
1709                    .py_1()
1710                    .gap_1p5()
1711                    .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted))
1712                    .child(Label::new(label).color(Color::Muted))
1713                    .when(!is_fully_expanded, |this| {
1714                        this.child(
1715                            Label::new(format!("({})", remaining_count))
1716                                .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.5))),
1717                        )
1718                    }),
1719            )
1720            .on_click(cx.listener(move |this, _, _window, cx| {
1721                this.selection = None;
1722                if is_fully_expanded {
1723                    this.expanded_groups.remove(&path_list);
1724                } else {
1725                    let current = this.expanded_groups.get(&path_list).copied().unwrap_or(0);
1726                    this.expanded_groups.insert(path_list.clone(), current + 1);
1727                }
1728                this.update_entries(cx);
1729            }))
1730            .into_any_element()
1731    }
1732
1733    fn create_new_thread(
1734        &mut self,
1735        workspace: &Entity<Workspace>,
1736        window: &mut Window,
1737        cx: &mut Context<Self>,
1738    ) {
1739        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1740            return;
1741        };
1742
1743        multi_workspace.update(cx, |multi_workspace, cx| {
1744            multi_workspace.activate(workspace.clone(), cx);
1745        });
1746
1747        workspace.update(cx, |workspace, cx| {
1748            if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx) {
1749                agent_panel.update(cx, |panel, cx| {
1750                    panel.new_thread(&NewThread, window, cx);
1751                });
1752            }
1753            workspace.focus_panel::<AgentPanel>(window, cx);
1754        });
1755    }
1756
1757    fn render_new_thread(
1758        &self,
1759        ix: usize,
1760        _path_list: &PathList,
1761        workspace: &Entity<Workspace>,
1762        is_selected: bool,
1763        cx: &mut Context<Self>,
1764    ) -> AnyElement {
1765        let workspace = workspace.clone();
1766
1767        div()
1768            .w_full()
1769            .p_2()
1770            .pt_1p5()
1771            .child(
1772                Button::new(
1773                    SharedString::from(format!("new-thread-btn-{}", ix)),
1774                    "New Thread",
1775                )
1776                .full_width()
1777                .style(ButtonStyle::Outlined)
1778                .start_icon(
1779                    Icon::new(IconName::Plus)
1780                        .size(IconSize::Small)
1781                        .color(Color::Muted),
1782                )
1783                .toggle_state(is_selected)
1784                .on_click(cx.listener(move |this, _, window, cx| {
1785                    this.selection = None;
1786                    this.create_new_thread(&workspace, window, cx);
1787                })),
1788            )
1789            .into_any_element()
1790    }
1791
1792    fn render_thread_list_header(
1793        &self,
1794        docked_right: bool,
1795        cx: &mut Context<Self>,
1796    ) -> impl IntoElement {
1797        let has_query = self.has_filter_query(cx);
1798
1799        h_flex()
1800            .h(Tab::container_height(cx))
1801            .flex_none()
1802            .gap_1p5()
1803            .border_b_1()
1804            .border_color(cx.theme().colors().border)
1805            .when(!docked_right, |this| {
1806                this.child(self.render_sidebar_toggle_button(false, cx))
1807            })
1808            .child(self.render_filter_input())
1809            .when(has_query, |this| {
1810                this.when(!docked_right, |this| this.pr_1p5()).child(
1811                    IconButton::new("clear_filter", IconName::Close)
1812                        .shape(IconButtonShape::Square)
1813                        .tooltip(Tooltip::text("Clear Search"))
1814                        .on_click(cx.listener(|this, _, window, cx| {
1815                            this.reset_filter_editor_text(window, cx);
1816                            this.update_entries(cx);
1817                        })),
1818                )
1819            })
1820            .when(docked_right, |this| {
1821                this.pl_2()
1822                    .pr_0p5()
1823                    .child(self.render_sidebar_toggle_button(true, cx))
1824            })
1825    }
1826
1827    fn render_thread_list_footer(&self, cx: &mut Context<Self>) -> impl IntoElement {
1828        h_flex()
1829            .p_1p5()
1830            .border_t_1()
1831            .border_color(cx.theme().colors().border_variant)
1832            .child(
1833                Button::new("view-archive", "Archive")
1834                    .full_width()
1835                    .label_size(LabelSize::Small)
1836                    .style(ButtonStyle::Outlined)
1837                    .start_icon(
1838                        Icon::new(IconName::Archive)
1839                            .size(IconSize::XSmall)
1840                            .color(Color::Muted),
1841                    )
1842                    .on_click(cx.listener(|this, _, window, cx| {
1843                        this.show_archive(window, cx);
1844                    })),
1845            )
1846    }
1847
1848    fn render_sidebar_toggle_button(
1849        &self,
1850        docked_right: bool,
1851        cx: &mut Context<Self>,
1852    ) -> impl IntoElement {
1853        let icon = if docked_right {
1854            IconName::ThreadsSidebarRightOpen
1855        } else {
1856            IconName::ThreadsSidebarLeftOpen
1857        };
1858
1859        h_flex()
1860            .h_full()
1861            .px_1()
1862            .map(|this| {
1863                if docked_right {
1864                    this.pr_1p5().border_l_1()
1865                } else {
1866                    this.border_r_1()
1867                }
1868            })
1869            .border_color(cx.theme().colors().border_variant)
1870            .child(
1871                IconButton::new("sidebar-close-toggle", icon)
1872                    .icon_size(IconSize::Small)
1873                    .tooltip(move |_, cx| {
1874                        Tooltip::for_action("Close Threads Sidebar", &ToggleWorkspaceSidebar, cx)
1875                    })
1876                    .on_click(|_, window, cx| {
1877                        window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
1878                    }),
1879            )
1880    }
1881}
1882
1883impl Sidebar {
1884    pub fn is_open(&self) -> bool {
1885        self.is_open
1886    }
1887
1888    fn show_archive(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1889        let Some(active_workspace) = self.multi_workspace.upgrade().and_then(|w| {
1890            w.read(cx)
1891                .workspaces()
1892                .get(w.read(cx).active_workspace_index())
1893                .cloned()
1894        }) else {
1895            return;
1896        };
1897
1898        let Some(agent_panel) = active_workspace.read(cx).panel::<AgentPanel>(cx) else {
1899            return;
1900        };
1901
1902        let thread_store = agent_panel.read(cx).thread_store().clone();
1903        let fs = active_workspace.read(cx).project().read(cx).fs().clone();
1904        let agent_connection_store = agent_panel.read(cx).connection_store().clone();
1905        let agent_server_store = active_workspace
1906            .read(cx)
1907            .project()
1908            .read(cx)
1909            .agent_server_store()
1910            .clone();
1911
1912        let archive_view = cx.new(|cx| {
1913            ThreadsArchiveView::new(
1914                agent_connection_store,
1915                agent_server_store,
1916                thread_store,
1917                fs,
1918                window,
1919                cx,
1920            )
1921        });
1922        let subscription = cx.subscribe_in(
1923            &archive_view,
1924            window,
1925            |this, _, event: &ThreadsArchiveViewEvent, window, cx| match event {
1926                ThreadsArchiveViewEvent::Close => {
1927                    this.show_thread_list(window, cx);
1928                }
1929                ThreadsArchiveViewEvent::OpenThread {
1930                    agent,
1931                    session_info,
1932                } => {
1933                    this.show_thread_list(window, cx);
1934                    this.activate_archived_thread(agent.clone(), session_info.clone(), window, cx);
1935                }
1936            },
1937        );
1938
1939        self._subscriptions.push(subscription);
1940        self.archive_view = Some(archive_view);
1941        self.view = SidebarView::Archive;
1942        cx.notify();
1943    }
1944
1945    fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1946        self.view = SidebarView::ThreadList;
1947        self.archive_view = None;
1948        self._subscriptions.clear();
1949        window.focus(&self.focus_handle, cx);
1950        cx.notify();
1951    }
1952
1953    pub fn set_open(&mut self, open: bool, cx: &mut Context<Self>) {
1954        if self.is_open == open {
1955            return;
1956        }
1957        self.is_open = open;
1958        cx.notify();
1959        if let Some(key) = self.persistence_key {
1960            let is_open = self.is_open;
1961            cx.background_spawn(async move {
1962                save_sidebar_open_state(key, is_open).await;
1963            })
1964            .detach();
1965        }
1966    }
1967
1968    pub fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1969        let new_state = !self.is_open;
1970        self.set_open(new_state, cx);
1971        if new_state {
1972            cx.focus_self(window);
1973        }
1974    }
1975
1976    pub fn focus_or_unfocus(
1977        &mut self,
1978        workspace: &mut Workspace,
1979        window: &mut Window,
1980        cx: &mut Context<Self>,
1981    ) {
1982        if self.is_open {
1983            let sidebar_is_focused = self.focus_handle(cx).contains_focused(window, cx);
1984            if sidebar_is_focused {
1985                let active_pane = workspace.active_pane().clone();
1986                let pane_focus = active_pane.read(cx).focus_handle(cx);
1987                window.focus(&pane_focus, cx);
1988            } else {
1989                cx.focus_self(window);
1990            }
1991        } else {
1992            self.set_open(true, cx);
1993            cx.focus_self(window);
1994        }
1995    }
1996
1997    pub fn width(&self, _cx: &App) -> Pixels {
1998        self.width
1999    }
2000
2001    pub fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
2002        self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
2003        cx.notify();
2004    }
2005
2006    pub fn has_notifications(&self, _cx: &App) -> bool {
2007        !self.contents.notified_threads.is_empty()
2008    }
2009}
2010
2011impl Focusable for Sidebar {
2012    fn focus_handle(&self, cx: &App) -> FocusHandle {
2013        self.filter_editor.focus_handle(cx)
2014    }
2015}
2016
2017impl Render for Sidebar {
2018    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2019        let ui_font = theme::setup_ui_font(window, cx);
2020        let docked_right = AgentSettings::get_global(cx).dock == settings::DockPosition::Right;
2021        let sticky_header = self.render_sticky_header(docked_right, window, cx);
2022
2023        v_flex()
2024            .id("workspace-sidebar")
2025            .key_context("WorkspaceSidebar")
2026            .track_focus(&self.focus_handle)
2027            .on_action(cx.listener(Self::select_next))
2028            .on_action(cx.listener(Self::select_previous))
2029            .on_action(cx.listener(Self::editor_move_down))
2030            .on_action(cx.listener(Self::editor_move_up))
2031            .on_action(cx.listener(Self::select_first))
2032            .on_action(cx.listener(Self::select_last))
2033            .on_action(cx.listener(Self::confirm))
2034            .on_action(cx.listener(Self::expand_selected_entry))
2035            .on_action(cx.listener(Self::collapse_selected_entry))
2036            .on_action(cx.listener(Self::cancel))
2037            .font(ui_font)
2038            .size_full()
2039            .bg(cx.theme().colors().surface_background)
2040            .map(|this| match self.view {
2041                SidebarView::ThreadList => this
2042                    .child(self.render_thread_list_header(docked_right, cx))
2043                    .child(
2044                        v_flex()
2045                            .relative()
2046                            .flex_1()
2047                            .overflow_hidden()
2048                            .child(
2049                                list(
2050                                    self.list_state.clone(),
2051                                    cx.processor(Self::render_list_entry),
2052                                )
2053                                .flex_1()
2054                                .size_full(),
2055                            )
2056                            .when_some(sticky_header, |this, header| this.child(header))
2057                            .vertical_scrollbar_for(&self.list_state, window, cx),
2058                    )
2059                    .child(self.render_thread_list_footer(cx)),
2060                SidebarView::Archive => {
2061                    if let Some(archive_view) = &self.archive_view {
2062                        this.child(archive_view.clone())
2063                    } else {
2064                        this
2065                    }
2066                }
2067            })
2068    }
2069}
2070
2071#[cfg(test)]
2072mod tests {
2073    use super::*;
2074    use crate::test_support::{active_session_id, open_thread_with_connection, send_message};
2075    use acp_thread::StubAgentConnection;
2076    use agent::ThreadStore;
2077    use assistant_text_thread::TextThreadStore;
2078    use chrono::DateTime;
2079    use feature_flags::FeatureFlagAppExt as _;
2080    use fs::FakeFs;
2081    use gpui::TestAppContext;
2082    use std::sync::Arc;
2083    use util::path_list::PathList;
2084
2085    fn init_test(cx: &mut TestAppContext) {
2086        crate::test_support::init_test(cx);
2087        cx.update(|cx| {
2088            cx.update_flags(false, vec!["agent-v2".into()]);
2089            ThreadStore::init_global(cx);
2090            language_model::LanguageModelRegistry::test(cx);
2091            prompt_store::init(cx);
2092        });
2093    }
2094
2095    fn make_test_thread(title: &str, updated_at: DateTime<Utc>) -> agent::DbThread {
2096        agent::DbThread {
2097            title: title.to_string().into(),
2098            messages: Vec::new(),
2099            updated_at,
2100            detailed_summary: None,
2101            initial_project_snapshot: None,
2102            cumulative_token_usage: Default::default(),
2103            request_token_usage: Default::default(),
2104            model: None,
2105            profile: None,
2106            imported: false,
2107            subagent_context: None,
2108            speed: None,
2109            thinking_enabled: false,
2110            thinking_effort: None,
2111            draft_prompt: None,
2112            ui_scroll_position: None,
2113        }
2114    }
2115
2116    async fn init_test_project(
2117        worktree_path: &str,
2118        cx: &mut TestAppContext,
2119    ) -> Entity<project::Project> {
2120        init_test(cx);
2121        let fs = FakeFs::new(cx.executor());
2122        fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
2123            .await;
2124        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2125        project::Project::test(fs, [worktree_path.as_ref()], cx).await
2126    }
2127
2128    fn setup_sidebar(
2129        multi_workspace: &Entity<MultiWorkspace>,
2130        cx: &mut gpui::VisualTestContext,
2131    ) -> Entity<Sidebar> {
2132        let (sidebar, _panel) = setup_sidebar_with_agent_panel(multi_workspace, cx);
2133        sidebar
2134    }
2135
2136    fn setup_sidebar_with_agent_panel(
2137        multi_workspace: &Entity<MultiWorkspace>,
2138        cx: &mut gpui::VisualTestContext,
2139    ) -> (Entity<Sidebar>, Entity<AgentPanel>) {
2140        let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
2141        let project = workspace.read_with(cx, |ws, _cx| ws.project().clone());
2142        let panel = add_agent_panel(&workspace, &project, cx);
2143        workspace.update_in(cx, |workspace, window, cx| {
2144            workspace.right_dock().update(cx, |dock, cx| {
2145                if let Some(panel_ix) = dock.panel_index_for_type::<AgentPanel>() {
2146                    dock.activate_panel(panel_ix, window, cx);
2147                }
2148                dock.set_open(true, window, cx);
2149            });
2150        });
2151        cx.run_until_parked();
2152        let sidebar = panel.read_with(cx, |panel, _cx| {
2153            panel
2154                .sidebar
2155                .clone()
2156                .expect("AgentPanel should have created a sidebar")
2157        });
2158        (sidebar, panel)
2159    }
2160
2161    async fn save_n_test_threads(
2162        count: u32,
2163        path_list: &PathList,
2164        cx: &mut gpui::VisualTestContext,
2165    ) {
2166        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2167        for i in 0..count {
2168            let save_task = thread_store.update(cx, |store, cx| {
2169                store.save_thread(
2170                    acp::SessionId::new(Arc::from(format!("thread-{}", i))),
2171                    make_test_thread(
2172                        &format!("Thread {}", i + 1),
2173                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
2174                    ),
2175                    path_list.clone(),
2176                    cx,
2177                )
2178            });
2179            save_task.await.unwrap();
2180        }
2181        cx.run_until_parked();
2182    }
2183
2184    async fn save_thread_to_store(
2185        session_id: &acp::SessionId,
2186        path_list: &PathList,
2187        cx: &mut gpui::VisualTestContext,
2188    ) {
2189        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2190        let save_task = thread_store.update(cx, |store, cx| {
2191            store.save_thread(
2192                session_id.clone(),
2193                make_test_thread(
2194                    "Test",
2195                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
2196                ),
2197                path_list.clone(),
2198                cx,
2199            )
2200        });
2201        save_task.await.unwrap();
2202        cx.run_until_parked();
2203    }
2204
2205    fn open_and_focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
2206        cx.run_until_parked();
2207        sidebar.update_in(cx, |sidebar, window, cx| {
2208            sidebar.set_open(true, cx);
2209            cx.focus_self(window);
2210        });
2211        cx.run_until_parked();
2212    }
2213
2214    fn visible_entries_as_strings(
2215        sidebar: &Entity<Sidebar>,
2216        cx: &mut gpui::VisualTestContext,
2217    ) -> Vec<String> {
2218        sidebar.read_with(cx, |sidebar, _cx| {
2219            sidebar
2220                .contents
2221                .entries
2222                .iter()
2223                .enumerate()
2224                .map(|(ix, entry)| {
2225                    let selected = if sidebar.selection == Some(ix) {
2226                        "  <== selected"
2227                    } else {
2228                        ""
2229                    };
2230                    match entry {
2231                        ListEntry::ProjectHeader {
2232                            label,
2233                            path_list,
2234                            highlight_positions: _,
2235                            ..
2236                        } => {
2237                            let icon = if sidebar.collapsed_groups.contains(path_list) {
2238                                ">"
2239                            } else {
2240                                "v"
2241                            };
2242                            format!("{} [{}]{}", icon, label, selected)
2243                        }
2244                        ListEntry::Thread(thread) => {
2245                            let title = thread
2246                                .session_info
2247                                .title
2248                                .as_ref()
2249                                .map(|s| s.as_ref())
2250                                .unwrap_or("Untitled");
2251                            let active = if thread.is_live { " *" } else { "" };
2252                            let status_str = match thread.status {
2253                                AgentThreadStatus::Running => " (running)",
2254                                AgentThreadStatus::Error => " (error)",
2255                                AgentThreadStatus::WaitingForConfirmation => " (waiting)",
2256                                _ => "",
2257                            };
2258                            let notified = if sidebar
2259                                .contents
2260                                .is_thread_notified(&thread.session_info.session_id)
2261                            {
2262                                " (!)"
2263                            } else {
2264                                ""
2265                            };
2266                            let worktree = thread
2267                                .worktree_name
2268                                .as_ref()
2269                                .map(|name| format!(" {{{}}}", name))
2270                                .unwrap_or_default();
2271                            format!(
2272                                "  {}{}{}{}{}{}",
2273                                title, worktree, active, status_str, notified, selected
2274                            )
2275                        }
2276                        ListEntry::ViewMore {
2277                            remaining_count,
2278                            is_fully_expanded,
2279                            ..
2280                        } => {
2281                            if *is_fully_expanded {
2282                                format!("  - Collapse{}", selected)
2283                            } else {
2284                                format!("  + View More ({}){}", remaining_count, selected)
2285                            }
2286                        }
2287                        ListEntry::NewThread { .. } => {
2288                            format!("  [+ New Thread]{}", selected)
2289                        }
2290                    }
2291                })
2292                .collect()
2293        })
2294    }
2295
2296    #[gpui::test]
2297    async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
2298        let project = init_test_project("/my-project", cx).await;
2299        let (multi_workspace, cx) =
2300            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2301        let sidebar = setup_sidebar(&multi_workspace, cx);
2302
2303        assert_eq!(
2304            visible_entries_as_strings(&sidebar, cx),
2305            vec!["v [my-project]", "  [+ New Thread]"]
2306        );
2307    }
2308
2309    #[gpui::test]
2310    async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
2311        let project = init_test_project("/my-project", cx).await;
2312        let (multi_workspace, cx) =
2313            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2314        let sidebar = setup_sidebar(&multi_workspace, cx);
2315
2316        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2317        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2318
2319        let save_task = thread_store.update(cx, |store, cx| {
2320            store.save_thread(
2321                acp::SessionId::new(Arc::from("thread-1")),
2322                make_test_thread(
2323                    "Fix crash in project panel",
2324                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
2325                ),
2326                path_list.clone(),
2327                cx,
2328            )
2329        });
2330        save_task.await.unwrap();
2331
2332        let save_task = thread_store.update(cx, |store, cx| {
2333            store.save_thread(
2334                acp::SessionId::new(Arc::from("thread-2")),
2335                make_test_thread(
2336                    "Add inline diff view",
2337                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
2338                ),
2339                path_list.clone(),
2340                cx,
2341            )
2342        });
2343        save_task.await.unwrap();
2344        cx.run_until_parked();
2345
2346        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2347        cx.run_until_parked();
2348
2349        assert_eq!(
2350            visible_entries_as_strings(&sidebar, cx),
2351            vec![
2352                "v [my-project]",
2353                "  Fix crash in project panel",
2354                "  Add inline diff view",
2355            ]
2356        );
2357    }
2358
2359    #[gpui::test]
2360    async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
2361        let project = init_test_project("/project-a", cx).await;
2362        let (multi_workspace, cx) =
2363            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2364        let sidebar = setup_sidebar(&multi_workspace, cx);
2365
2366        // Single workspace with a thread
2367        let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]);
2368        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2369
2370        let save_task = thread_store.update(cx, |store, cx| {
2371            store.save_thread(
2372                acp::SessionId::new(Arc::from("thread-a1")),
2373                make_test_thread(
2374                    "Thread A1",
2375                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
2376                ),
2377                path_list.clone(),
2378                cx,
2379            )
2380        });
2381        save_task.await.unwrap();
2382        cx.run_until_parked();
2383
2384        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2385        cx.run_until_parked();
2386
2387        assert_eq!(
2388            visible_entries_as_strings(&sidebar, cx),
2389            vec!["v [project-a]", "  Thread A1"]
2390        );
2391
2392        // Add a second workspace
2393        multi_workspace.update_in(cx, |mw, window, cx| {
2394            mw.create_workspace(window, cx);
2395        });
2396        cx.run_until_parked();
2397
2398        assert_eq!(
2399            visible_entries_as_strings(&sidebar, cx),
2400            vec![
2401                "v [project-a]",
2402                "  Thread A1",
2403                "v [Empty Workspace]",
2404                "  [+ New Thread]"
2405            ]
2406        );
2407
2408        // Remove the second workspace
2409        multi_workspace.update_in(cx, |mw, window, cx| {
2410            mw.remove_workspace(1, window, cx);
2411        });
2412        cx.run_until_parked();
2413
2414        assert_eq!(
2415            visible_entries_as_strings(&sidebar, cx),
2416            vec!["v [project-a]", "  Thread A1"]
2417        );
2418    }
2419
2420    #[gpui::test]
2421    async fn test_view_more_pagination(cx: &mut TestAppContext) {
2422        let project = init_test_project("/my-project", cx).await;
2423        let (multi_workspace, cx) =
2424            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2425        let sidebar = setup_sidebar(&multi_workspace, cx);
2426
2427        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2428        save_n_test_threads(12, &path_list, cx).await;
2429
2430        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2431        cx.run_until_parked();
2432
2433        assert_eq!(
2434            visible_entries_as_strings(&sidebar, cx),
2435            vec![
2436                "v [my-project]",
2437                "  Thread 12",
2438                "  Thread 11",
2439                "  Thread 10",
2440                "  Thread 9",
2441                "  Thread 8",
2442                "  + View More (7)",
2443            ]
2444        );
2445    }
2446
2447    #[gpui::test]
2448    async fn test_view_more_batched_expansion(cx: &mut TestAppContext) {
2449        let project = init_test_project("/my-project", cx).await;
2450        let (multi_workspace, cx) =
2451            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2452        let sidebar = setup_sidebar(&multi_workspace, cx);
2453
2454        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2455        // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse
2456        save_n_test_threads(17, &path_list, cx).await;
2457
2458        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2459        cx.run_until_parked();
2460
2461        // Initially shows 5 threads + View More (12 remaining)
2462        let entries = visible_entries_as_strings(&sidebar, cx);
2463        assert_eq!(entries.len(), 7); // header + 5 threads + View More
2464        assert!(entries.iter().any(|e| e.contains("View More (12)")));
2465
2466        // Focus and navigate to View More, then confirm to expand by one batch
2467        open_and_focus_sidebar(&sidebar, cx);
2468        for _ in 0..7 {
2469            cx.dispatch_action(SelectNext);
2470        }
2471        cx.dispatch_action(Confirm);
2472        cx.run_until_parked();
2473
2474        // Now shows 10 threads + View More (7 remaining)
2475        let entries = visible_entries_as_strings(&sidebar, cx);
2476        assert_eq!(entries.len(), 12); // header + 10 threads + View More
2477        assert!(entries.iter().any(|e| e.contains("View More (7)")));
2478
2479        // Expand again by one batch
2480        sidebar.update_in(cx, |s, _window, cx| {
2481            let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0);
2482            s.expanded_groups.insert(path_list.clone(), current + 1);
2483            s.update_entries(cx);
2484        });
2485        cx.run_until_parked();
2486
2487        // Now shows 15 threads + View More (2 remaining)
2488        let entries = visible_entries_as_strings(&sidebar, cx);
2489        assert_eq!(entries.len(), 17); // header + 15 threads + View More
2490        assert!(entries.iter().any(|e| e.contains("View More (2)")));
2491
2492        // Expand one more time - should show all 17 threads with Collapse button
2493        sidebar.update_in(cx, |s, _window, cx| {
2494            let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0);
2495            s.expanded_groups.insert(path_list.clone(), current + 1);
2496            s.update_entries(cx);
2497        });
2498        cx.run_until_parked();
2499
2500        // All 17 threads shown with Collapse button
2501        let entries = visible_entries_as_strings(&sidebar, cx);
2502        assert_eq!(entries.len(), 19); // header + 17 threads + Collapse
2503        assert!(!entries.iter().any(|e| e.contains("View More")));
2504        assert!(entries.iter().any(|e| e.contains("Collapse")));
2505
2506        // Click collapse - should go back to showing 5 threads
2507        sidebar.update_in(cx, |s, _window, cx| {
2508            s.expanded_groups.remove(&path_list);
2509            s.update_entries(cx);
2510        });
2511        cx.run_until_parked();
2512
2513        // Back to initial state: 5 threads + View More (12 remaining)
2514        let entries = visible_entries_as_strings(&sidebar, cx);
2515        assert_eq!(entries.len(), 7); // header + 5 threads + View More
2516        assert!(entries.iter().any(|e| e.contains("View More (12)")));
2517    }
2518
2519    #[gpui::test]
2520    async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
2521        let project = init_test_project("/my-project", cx).await;
2522        let (multi_workspace, cx) =
2523            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2524        let sidebar = setup_sidebar(&multi_workspace, cx);
2525
2526        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2527        save_n_test_threads(1, &path_list, cx).await;
2528
2529        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2530        cx.run_until_parked();
2531
2532        assert_eq!(
2533            visible_entries_as_strings(&sidebar, cx),
2534            vec!["v [my-project]", "  Thread 1"]
2535        );
2536
2537        // Collapse
2538        sidebar.update_in(cx, |s, window, cx| {
2539            s.toggle_collapse(&path_list, window, cx);
2540        });
2541        cx.run_until_parked();
2542
2543        assert_eq!(
2544            visible_entries_as_strings(&sidebar, cx),
2545            vec!["> [my-project]"]
2546        );
2547
2548        // Expand
2549        sidebar.update_in(cx, |s, window, cx| {
2550            s.toggle_collapse(&path_list, window, cx);
2551        });
2552        cx.run_until_parked();
2553
2554        assert_eq!(
2555            visible_entries_as_strings(&sidebar, cx),
2556            vec!["v [my-project]", "  Thread 1"]
2557        );
2558    }
2559
2560    #[gpui::test]
2561    async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
2562        let project = init_test_project("/my-project", cx).await;
2563        let (multi_workspace, cx) =
2564            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2565        let sidebar = setup_sidebar(&multi_workspace, cx);
2566
2567        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2568        let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]);
2569        let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]);
2570
2571        sidebar.update_in(cx, |s, _window, _cx| {
2572            s.collapsed_groups.insert(collapsed_path.clone());
2573            s.contents
2574                .notified_threads
2575                .insert(acp::SessionId::new(Arc::from("t-5")));
2576            s.contents.entries = vec![
2577                // Expanded project header
2578                ListEntry::ProjectHeader {
2579                    path_list: expanded_path.clone(),
2580                    label: "expanded-project".into(),
2581                    workspace: workspace.clone(),
2582                    highlight_positions: Vec::new(),
2583                    has_threads: true,
2584                },
2585                // Thread with default (Completed) status, not active
2586                ListEntry::Thread(ThreadEntry {
2587                    agent: Agent::NativeAgent,
2588                    session_info: acp_thread::AgentSessionInfo {
2589                        session_id: acp::SessionId::new(Arc::from("t-1")),
2590                        cwd: None,
2591                        title: Some("Completed thread".into()),
2592                        updated_at: Some(Utc::now()),
2593                        created_at: Some(Utc::now()),
2594                        meta: None,
2595                    },
2596                    icon: IconName::ZedAgent,
2597                    icon_from_external_svg: None,
2598                    status: AgentThreadStatus::Completed,
2599                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
2600                    is_live: false,
2601                    is_background: false,
2602                    highlight_positions: Vec::new(),
2603                    worktree_name: None,
2604                    worktree_highlight_positions: Vec::new(),
2605                    diff_stats: DiffStats::default(),
2606                }),
2607                // Active thread with Running status
2608                ListEntry::Thread(ThreadEntry {
2609                    agent: Agent::NativeAgent,
2610                    session_info: acp_thread::AgentSessionInfo {
2611                        session_id: acp::SessionId::new(Arc::from("t-2")),
2612                        cwd: None,
2613                        title: Some("Running thread".into()),
2614                        updated_at: Some(Utc::now()),
2615                        created_at: Some(Utc::now()),
2616                        meta: None,
2617                    },
2618                    icon: IconName::ZedAgent,
2619                    icon_from_external_svg: None,
2620                    status: AgentThreadStatus::Running,
2621                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
2622                    is_live: true,
2623                    is_background: false,
2624                    highlight_positions: Vec::new(),
2625                    worktree_name: None,
2626                    worktree_highlight_positions: Vec::new(),
2627                    diff_stats: DiffStats::default(),
2628                }),
2629                // Active thread with Error status
2630                ListEntry::Thread(ThreadEntry {
2631                    agent: Agent::NativeAgent,
2632                    session_info: acp_thread::AgentSessionInfo {
2633                        session_id: acp::SessionId::new(Arc::from("t-3")),
2634                        cwd: None,
2635                        title: Some("Error thread".into()),
2636                        updated_at: Some(Utc::now()),
2637                        created_at: Some(Utc::now()),
2638                        meta: None,
2639                    },
2640                    icon: IconName::ZedAgent,
2641                    icon_from_external_svg: None,
2642                    status: AgentThreadStatus::Error,
2643                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
2644                    is_live: true,
2645                    is_background: false,
2646                    highlight_positions: Vec::new(),
2647                    worktree_name: None,
2648                    worktree_highlight_positions: Vec::new(),
2649                    diff_stats: DiffStats::default(),
2650                }),
2651                // Thread with WaitingForConfirmation status, not active
2652                ListEntry::Thread(ThreadEntry {
2653                    agent: Agent::NativeAgent,
2654                    session_info: acp_thread::AgentSessionInfo {
2655                        session_id: acp::SessionId::new(Arc::from("t-4")),
2656                        cwd: None,
2657                        title: Some("Waiting thread".into()),
2658                        updated_at: Some(Utc::now()),
2659                        created_at: Some(Utc::now()),
2660                        meta: None,
2661                    },
2662                    icon: IconName::ZedAgent,
2663                    icon_from_external_svg: None,
2664                    status: AgentThreadStatus::WaitingForConfirmation,
2665                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
2666                    is_live: false,
2667                    is_background: false,
2668                    highlight_positions: Vec::new(),
2669                    worktree_name: None,
2670                    worktree_highlight_positions: Vec::new(),
2671                    diff_stats: DiffStats::default(),
2672                }),
2673                // Background thread that completed (should show notification)
2674                ListEntry::Thread(ThreadEntry {
2675                    agent: Agent::NativeAgent,
2676                    session_info: acp_thread::AgentSessionInfo {
2677                        session_id: acp::SessionId::new(Arc::from("t-5")),
2678                        cwd: None,
2679                        title: Some("Notified thread".into()),
2680                        updated_at: Some(Utc::now()),
2681                        created_at: Some(Utc::now()),
2682                        meta: None,
2683                    },
2684                    icon: IconName::ZedAgent,
2685                    icon_from_external_svg: None,
2686                    status: AgentThreadStatus::Completed,
2687                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
2688                    is_live: true,
2689                    is_background: true,
2690                    highlight_positions: Vec::new(),
2691                    worktree_name: None,
2692                    worktree_highlight_positions: Vec::new(),
2693                    diff_stats: DiffStats::default(),
2694                }),
2695                // View More entry
2696                ListEntry::ViewMore {
2697                    path_list: expanded_path.clone(),
2698                    remaining_count: 42,
2699                    is_fully_expanded: false,
2700                },
2701                // Collapsed project header
2702                ListEntry::ProjectHeader {
2703                    path_list: collapsed_path.clone(),
2704                    label: "collapsed-project".into(),
2705                    workspace: workspace.clone(),
2706                    highlight_positions: Vec::new(),
2707                    has_threads: true,
2708                },
2709            ];
2710            // Select the Running thread (index 2)
2711            s.selection = Some(2);
2712        });
2713
2714        assert_eq!(
2715            visible_entries_as_strings(&sidebar, cx),
2716            vec![
2717                "v [expanded-project]",
2718                "  Completed thread",
2719                "  Running thread * (running)  <== selected",
2720                "  Error thread * (error)",
2721                "  Waiting thread (waiting)",
2722                "  Notified thread * (!)",
2723                "  + View More (42)",
2724                "> [collapsed-project]",
2725            ]
2726        );
2727
2728        // Move selection to the collapsed header
2729        sidebar.update_in(cx, |s, _window, _cx| {
2730            s.selection = Some(7);
2731        });
2732
2733        assert_eq!(
2734            visible_entries_as_strings(&sidebar, cx).last().cloned(),
2735            Some("> [collapsed-project]  <== selected".to_string()),
2736        );
2737
2738        // Clear selection
2739        sidebar.update_in(cx, |s, _window, _cx| {
2740            s.selection = None;
2741        });
2742
2743        // No entry should have the selected marker
2744        let entries = visible_entries_as_strings(&sidebar, cx);
2745        for entry in &entries {
2746            assert!(
2747                !entry.contains("<== selected"),
2748                "unexpected selection marker in: {}",
2749                entry
2750            );
2751        }
2752    }
2753
2754    #[gpui::test]
2755    async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
2756        let project = init_test_project("/my-project", cx).await;
2757        let (multi_workspace, cx) =
2758            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2759        let sidebar = setup_sidebar(&multi_workspace, cx);
2760
2761        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2762        save_n_test_threads(3, &path_list, cx).await;
2763
2764        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2765        cx.run_until_parked();
2766
2767        // Entries: [header, thread3, thread2, thread1]
2768        // Focusing the sidebar does not set a selection; select_next/select_previous
2769        // handle None gracefully by starting from the first or last entry.
2770        open_and_focus_sidebar(&sidebar, cx);
2771        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
2772
2773        // First SelectNext from None starts at index 0
2774        cx.dispatch_action(SelectNext);
2775        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2776
2777        // Move down through remaining entries
2778        cx.dispatch_action(SelectNext);
2779        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
2780
2781        cx.dispatch_action(SelectNext);
2782        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
2783
2784        cx.dispatch_action(SelectNext);
2785        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
2786
2787        // At the end, selection stays on the last entry
2788        cx.dispatch_action(SelectNext);
2789        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
2790
2791        // Move back up
2792
2793        cx.dispatch_action(SelectPrevious);
2794        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
2795
2796        cx.dispatch_action(SelectPrevious);
2797        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
2798
2799        cx.dispatch_action(SelectPrevious);
2800        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2801
2802        // At the top, selection stays on the first entry
2803        cx.dispatch_action(SelectPrevious);
2804        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2805    }
2806
2807    #[gpui::test]
2808    async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
2809        let project = init_test_project("/my-project", cx).await;
2810        let (multi_workspace, cx) =
2811            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2812        let sidebar = setup_sidebar(&multi_workspace, cx);
2813
2814        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2815        save_n_test_threads(3, &path_list, cx).await;
2816        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2817        cx.run_until_parked();
2818
2819        open_and_focus_sidebar(&sidebar, cx);
2820
2821        // SelectLast jumps to the end
2822        cx.dispatch_action(SelectLast);
2823        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
2824
2825        // SelectFirst jumps to the beginning
2826        cx.dispatch_action(SelectFirst);
2827        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2828    }
2829
2830    #[gpui::test]
2831    async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) {
2832        let project = init_test_project("/my-project", cx).await;
2833        let (multi_workspace, cx) =
2834            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2835        let sidebar = setup_sidebar(&multi_workspace, cx);
2836
2837        // Initially no selection
2838        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
2839
2840        // Open the sidebar so it's rendered, then focus it to trigger focus_in.
2841        // focus_in no longer sets a default selection.
2842        open_and_focus_sidebar(&sidebar, cx);
2843        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
2844
2845        // Manually set a selection, blur, then refocus — selection should be preserved
2846        sidebar.update_in(cx, |sidebar, _window, _cx| {
2847            sidebar.selection = Some(0);
2848        });
2849
2850        cx.update(|window, _cx| {
2851            window.blur();
2852        });
2853        cx.run_until_parked();
2854
2855        sidebar.update_in(cx, |_, window, cx| {
2856            cx.focus_self(window);
2857        });
2858        cx.run_until_parked();
2859        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2860    }
2861
2862    #[gpui::test]
2863    async fn test_keyboard_confirm_on_project_header_activates_workspace(cx: &mut TestAppContext) {
2864        let project = init_test_project("/my-project", cx).await;
2865        let (multi_workspace, cx) =
2866            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2867        let sidebar = setup_sidebar(&multi_workspace, cx);
2868
2869        multi_workspace.update_in(cx, |mw, window, cx| {
2870            mw.create_workspace(window, cx);
2871        });
2872        cx.run_until_parked();
2873
2874        // Add an agent panel to workspace 1 so the sidebar renders when it's active.
2875        setup_sidebar_with_agent_panel(&multi_workspace, cx);
2876
2877        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2878        save_n_test_threads(1, &path_list, cx).await;
2879        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2880        cx.run_until_parked();
2881
2882        assert_eq!(
2883            visible_entries_as_strings(&sidebar, cx),
2884            vec![
2885                "v [my-project]",
2886                "  Thread 1",
2887                "v [Empty Workspace]",
2888                "  [+ New Thread]",
2889            ]
2890        );
2891
2892        // Switch to workspace 1 so we can verify confirm switches back.
2893        multi_workspace.update_in(cx, |mw, window, cx| {
2894            mw.activate_index(1, window, cx);
2895        });
2896        cx.run_until_parked();
2897        assert_eq!(
2898            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
2899            1
2900        );
2901
2902        // Focus the sidebar and manually select the header (index 0)
2903        open_and_focus_sidebar(&sidebar, cx);
2904        sidebar.update_in(cx, |sidebar, _window, _cx| {
2905            sidebar.selection = Some(0);
2906        });
2907
2908        // Press confirm on project header (workspace 0) to activate it.
2909        cx.dispatch_action(Confirm);
2910        cx.run_until_parked();
2911
2912        assert_eq!(
2913            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
2914            0
2915        );
2916
2917        // Focus should have moved out of the sidebar to the workspace center.
2918        let workspace_0 = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone());
2919        workspace_0.update_in(cx, |workspace, window, cx| {
2920            let pane_focus = workspace.active_pane().read(cx).focus_handle(cx);
2921            assert!(
2922                pane_focus.contains_focused(window, cx),
2923                "Confirming a project header should focus the workspace center pane"
2924            );
2925        });
2926    }
2927
2928    #[gpui::test]
2929    async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) {
2930        let project = init_test_project("/my-project", cx).await;
2931        let (multi_workspace, cx) =
2932            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2933        let sidebar = setup_sidebar(&multi_workspace, cx);
2934
2935        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2936        save_n_test_threads(8, &path_list, cx).await;
2937        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2938        cx.run_until_parked();
2939
2940        // Should show header + 5 threads + "View More (3)"
2941        let entries = visible_entries_as_strings(&sidebar, cx);
2942        assert_eq!(entries.len(), 7);
2943        assert!(entries.iter().any(|e| e.contains("View More (3)")));
2944
2945        // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6)
2946        open_and_focus_sidebar(&sidebar, cx);
2947        for _ in 0..7 {
2948            cx.dispatch_action(SelectNext);
2949        }
2950        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6));
2951
2952        // Confirm on "View More" to expand
2953        cx.dispatch_action(Confirm);
2954        cx.run_until_parked();
2955
2956        // All 8 threads should now be visible with a "Collapse" button
2957        let entries = visible_entries_as_strings(&sidebar, cx);
2958        assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button
2959        assert!(!entries.iter().any(|e| e.contains("View More")));
2960        assert!(entries.iter().any(|e| e.contains("Collapse")));
2961    }
2962
2963    #[gpui::test]
2964    async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
2965        let project = init_test_project("/my-project", cx).await;
2966        let (multi_workspace, cx) =
2967            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2968        let sidebar = setup_sidebar(&multi_workspace, cx);
2969
2970        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2971        save_n_test_threads(1, &path_list, cx).await;
2972        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2973        cx.run_until_parked();
2974
2975        assert_eq!(
2976            visible_entries_as_strings(&sidebar, cx),
2977            vec!["v [my-project]", "  Thread 1"]
2978        );
2979
2980        // Focus sidebar and manually select the header (index 0). Press left to collapse.
2981        open_and_focus_sidebar(&sidebar, cx);
2982        sidebar.update_in(cx, |sidebar, _window, _cx| {
2983            sidebar.selection = Some(0);
2984        });
2985
2986        cx.dispatch_action(CollapseSelectedEntry);
2987        cx.run_until_parked();
2988
2989        assert_eq!(
2990            visible_entries_as_strings(&sidebar, cx),
2991            vec!["> [my-project]  <== selected"]
2992        );
2993
2994        // Press right to expand
2995        cx.dispatch_action(ExpandSelectedEntry);
2996        cx.run_until_parked();
2997
2998        assert_eq!(
2999            visible_entries_as_strings(&sidebar, cx),
3000            vec!["v [my-project]  <== selected", "  Thread 1",]
3001        );
3002
3003        // Press right again on already-expanded header moves selection down
3004        cx.dispatch_action(ExpandSelectedEntry);
3005        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
3006    }
3007
3008    #[gpui::test]
3009    async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) {
3010        let project = init_test_project("/my-project", cx).await;
3011        let (multi_workspace, cx) =
3012            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3013        let sidebar = setup_sidebar(&multi_workspace, cx);
3014
3015        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3016        save_n_test_threads(1, &path_list, cx).await;
3017        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3018        cx.run_until_parked();
3019
3020        // Focus sidebar (selection starts at None), then navigate down to the thread (child)
3021        open_and_focus_sidebar(&sidebar, cx);
3022        cx.dispatch_action(SelectNext);
3023        cx.dispatch_action(SelectNext);
3024        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
3025
3026        assert_eq!(
3027            visible_entries_as_strings(&sidebar, cx),
3028            vec!["v [my-project]", "  Thread 1  <== selected",]
3029        );
3030
3031        // Pressing left on a child collapses the parent group and selects it
3032        cx.dispatch_action(CollapseSelectedEntry);
3033        cx.run_until_parked();
3034
3035        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
3036        assert_eq!(
3037            visible_entries_as_strings(&sidebar, cx),
3038            vec!["> [my-project]  <== selected"]
3039        );
3040    }
3041
3042    #[gpui::test]
3043    async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
3044        let project = init_test_project("/empty-project", cx).await;
3045        let (multi_workspace, cx) =
3046            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3047        let sidebar = setup_sidebar(&multi_workspace, cx);
3048
3049        // Even an empty project has the header and a new thread button
3050        assert_eq!(
3051            visible_entries_as_strings(&sidebar, cx),
3052            vec!["v [empty-project]", "  [+ New Thread]"]
3053        );
3054
3055        // Focus sidebar — focus_in does not set a selection
3056        open_and_focus_sidebar(&sidebar, cx);
3057        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
3058
3059        // First SelectNext from None starts at index 0 (header)
3060        cx.dispatch_action(SelectNext);
3061        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
3062
3063        // SelectNext moves to the new thread button
3064        cx.dispatch_action(SelectNext);
3065        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
3066
3067        // At the end, selection stays on the last entry
3068        cx.dispatch_action(SelectNext);
3069        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
3070
3071        // SelectPrevious goes back to the header
3072        cx.dispatch_action(SelectPrevious);
3073        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
3074    }
3075
3076    #[gpui::test]
3077    async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
3078        let project = init_test_project("/my-project", cx).await;
3079        let (multi_workspace, cx) =
3080            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3081        let sidebar = setup_sidebar(&multi_workspace, cx);
3082
3083        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3084        save_n_test_threads(1, &path_list, cx).await;
3085        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3086        cx.run_until_parked();
3087
3088        // Focus sidebar (selection starts at None), navigate down to the thread (index 1)
3089        open_and_focus_sidebar(&sidebar, cx);
3090        cx.dispatch_action(SelectNext);
3091        cx.dispatch_action(SelectNext);
3092        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
3093
3094        // Collapse the group, which removes the thread from the list
3095        cx.dispatch_action(CollapseSelectedEntry);
3096        cx.run_until_parked();
3097
3098        // Selection should be clamped to the last valid index (0 = header)
3099        let selection = sidebar.read_with(cx, |s, _| s.selection);
3100        let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len());
3101        assert!(
3102            selection.unwrap_or(0) < entry_count,
3103            "selection {} should be within bounds (entries: {})",
3104            selection.unwrap_or(0),
3105            entry_count,
3106        );
3107    }
3108
3109    fn add_agent_panel(
3110        workspace: &Entity<Workspace>,
3111        project: &Entity<project::Project>,
3112        cx: &mut gpui::VisualTestContext,
3113    ) -> Entity<AgentPanel> {
3114        workspace.update_in(cx, |workspace, window, cx| {
3115            let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
3116            let panel = cx.new(|cx| AgentPanel::test_new(workspace, text_thread_store, window, cx));
3117            workspace.add_panel(panel.clone(), window, cx);
3118            panel
3119        })
3120    }
3121
3122    #[gpui::test]
3123    async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
3124        let project = init_test_project("/my-project", cx).await;
3125        let (multi_workspace, cx) =
3126            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3127        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
3128
3129        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3130
3131        // Open thread A and keep it generating.
3132        let connection = StubAgentConnection::new();
3133        open_thread_with_connection(&panel, connection.clone(), cx);
3134        send_message(&panel, cx);
3135
3136        let session_id_a = active_session_id(&panel, cx);
3137        save_thread_to_store(&session_id_a, &path_list, cx).await;
3138
3139        cx.update(|_, cx| {
3140            connection.send_update(
3141                session_id_a.clone(),
3142                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
3143                cx,
3144            );
3145        });
3146        cx.run_until_parked();
3147
3148        // Open thread B (idle, default response) — thread A goes to background.
3149        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3150            acp::ContentChunk::new("Done".into()),
3151        )]);
3152        open_thread_with_connection(&panel, connection, cx);
3153        send_message(&panel, cx);
3154
3155        let session_id_b = active_session_id(&panel, cx);
3156        save_thread_to_store(&session_id_b, &path_list, cx).await;
3157
3158        cx.run_until_parked();
3159
3160        let mut entries = visible_entries_as_strings(&sidebar, cx);
3161        entries[1..].sort();
3162        assert_eq!(
3163            entries,
3164            vec!["v [my-project]", "  Hello *", "  Hello * (running)",]
3165        );
3166    }
3167
3168    #[gpui::test]
3169    async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
3170        let project_a = init_test_project("/project-a", cx).await;
3171        let (multi_workspace, cx) = cx
3172            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
3173        let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
3174
3175        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
3176
3177        // Open thread on workspace A and keep it generating.
3178        let connection_a = StubAgentConnection::new();
3179        open_thread_with_connection(&panel_a, connection_a.clone(), cx);
3180        send_message(&panel_a, cx);
3181
3182        let session_id_a = active_session_id(&panel_a, cx);
3183        save_thread_to_store(&session_id_a, &path_list_a, cx).await;
3184
3185        cx.update(|_, cx| {
3186            connection_a.send_update(
3187                session_id_a.clone(),
3188                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
3189                cx,
3190            );
3191        });
3192        cx.run_until_parked();
3193
3194        // Add a second workspace and activate it (making workspace A the background).
3195        let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
3196        let project_b = project::Project::test(fs, [], cx).await;
3197        multi_workspace.update_in(cx, |mw, window, cx| {
3198            mw.test_add_workspace(project_b, window, cx);
3199        });
3200        cx.run_until_parked();
3201
3202        // Thread A is still running; no notification yet.
3203        assert_eq!(
3204            visible_entries_as_strings(&sidebar, cx),
3205            vec![
3206                "v [project-a]",
3207                "  Hello * (running)",
3208                "v [Empty Workspace]",
3209                "  [+ New Thread]",
3210            ]
3211        );
3212
3213        // Complete thread A's turn (transition Running → Completed).
3214        connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn);
3215        cx.run_until_parked();
3216
3217        // The completed background thread shows a notification indicator.
3218        assert_eq!(
3219            visible_entries_as_strings(&sidebar, cx),
3220            vec![
3221                "v [project-a]",
3222                "  Hello * (!)",
3223                "v [Empty Workspace]",
3224                "  [+ New Thread]",
3225            ]
3226        );
3227    }
3228
3229    fn type_in_search(sidebar: &Entity<Sidebar>, query: &str, cx: &mut gpui::VisualTestContext) {
3230        sidebar.update_in(cx, |sidebar, window, cx| {
3231            window.focus(&sidebar.filter_editor.focus_handle(cx), cx);
3232            sidebar.filter_editor.update(cx, |editor, cx| {
3233                editor.set_text(query, window, cx);
3234            });
3235        });
3236        cx.run_until_parked();
3237    }
3238
3239    #[gpui::test]
3240    async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) {
3241        let project = init_test_project("/my-project", cx).await;
3242        let (multi_workspace, cx) =
3243            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3244        let sidebar = setup_sidebar(&multi_workspace, cx);
3245
3246        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3247        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3248
3249        for (id, title, hour) in [
3250            ("t-1", "Fix crash in project panel", 3),
3251            ("t-2", "Add inline diff view", 2),
3252            ("t-3", "Refactor settings module", 1),
3253        ] {
3254            let save_task = thread_store.update(cx, |store, cx| {
3255                store.save_thread(
3256                    acp::SessionId::new(Arc::from(id)),
3257                    make_test_thread(
3258                        title,
3259                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3260                    ),
3261                    path_list.clone(),
3262                    cx,
3263                )
3264            });
3265            save_task.await.unwrap();
3266        }
3267        cx.run_until_parked();
3268
3269        assert_eq!(
3270            visible_entries_as_strings(&sidebar, cx),
3271            vec![
3272                "v [my-project]",
3273                "  Fix crash in project panel",
3274                "  Add inline diff view",
3275                "  Refactor settings module",
3276            ]
3277        );
3278
3279        // User types "diff" in the search box — only the matching thread remains,
3280        // with its workspace header preserved for context.
3281        type_in_search(&sidebar, "diff", cx);
3282        assert_eq!(
3283            visible_entries_as_strings(&sidebar, cx),
3284            vec!["v [my-project]", "  Add inline diff view  <== selected",]
3285        );
3286
3287        // User changes query to something with no matches — list is empty.
3288        type_in_search(&sidebar, "nonexistent", cx);
3289        assert_eq!(
3290            visible_entries_as_strings(&sidebar, cx),
3291            Vec::<String>::new()
3292        );
3293    }
3294
3295    #[gpui::test]
3296    async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
3297        // Scenario: A user remembers a thread title but not the exact casing.
3298        // Search should match case-insensitively so they can still find it.
3299        let project = init_test_project("/my-project", cx).await;
3300        let (multi_workspace, cx) =
3301            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3302        let sidebar = setup_sidebar(&multi_workspace, cx);
3303
3304        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3305        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3306
3307        let save_task = thread_store.update(cx, |store, cx| {
3308            store.save_thread(
3309                acp::SessionId::new(Arc::from("thread-1")),
3310                make_test_thread(
3311                    "Fix Crash In Project Panel",
3312                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3313                ),
3314                path_list.clone(),
3315                cx,
3316            )
3317        });
3318        save_task.await.unwrap();
3319        cx.run_until_parked();
3320
3321        // Lowercase query matches mixed-case title.
3322        type_in_search(&sidebar, "fix crash", cx);
3323        assert_eq!(
3324            visible_entries_as_strings(&sidebar, cx),
3325            vec![
3326                "v [my-project]",
3327                "  Fix Crash In Project Panel  <== selected",
3328            ]
3329        );
3330
3331        // Uppercase query also matches the same title.
3332        type_in_search(&sidebar, "FIX CRASH", cx);
3333        assert_eq!(
3334            visible_entries_as_strings(&sidebar, cx),
3335            vec![
3336                "v [my-project]",
3337                "  Fix Crash In Project Panel  <== selected",
3338            ]
3339        );
3340    }
3341
3342    #[gpui::test]
3343    async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) {
3344        // Scenario: A user searches, finds what they need, then presses Escape
3345        // to dismiss the filter and see the full list again.
3346        let project = init_test_project("/my-project", cx).await;
3347        let (multi_workspace, cx) =
3348            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3349        let sidebar = setup_sidebar(&multi_workspace, cx);
3350
3351        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3352        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3353
3354        for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] {
3355            let save_task = thread_store.update(cx, |store, cx| {
3356                store.save_thread(
3357                    acp::SessionId::new(Arc::from(id)),
3358                    make_test_thread(
3359                        title,
3360                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3361                    ),
3362                    path_list.clone(),
3363                    cx,
3364                )
3365            });
3366            save_task.await.unwrap();
3367        }
3368        cx.run_until_parked();
3369
3370        // Confirm the full list is showing.
3371        assert_eq!(
3372            visible_entries_as_strings(&sidebar, cx),
3373            vec!["v [my-project]", "  Alpha thread", "  Beta thread",]
3374        );
3375
3376        // User types a search query to filter down.
3377        open_and_focus_sidebar(&sidebar, cx);
3378        type_in_search(&sidebar, "alpha", cx);
3379        assert_eq!(
3380            visible_entries_as_strings(&sidebar, cx),
3381            vec!["v [my-project]", "  Alpha thread  <== selected",]
3382        );
3383
3384        // User presses Escape — filter clears, full list is restored.
3385        cx.dispatch_action(Cancel);
3386        cx.run_until_parked();
3387        assert_eq!(
3388            visible_entries_as_strings(&sidebar, cx),
3389            vec![
3390                "v [my-project]",
3391                "  Alpha thread  <== selected",
3392                "  Beta thread",
3393            ]
3394        );
3395    }
3396
3397    #[gpui::test]
3398    async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
3399        let project_a = init_test_project("/project-a", cx).await;
3400        let (multi_workspace, cx) =
3401            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3402        let sidebar = setup_sidebar(&multi_workspace, cx);
3403
3404        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
3405        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3406
3407        for (id, title, hour) in [
3408            ("a1", "Fix bug in sidebar", 2),
3409            ("a2", "Add tests for editor", 1),
3410        ] {
3411            let save_task = thread_store.update(cx, |store, cx| {
3412                store.save_thread(
3413                    acp::SessionId::new(Arc::from(id)),
3414                    make_test_thread(
3415                        title,
3416                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3417                    ),
3418                    path_list_a.clone(),
3419                    cx,
3420                )
3421            });
3422            save_task.await.unwrap();
3423        }
3424
3425        // Add a second workspace.
3426        multi_workspace.update_in(cx, |mw, window, cx| {
3427            mw.create_workspace(window, cx);
3428        });
3429        cx.run_until_parked();
3430
3431        let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
3432
3433        for (id, title, hour) in [
3434            ("b1", "Refactor sidebar layout", 3),
3435            ("b2", "Fix typo in README", 1),
3436        ] {
3437            let save_task = thread_store.update(cx, |store, cx| {
3438                store.save_thread(
3439                    acp::SessionId::new(Arc::from(id)),
3440                    make_test_thread(
3441                        title,
3442                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3443                    ),
3444                    path_list_b.clone(),
3445                    cx,
3446                )
3447            });
3448            save_task.await.unwrap();
3449        }
3450        cx.run_until_parked();
3451
3452        assert_eq!(
3453            visible_entries_as_strings(&sidebar, cx),
3454            vec![
3455                "v [project-a]",
3456                "  Fix bug in sidebar",
3457                "  Add tests for editor",
3458                "v [Empty Workspace]",
3459                "  Refactor sidebar layout",
3460                "  Fix typo in README",
3461            ]
3462        );
3463
3464        // "sidebar" matches a thread in each workspace — both headers stay visible.
3465        type_in_search(&sidebar, "sidebar", cx);
3466        assert_eq!(
3467            visible_entries_as_strings(&sidebar, cx),
3468            vec![
3469                "v [project-a]",
3470                "  Fix bug in sidebar  <== selected",
3471                "v [Empty Workspace]",
3472                "  Refactor sidebar layout",
3473            ]
3474        );
3475
3476        // "typo" only matches in the second workspace — the first header disappears.
3477        type_in_search(&sidebar, "typo", cx);
3478        assert_eq!(
3479            visible_entries_as_strings(&sidebar, cx),
3480            vec!["v [Empty Workspace]", "  Fix typo in README  <== selected",]
3481        );
3482
3483        // "project-a" matches the first workspace name — the header appears
3484        // with all child threads included.
3485        type_in_search(&sidebar, "project-a", cx);
3486        assert_eq!(
3487            visible_entries_as_strings(&sidebar, cx),
3488            vec![
3489                "v [project-a]",
3490                "  Fix bug in sidebar  <== selected",
3491                "  Add tests for editor",
3492            ]
3493        );
3494    }
3495
3496    #[gpui::test]
3497    async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
3498        let project_a = init_test_project("/alpha-project", cx).await;
3499        let (multi_workspace, cx) =
3500            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3501        let sidebar = setup_sidebar(&multi_workspace, cx);
3502
3503        let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]);
3504        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3505
3506        for (id, title, hour) in [
3507            ("a1", "Fix bug in sidebar", 2),
3508            ("a2", "Add tests for editor", 1),
3509        ] {
3510            let save_task = thread_store.update(cx, |store, cx| {
3511                store.save_thread(
3512                    acp::SessionId::new(Arc::from(id)),
3513                    make_test_thread(
3514                        title,
3515                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3516                    ),
3517                    path_list_a.clone(),
3518                    cx,
3519                )
3520            });
3521            save_task.await.unwrap();
3522        }
3523
3524        // Add a second workspace.
3525        multi_workspace.update_in(cx, |mw, window, cx| {
3526            mw.create_workspace(window, cx);
3527        });
3528        cx.run_until_parked();
3529
3530        let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
3531
3532        for (id, title, hour) in [
3533            ("b1", "Refactor sidebar layout", 3),
3534            ("b2", "Fix typo in README", 1),
3535        ] {
3536            let save_task = thread_store.update(cx, |store, cx| {
3537                store.save_thread(
3538                    acp::SessionId::new(Arc::from(id)),
3539                    make_test_thread(
3540                        title,
3541                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3542                    ),
3543                    path_list_b.clone(),
3544                    cx,
3545                )
3546            });
3547            save_task.await.unwrap();
3548        }
3549        cx.run_until_parked();
3550
3551        // "alpha" matches the workspace name "alpha-project" but no thread titles.
3552        // The workspace header should appear with all child threads included.
3553        type_in_search(&sidebar, "alpha", cx);
3554        assert_eq!(
3555            visible_entries_as_strings(&sidebar, cx),
3556            vec![
3557                "v [alpha-project]",
3558                "  Fix bug in sidebar  <== selected",
3559                "  Add tests for editor",
3560            ]
3561        );
3562
3563        // "sidebar" matches thread titles in both workspaces but not workspace names.
3564        // Both headers appear with their matching threads.
3565        type_in_search(&sidebar, "sidebar", cx);
3566        assert_eq!(
3567            visible_entries_as_strings(&sidebar, cx),
3568            vec![
3569                "v [alpha-project]",
3570                "  Fix bug in sidebar  <== selected",
3571                "v [Empty Workspace]",
3572                "  Refactor sidebar layout",
3573            ]
3574        );
3575
3576        // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r
3577        // doesn't match) — but does not match either workspace name or any thread.
3578        // Actually let's test something simpler: a query that matches both a workspace
3579        // name AND some threads in that workspace. Matching threads should still appear.
3580        type_in_search(&sidebar, "fix", cx);
3581        assert_eq!(
3582            visible_entries_as_strings(&sidebar, cx),
3583            vec![
3584                "v [alpha-project]",
3585                "  Fix bug in sidebar  <== selected",
3586                "v [Empty Workspace]",
3587                "  Fix typo in README",
3588            ]
3589        );
3590
3591        // A query that matches a workspace name AND a thread in that same workspace.
3592        // Both the header (highlighted) and all child threads should appear.
3593        type_in_search(&sidebar, "alpha", cx);
3594        assert_eq!(
3595            visible_entries_as_strings(&sidebar, cx),
3596            vec![
3597                "v [alpha-project]",
3598                "  Fix bug in sidebar  <== selected",
3599                "  Add tests for editor",
3600            ]
3601        );
3602
3603        // Now search for something that matches only a workspace name when there
3604        // are also threads with matching titles — the non-matching workspace's
3605        // threads should still appear if their titles match.
3606        type_in_search(&sidebar, "alp", cx);
3607        assert_eq!(
3608            visible_entries_as_strings(&sidebar, cx),
3609            vec![
3610                "v [alpha-project]",
3611                "  Fix bug in sidebar  <== selected",
3612                "  Add tests for editor",
3613            ]
3614        );
3615    }
3616
3617    #[gpui::test]
3618    async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) {
3619        let project = init_test_project("/my-project", cx).await;
3620        let (multi_workspace, cx) =
3621            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3622        let sidebar = setup_sidebar(&multi_workspace, cx);
3623
3624        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3625        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3626
3627        // Create 8 threads. The oldest one has a unique name and will be
3628        // behind View More (only 5 shown by default).
3629        for i in 0..8u32 {
3630            let title = if i == 0 {
3631                "Hidden gem thread".to_string()
3632            } else {
3633                format!("Thread {}", i + 1)
3634            };
3635            let save_task = thread_store.update(cx, |store, cx| {
3636                store.save_thread(
3637                    acp::SessionId::new(Arc::from(format!("thread-{}", i))),
3638                    make_test_thread(
3639                        &title,
3640                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
3641                    ),
3642                    path_list.clone(),
3643                    cx,
3644                )
3645            });
3646            save_task.await.unwrap();
3647        }
3648        cx.run_until_parked();
3649
3650        // Confirm the thread is not visible and View More is shown.
3651        let entries = visible_entries_as_strings(&sidebar, cx);
3652        assert!(
3653            entries.iter().any(|e| e.contains("View More")),
3654            "should have View More button"
3655        );
3656        assert!(
3657            !entries.iter().any(|e| e.contains("Hidden gem")),
3658            "Hidden gem should be behind View More"
3659        );
3660
3661        // User searches for the hidden thread — it appears, and View More is gone.
3662        type_in_search(&sidebar, "hidden gem", cx);
3663        let filtered = visible_entries_as_strings(&sidebar, cx);
3664        assert_eq!(
3665            filtered,
3666            vec!["v [my-project]", "  Hidden gem thread  <== selected",]
3667        );
3668        assert!(
3669            !filtered.iter().any(|e| e.contains("View More")),
3670            "View More should not appear when filtering"
3671        );
3672    }
3673
3674    #[gpui::test]
3675    async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
3676        let project = init_test_project("/my-project", cx).await;
3677        let (multi_workspace, cx) =
3678            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3679        let sidebar = setup_sidebar(&multi_workspace, cx);
3680
3681        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3682        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3683
3684        let save_task = thread_store.update(cx, |store, cx| {
3685            store.save_thread(
3686                acp::SessionId::new(Arc::from("thread-1")),
3687                make_test_thread(
3688                    "Important thread",
3689                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3690                ),
3691                path_list.clone(),
3692                cx,
3693            )
3694        });
3695        save_task.await.unwrap();
3696        cx.run_until_parked();
3697
3698        // User focuses the sidebar and collapses the group using keyboard:
3699        // manually select the header, then press CollapseSelectedEntry to collapse.
3700        open_and_focus_sidebar(&sidebar, cx);
3701        sidebar.update_in(cx, |sidebar, _window, _cx| {
3702            sidebar.selection = Some(0);
3703        });
3704        cx.dispatch_action(CollapseSelectedEntry);
3705        cx.run_until_parked();
3706
3707        assert_eq!(
3708            visible_entries_as_strings(&sidebar, cx),
3709            vec!["> [my-project]  <== selected"]
3710        );
3711
3712        // User types a search — the thread appears even though its group is collapsed.
3713        type_in_search(&sidebar, "important", cx);
3714        assert_eq!(
3715            visible_entries_as_strings(&sidebar, cx),
3716            vec!["> [my-project]", "  Important thread  <== selected",]
3717        );
3718    }
3719
3720    #[gpui::test]
3721    async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
3722        let project = init_test_project("/my-project", cx).await;
3723        let (multi_workspace, cx) =
3724            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3725        let sidebar = setup_sidebar(&multi_workspace, cx);
3726
3727        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3728        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3729
3730        for (id, title, hour) in [
3731            ("t-1", "Fix crash in panel", 3),
3732            ("t-2", "Fix lint warnings", 2),
3733            ("t-3", "Add new feature", 1),
3734        ] {
3735            let save_task = thread_store.update(cx, |store, cx| {
3736                store.save_thread(
3737                    acp::SessionId::new(Arc::from(id)),
3738                    make_test_thread(
3739                        title,
3740                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3741                    ),
3742                    path_list.clone(),
3743                    cx,
3744                )
3745            });
3746            save_task.await.unwrap();
3747        }
3748        cx.run_until_parked();
3749
3750        open_and_focus_sidebar(&sidebar, cx);
3751
3752        // User types "fix" — two threads match.
3753        type_in_search(&sidebar, "fix", cx);
3754        assert_eq!(
3755            visible_entries_as_strings(&sidebar, cx),
3756            vec![
3757                "v [my-project]",
3758                "  Fix crash in panel  <== selected",
3759                "  Fix lint warnings",
3760            ]
3761        );
3762
3763        // Selection starts on the first matching thread. User presses
3764        // SelectNext to move to the second match.
3765        cx.dispatch_action(SelectNext);
3766        assert_eq!(
3767            visible_entries_as_strings(&sidebar, cx),
3768            vec![
3769                "v [my-project]",
3770                "  Fix crash in panel",
3771                "  Fix lint warnings  <== selected",
3772            ]
3773        );
3774
3775        // User can also jump back with SelectPrevious.
3776        cx.dispatch_action(SelectPrevious);
3777        assert_eq!(
3778            visible_entries_as_strings(&sidebar, cx),
3779            vec![
3780                "v [my-project]",
3781                "  Fix crash in panel  <== selected",
3782                "  Fix lint warnings",
3783            ]
3784        );
3785    }
3786
3787    #[gpui::test]
3788    async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) {
3789        let project = init_test_project("/my-project", cx).await;
3790        let (multi_workspace, cx) =
3791            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3792        let sidebar = setup_sidebar(&multi_workspace, cx);
3793
3794        multi_workspace.update_in(cx, |mw, window, cx| {
3795            mw.create_workspace(window, cx);
3796        });
3797        cx.run_until_parked();
3798
3799        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3800        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3801
3802        let save_task = thread_store.update(cx, |store, cx| {
3803            store.save_thread(
3804                acp::SessionId::new(Arc::from("hist-1")),
3805                make_test_thread(
3806                    "Historical Thread",
3807                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
3808                ),
3809                path_list.clone(),
3810                cx,
3811            )
3812        });
3813        save_task.await.unwrap();
3814        cx.run_until_parked();
3815        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3816        cx.run_until_parked();
3817
3818        assert_eq!(
3819            visible_entries_as_strings(&sidebar, cx),
3820            vec![
3821                "v [my-project]",
3822                "  Historical Thread",
3823                "v [Empty Workspace]",
3824                "  [+ New Thread]",
3825            ]
3826        );
3827
3828        // Switch to workspace 1 so we can verify the confirm switches back.
3829        multi_workspace.update_in(cx, |mw, window, cx| {
3830            mw.activate_index(1, window, cx);
3831        });
3832        cx.run_until_parked();
3833        assert_eq!(
3834            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
3835            1
3836        );
3837
3838        // Confirm on the historical (non-live) thread at index 1.
3839        // Before a previous fix, the workspace field was Option<usize> and
3840        // historical threads had None, so activate_thread early-returned
3841        // without switching the workspace.
3842        sidebar.update_in(cx, |sidebar, window, cx| {
3843            sidebar.selection = Some(1);
3844            sidebar.confirm(&Confirm, window, cx);
3845        });
3846        cx.run_until_parked();
3847
3848        assert_eq!(
3849            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
3850            0
3851        );
3852    }
3853
3854    #[gpui::test]
3855    async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
3856        let project = init_test_project("/my-project", cx).await;
3857        let (multi_workspace, cx) =
3858            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3859        let sidebar = setup_sidebar(&multi_workspace, cx);
3860
3861        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3862        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3863
3864        let save_task = thread_store.update(cx, |store, cx| {
3865            store.save_thread(
3866                acp::SessionId::new(Arc::from("t-1")),
3867                make_test_thread(
3868                    "Thread A",
3869                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
3870                ),
3871                path_list.clone(),
3872                cx,
3873            )
3874        });
3875        save_task.await.unwrap();
3876        let save_task = thread_store.update(cx, |store, cx| {
3877            store.save_thread(
3878                acp::SessionId::new(Arc::from("t-2")),
3879                make_test_thread(
3880                    "Thread B",
3881                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3882                ),
3883                path_list.clone(),
3884                cx,
3885            )
3886        });
3887        save_task.await.unwrap();
3888        cx.run_until_parked();
3889        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3890        cx.run_until_parked();
3891
3892        assert_eq!(
3893            visible_entries_as_strings(&sidebar, cx),
3894            vec!["v [my-project]", "  Thread A", "  Thread B",]
3895        );
3896
3897        // Keyboard confirm preserves selection.
3898        sidebar.update_in(cx, |sidebar, window, cx| {
3899            sidebar.selection = Some(1);
3900            sidebar.confirm(&Confirm, window, cx);
3901        });
3902        assert_eq!(
3903            sidebar.read_with(cx, |sidebar, _| sidebar.selection),
3904            Some(1)
3905        );
3906
3907        // Click handlers clear selection to None so no highlight lingers
3908        // after a click regardless of focus state. The hover style provides
3909        // visual feedback during mouse interaction instead.
3910        sidebar.update_in(cx, |sidebar, window, cx| {
3911            sidebar.selection = None;
3912            let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3913            sidebar.toggle_collapse(&path_list, window, cx);
3914        });
3915        assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
3916
3917        // When the user tabs back into the sidebar, focus_in no longer
3918        // restores selection — it stays None.
3919        sidebar.update_in(cx, |sidebar, window, cx| {
3920            sidebar.focus_in(window, cx);
3921        });
3922        assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
3923    }
3924
3925    #[gpui::test]
3926    async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
3927        let project = init_test_project("/my-project", cx).await;
3928        let (multi_workspace, cx) =
3929            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3930        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
3931
3932        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3933
3934        let connection = StubAgentConnection::new();
3935        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3936            acp::ContentChunk::new("Hi there!".into()),
3937        )]);
3938        open_thread_with_connection(&panel, connection, cx);
3939        send_message(&panel, cx);
3940
3941        let session_id = active_session_id(&panel, cx);
3942        save_thread_to_store(&session_id, &path_list, cx).await;
3943        cx.run_until_parked();
3944
3945        assert_eq!(
3946            visible_entries_as_strings(&sidebar, cx),
3947            vec!["v [my-project]", "  Hello *"]
3948        );
3949
3950        // Simulate the agent generating a title. The notification chain is:
3951        // AcpThread::set_title emits TitleUpdated →
3952        // ConnectionView::handle_thread_event calls cx.notify() →
3953        // AgentPanel observer fires and emits AgentPanelEvent →
3954        // Sidebar subscription calls update_entries / rebuild_contents.
3955        //
3956        // Before the fix, handle_thread_event did NOT call cx.notify() for
3957        // TitleUpdated, so the AgentPanel observer never fired and the
3958        // sidebar kept showing the old title.
3959        let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap());
3960        thread.update(cx, |thread, cx| {
3961            thread
3962                .set_title("Friendly Greeting with AI".into(), cx)
3963                .detach();
3964        });
3965        cx.run_until_parked();
3966
3967        assert_eq!(
3968            visible_entries_as_strings(&sidebar, cx),
3969            vec!["v [my-project]", "  Friendly Greeting with AI *"]
3970        );
3971    }
3972
3973    #[gpui::test]
3974    async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
3975        let project_a = init_test_project("/project-a", cx).await;
3976        let (multi_workspace, cx) = cx
3977            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
3978        let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
3979
3980        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
3981
3982        // Save a thread so it appears in the list.
3983        let connection_a = StubAgentConnection::new();
3984        connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3985            acp::ContentChunk::new("Done".into()),
3986        )]);
3987        open_thread_with_connection(&panel_a, connection_a, cx);
3988        send_message(&panel_a, cx);
3989        let session_id_a = active_session_id(&panel_a, cx);
3990        save_thread_to_store(&session_id_a, &path_list_a, cx).await;
3991
3992        // Add a second workspace with its own agent panel.
3993        let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
3994        fs.as_fake()
3995            .insert_tree("/project-b", serde_json::json!({ "src": {} }))
3996            .await;
3997        let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
3998        let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
3999            mw.test_add_workspace(project_b.clone(), window, cx)
4000        });
4001        let panel_b = add_agent_panel(&workspace_b, &project_b, cx);
4002        cx.run_until_parked();
4003
4004        let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone());
4005
4006        // ── 1. Initial state: no focused thread ──────────────────────────────
4007        // Workspace B is active (just added) and has no thread, so its header
4008        // is the active entry.
4009        sidebar.read_with(cx, |sidebar, _cx| {
4010            assert_eq!(
4011                sidebar.focused_thread, None,
4012                "Initially no thread should be focused"
4013            );
4014            let active_entry = sidebar
4015                .active_entry_index
4016                .and_then(|ix| sidebar.contents.entries.get(ix));
4017            assert!(
4018                matches!(active_entry, Some(ListEntry::ProjectHeader { .. })),
4019                "Active entry should be the active workspace header"
4020            );
4021        });
4022
4023        // ── 2. Click thread in workspace A via sidebar ───────────────────────
4024        sidebar.update_in(cx, |sidebar, window, cx| {
4025            sidebar.activate_thread(
4026                Agent::NativeAgent,
4027                acp_thread::AgentSessionInfo {
4028                    session_id: session_id_a.clone(),
4029                    cwd: None,
4030                    title: Some("Test".into()),
4031                    updated_at: None,
4032                    created_at: None,
4033                    meta: None,
4034                },
4035                &workspace_a,
4036                window,
4037                cx,
4038            );
4039        });
4040        cx.run_until_parked();
4041
4042        sidebar.read_with(cx, |sidebar, _cx| {
4043            assert_eq!(
4044                sidebar.focused_thread.as_ref(),
4045                Some(&session_id_a),
4046                "After clicking a thread, it should be the focused thread"
4047            );
4048            let active_entry = sidebar.active_entry_index
4049                .and_then(|ix| sidebar.contents.entries.get(ix));
4050            assert!(
4051                matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_a),
4052                "Active entry should be the clicked thread"
4053            );
4054        });
4055
4056        workspace_a.read_with(cx, |workspace, cx| {
4057            assert!(
4058                workspace.panel::<AgentPanel>(cx).is_some(),
4059                "Agent panel should exist"
4060            );
4061            let dock = workspace.right_dock().read(cx);
4062            assert!(
4063                dock.is_open(),
4064                "Clicking a thread should open the agent panel dock"
4065            );
4066        });
4067
4068        // ── 3. Open thread in workspace B, then click it via sidebar ─────────
4069        let connection_b = StubAgentConnection::new();
4070        connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4071            acp::ContentChunk::new("Thread B".into()),
4072        )]);
4073        open_thread_with_connection(&panel_b, connection_b, cx);
4074        send_message(&panel_b, cx);
4075        let session_id_b = active_session_id(&panel_b, cx);
4076        let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
4077        save_thread_to_store(&session_id_b, &path_list_b, cx).await;
4078        cx.run_until_parked();
4079
4080        // Opening a thread in a non-active workspace should NOT change
4081        // focused_thread — it's derived from the active workspace.
4082        sidebar.read_with(cx, |sidebar, _cx| {
4083            assert_eq!(
4084                sidebar.focused_thread.as_ref(),
4085                Some(&session_id_a),
4086                "Opening a thread in a non-active workspace should not affect focused_thread"
4087            );
4088        });
4089
4090        // Workspace A is currently active. Click a thread in workspace B,
4091        // which also triggers a workspace switch.
4092        sidebar.update_in(cx, |sidebar, window, cx| {
4093            sidebar.activate_thread(
4094                Agent::NativeAgent,
4095                acp_thread::AgentSessionInfo {
4096                    session_id: session_id_b.clone(),
4097                    cwd: None,
4098                    title: Some("Thread B".into()),
4099                    updated_at: None,
4100                    created_at: None,
4101                    meta: None,
4102                },
4103                &workspace_b,
4104                window,
4105                cx,
4106            );
4107        });
4108        cx.run_until_parked();
4109
4110        sidebar.read_with(cx, |sidebar, _cx| {
4111            assert_eq!(
4112                sidebar.focused_thread.as_ref(),
4113                Some(&session_id_b),
4114                "Clicking a thread in another workspace should focus that thread"
4115            );
4116            let active_entry = sidebar
4117                .active_entry_index
4118                .and_then(|ix| sidebar.contents.entries.get(ix));
4119            assert!(
4120                matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b),
4121                "Active entry should be the cross-workspace thread"
4122            );
4123        });
4124
4125        // ── 4. Switch workspace → focused_thread reflects new workspace ──────
4126        multi_workspace.update_in(cx, |mw, window, cx| {
4127            mw.activate_next_workspace(window, cx);
4128        });
4129        cx.run_until_parked();
4130
4131        // Workspace A is now active. Its agent panel still has session_id_a
4132        // loaded, so focused_thread should reflect that.
4133        sidebar.read_with(cx, |sidebar, _cx| {
4134            assert_eq!(
4135                sidebar.focused_thread.as_ref(),
4136                Some(&session_id_a),
4137                "Switching workspaces should derive focused_thread from the new active workspace"
4138            );
4139            let active_entry = sidebar
4140                .active_entry_index
4141                .and_then(|ix| sidebar.contents.entries.get(ix));
4142            assert!(
4143                matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_a),
4144                "Active entry should be workspace_a's active thread"
4145            );
4146        });
4147
4148        // ── 5. Opening a thread in a non-active workspace is ignored ──────────
4149        let connection_b2 = StubAgentConnection::new();
4150        connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4151            acp::ContentChunk::new("New thread".into()),
4152        )]);
4153        open_thread_with_connection(&panel_b, connection_b2, cx);
4154        send_message(&panel_b, cx);
4155        let session_id_b2 = active_session_id(&panel_b, cx);
4156        save_thread_to_store(&session_id_b2, &path_list_b, cx).await;
4157        cx.run_until_parked();
4158
4159        // Workspace A is still active, so focused_thread stays on session_id_a.
4160        sidebar.read_with(cx, |sidebar, _cx| {
4161            assert_eq!(
4162                sidebar.focused_thread.as_ref(),
4163                Some(&session_id_a),
4164                "Opening a thread in a non-active workspace should not affect focused_thread"
4165            );
4166        });
4167
4168        // ── 6. Activating workspace B shows its active thread ────────────────
4169        sidebar.update_in(cx, |sidebar, window, cx| {
4170            sidebar.activate_workspace(&workspace_b, window, cx);
4171        });
4172        cx.run_until_parked();
4173
4174        // Workspace B is now active with session_id_b2 loaded.
4175        sidebar.read_with(cx, |sidebar, _cx| {
4176            assert_eq!(
4177                sidebar.focused_thread.as_ref(),
4178                Some(&session_id_b2),
4179                "Activating workspace_b should show workspace_b's active thread"
4180            );
4181            let active_entry = sidebar
4182                .active_entry_index
4183                .and_then(|ix| sidebar.contents.entries.get(ix));
4184            assert!(
4185                matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b2),
4186                "Active entry should be workspace_b's active thread"
4187            );
4188        });
4189
4190        // ── 7. Switching back to workspace A reflects its thread ─────────────
4191        multi_workspace.update_in(cx, |mw, window, cx| {
4192            mw.activate_next_workspace(window, cx);
4193        });
4194        cx.run_until_parked();
4195
4196        sidebar.read_with(cx, |sidebar, _cx| {
4197            assert_eq!(
4198                sidebar.focused_thread.as_ref(),
4199                Some(&session_id_a),
4200                "Switching back to workspace_a should show its active thread"
4201            );
4202        });
4203    }
4204
4205    async fn save_named_thread(
4206        session_id: &str,
4207        title: &str,
4208        path_list: &PathList,
4209        cx: &mut gpui::VisualTestContext,
4210    ) {
4211        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
4212        let save_task = thread_store.update(cx, |store, cx| {
4213            store.save_thread(
4214                acp::SessionId::new(Arc::from(session_id)),
4215                make_test_thread(
4216                    title,
4217                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4218                ),
4219                path_list.clone(),
4220                cx,
4221            )
4222        });
4223        save_task.await.unwrap();
4224        cx.run_until_parked();
4225    }
4226
4227    async fn init_test_project_with_git(
4228        worktree_path: &str,
4229        cx: &mut TestAppContext,
4230    ) -> (Entity<project::Project>, Arc<dyn fs::Fs>) {
4231        init_test(cx);
4232        let fs = FakeFs::new(cx.executor());
4233        fs.insert_tree(
4234            worktree_path,
4235            serde_json::json!({
4236                ".git": {},
4237                "src": {},
4238            }),
4239        )
4240        .await;
4241        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4242        let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await;
4243        (project, fs)
4244    }
4245
4246    #[gpui::test]
4247    async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
4248        let (project, fs) = init_test_project_with_git("/project", cx).await;
4249
4250        fs.as_fake()
4251            .with_git_state(std::path::Path::new("/project/.git"), false, |state| {
4252                state.worktrees.push(git::repository::Worktree {
4253                    path: std::path::PathBuf::from("/wt/rosewood"),
4254                    ref_name: "refs/heads/rosewood".into(),
4255                    sha: "abc".into(),
4256                });
4257            })
4258            .unwrap();
4259
4260        project
4261            .update(cx, |project, cx| project.git_scans_complete(cx))
4262            .await;
4263
4264        let (multi_workspace, cx) =
4265            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4266        let sidebar = setup_sidebar(&multi_workspace, cx);
4267
4268        let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]);
4269        let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
4270        save_named_thread("main-t", "Unrelated Thread", &main_paths, cx).await;
4271        save_named_thread("wt-t", "Fix Bug", &wt_paths, cx).await;
4272
4273        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4274        cx.run_until_parked();
4275
4276        // Search for "rosewood" — should match the worktree name, not the title.
4277        type_in_search(&sidebar, "rosewood", cx);
4278
4279        assert_eq!(
4280            visible_entries_as_strings(&sidebar, cx),
4281            vec!["v [project]", "  Fix Bug {rosewood}  <== selected"],
4282        );
4283    }
4284
4285    #[gpui::test]
4286    async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
4287        let (project, fs) = init_test_project_with_git("/project", cx).await;
4288
4289        project
4290            .update(cx, |project, cx| project.git_scans_complete(cx))
4291            .await;
4292
4293        let (multi_workspace, cx) =
4294            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4295        let sidebar = setup_sidebar(&multi_workspace, cx);
4296
4297        // Save a thread against a worktree path that doesn't exist yet.
4298        let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
4299        save_named_thread("wt-thread", "Worktree Thread", &wt_paths, cx).await;
4300
4301        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4302        cx.run_until_parked();
4303
4304        // Thread is not visible yet — no worktree knows about this path.
4305        assert_eq!(
4306            visible_entries_as_strings(&sidebar, cx),
4307            vec!["v [project]", "  [+ New Thread]"]
4308        );
4309
4310        // Now add the worktree to the git state and trigger a rescan.
4311        fs.as_fake()
4312            .with_git_state(std::path::Path::new("/project/.git"), true, |state| {
4313                state.worktrees.push(git::repository::Worktree {
4314                    path: std::path::PathBuf::from("/wt/rosewood"),
4315                    ref_name: "refs/heads/rosewood".into(),
4316                    sha: "abc".into(),
4317                });
4318            })
4319            .unwrap();
4320
4321        cx.run_until_parked();
4322
4323        assert_eq!(
4324            visible_entries_as_strings(&sidebar, cx),
4325            vec!["v [project]", "  Worktree Thread {rosewood}",]
4326        );
4327    }
4328
4329    #[gpui::test]
4330    async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) {
4331        init_test(cx);
4332        let fs = FakeFs::new(cx.executor());
4333
4334        // Create the main repo directory (not opened as a workspace yet).
4335        fs.insert_tree(
4336            "/project",
4337            serde_json::json!({
4338                ".git": {
4339                    "worktrees": {
4340                        "feature-a": {
4341                            "commondir": "../../",
4342                            "HEAD": "ref: refs/heads/feature-a",
4343                        },
4344                        "feature-b": {
4345                            "commondir": "../../",
4346                            "HEAD": "ref: refs/heads/feature-b",
4347                        },
4348                    },
4349                },
4350                "src": {},
4351            }),
4352        )
4353        .await;
4354
4355        // Two worktree checkouts whose .git files point back to the main repo.
4356        fs.insert_tree(
4357            "/wt-feature-a",
4358            serde_json::json!({
4359                ".git": "gitdir: /project/.git/worktrees/feature-a",
4360                "src": {},
4361            }),
4362        )
4363        .await;
4364        fs.insert_tree(
4365            "/wt-feature-b",
4366            serde_json::json!({
4367                ".git": "gitdir: /project/.git/worktrees/feature-b",
4368                "src": {},
4369            }),
4370        )
4371        .await;
4372
4373        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4374
4375        let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4376        let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
4377
4378        project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
4379        project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
4380
4381        // Open both worktrees as workspaces — no main repo yet.
4382        let (multi_workspace, cx) = cx
4383            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4384        multi_workspace.update_in(cx, |mw, window, cx| {
4385            mw.test_add_workspace(project_b.clone(), window, cx);
4386        });
4387        let sidebar = setup_sidebar(&multi_workspace, cx);
4388
4389        let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
4390        let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]);
4391        save_named_thread("thread-a", "Thread A", &paths_a, cx).await;
4392        save_named_thread("thread-b", "Thread B", &paths_b, cx).await;
4393
4394        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4395        cx.run_until_parked();
4396
4397        // Without the main repo, each worktree has its own header.
4398        assert_eq!(
4399            visible_entries_as_strings(&sidebar, cx),
4400            vec![
4401                "v [wt-feature-a]",
4402                "  Thread A",
4403                "v [wt-feature-b]",
4404                "  Thread B",
4405            ]
4406        );
4407
4408        // Configure the main repo to list both worktrees before opening
4409        // it so the initial git scan picks them up.
4410        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
4411            state.worktrees.push(git::repository::Worktree {
4412                path: std::path::PathBuf::from("/wt-feature-a"),
4413                ref_name: "refs/heads/feature-a".into(),
4414                sha: "aaa".into(),
4415            });
4416            state.worktrees.push(git::repository::Worktree {
4417                path: std::path::PathBuf::from("/wt-feature-b"),
4418                ref_name: "refs/heads/feature-b".into(),
4419                sha: "bbb".into(),
4420            });
4421        })
4422        .unwrap();
4423
4424        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4425        main_project
4426            .update(cx, |p, cx| p.git_scans_complete(cx))
4427            .await;
4428
4429        multi_workspace.update_in(cx, |mw, window, cx| {
4430            mw.test_add_workspace(main_project.clone(), window, cx);
4431        });
4432        cx.run_until_parked();
4433
4434        // Both worktree workspaces should now be absorbed under the main
4435        // repo header, with worktree chips.
4436        assert_eq!(
4437            visible_entries_as_strings(&sidebar, cx),
4438            vec![
4439                "v [project]",
4440                "  Thread A {wt-feature-a}",
4441                "  Thread B {wt-feature-b}",
4442            ]
4443        );
4444
4445        // Remove feature-b from the main repo's linked worktrees.
4446        // The feature-b workspace should be pruned automatically.
4447        fs.with_git_state(std::path::Path::new("/project/.git"), true, |state| {
4448            state
4449                .worktrees
4450                .retain(|wt| wt.path != std::path::Path::new("/wt-feature-b"));
4451        })
4452        .unwrap();
4453
4454        cx.run_until_parked();
4455
4456        // feature-b's workspace is pruned; feature-a remains absorbed
4457        // under the main repo.
4458        assert_eq!(
4459            visible_entries_as_strings(&sidebar, cx),
4460            vec!["v [project]", "  Thread A {wt-feature-a}",]
4461        );
4462    }
4463
4464    #[gpui::test]
4465    async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(
4466        cx: &mut TestAppContext,
4467    ) {
4468        init_test(cx);
4469        let fs = FakeFs::new(cx.executor());
4470
4471        fs.insert_tree(
4472            "/project",
4473            serde_json::json!({
4474                ".git": {
4475                    "worktrees": {
4476                        "feature-a": {
4477                            "commondir": "../../",
4478                            "HEAD": "ref: refs/heads/feature-a",
4479                        },
4480                    },
4481                },
4482                "src": {},
4483            }),
4484        )
4485        .await;
4486
4487        fs.insert_tree(
4488            "/wt-feature-a",
4489            serde_json::json!({
4490                ".git": "gitdir: /project/.git/worktrees/feature-a",
4491                "src": {},
4492            }),
4493        )
4494        .await;
4495
4496        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
4497            state.worktrees.push(git::repository::Worktree {
4498                path: std::path::PathBuf::from("/wt-feature-a"),
4499                ref_name: "refs/heads/feature-a".into(),
4500                sha: "aaa".into(),
4501            });
4502        })
4503        .unwrap();
4504
4505        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4506
4507        // Only open the main repo — no workspace for the worktree.
4508        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4509        main_project
4510            .update(cx, |p, cx| p.git_scans_complete(cx))
4511            .await;
4512
4513        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
4514            MultiWorkspace::test_new(main_project.clone(), window, cx)
4515        });
4516        let sidebar = setup_sidebar(&multi_workspace, cx);
4517
4518        // Save a thread for the worktree path (no workspace for it).
4519        let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
4520        save_named_thread("thread-wt", "WT Thread", &paths_wt, cx).await;
4521
4522        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4523        cx.run_until_parked();
4524
4525        // Thread should appear under the main repo with a worktree chip.
4526        assert_eq!(
4527            visible_entries_as_strings(&sidebar, cx),
4528            vec!["v [project]", "  WT Thread {wt-feature-a}"],
4529        );
4530
4531        // Only 1 workspace should exist.
4532        assert_eq!(
4533            multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
4534            1,
4535        );
4536
4537        // Focus the sidebar and select the worktree thread.
4538        open_and_focus_sidebar(&sidebar, cx);
4539        sidebar.update_in(cx, |sidebar, _window, _cx| {
4540            sidebar.selection = Some(1); // index 0 is header, 1 is the thread
4541        });
4542
4543        // Confirm to open the worktree thread.
4544        cx.dispatch_action(Confirm);
4545        cx.run_until_parked();
4546
4547        // A new workspace should have been created for the worktree path.
4548        let new_workspace = multi_workspace.read_with(cx, |mw, _| {
4549            assert_eq!(
4550                mw.workspaces().len(),
4551                2,
4552                "confirming a worktree thread without a workspace should open one",
4553            );
4554            mw.workspaces()[1].clone()
4555        });
4556
4557        let new_path_list =
4558            new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
4559        assert_eq!(
4560            new_path_list,
4561            PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
4562            "the new workspace should have been opened for the worktree path",
4563        );
4564    }
4565
4566    #[gpui::test]
4567    async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
4568        cx: &mut TestAppContext,
4569    ) {
4570        init_test(cx);
4571        let fs = FakeFs::new(cx.executor());
4572
4573        fs.insert_tree(
4574            "/project",
4575            serde_json::json!({
4576                ".git": {
4577                    "worktrees": {
4578                        "feature-a": {
4579                            "commondir": "../../",
4580                            "HEAD": "ref: refs/heads/feature-a",
4581                        },
4582                    },
4583                },
4584                "src": {},
4585            }),
4586        )
4587        .await;
4588
4589        fs.insert_tree(
4590            "/wt-feature-a",
4591            serde_json::json!({
4592                ".git": "gitdir: /project/.git/worktrees/feature-a",
4593                "src": {},
4594            }),
4595        )
4596        .await;
4597
4598        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
4599            state.worktrees.push(git::repository::Worktree {
4600                path: std::path::PathBuf::from("/wt-feature-a"),
4601                ref_name: "refs/heads/feature-a".into(),
4602                sha: "aaa".into(),
4603            });
4604        })
4605        .unwrap();
4606
4607        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4608
4609        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4610        let worktree_project =
4611            project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4612
4613        main_project
4614            .update(cx, |p, cx| p.git_scans_complete(cx))
4615            .await;
4616        worktree_project
4617            .update(cx, |p, cx| p.git_scans_complete(cx))
4618            .await;
4619
4620        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
4621            MultiWorkspace::test_new(main_project.clone(), window, cx)
4622        });
4623
4624        let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
4625            mw.test_add_workspace(worktree_project.clone(), window, cx)
4626        });
4627
4628        // Activate the main workspace before setting up the sidebar.
4629        multi_workspace.update_in(cx, |mw, window, cx| {
4630            mw.activate_index(0, window, cx);
4631        });
4632
4633        let sidebar = setup_sidebar(&multi_workspace, cx);
4634
4635        let paths_main = PathList::new(&[std::path::PathBuf::from("/project")]);
4636        let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
4637        save_named_thread("thread-main", "Main Thread", &paths_main, cx).await;
4638        save_named_thread("thread-wt", "WT Thread", &paths_wt, cx).await;
4639
4640        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4641        cx.run_until_parked();
4642
4643        // The worktree workspace should be absorbed under the main repo.
4644        let entries = visible_entries_as_strings(&sidebar, cx);
4645        assert_eq!(entries.len(), 3);
4646        assert_eq!(entries[0], "v [project]");
4647        assert!(entries.contains(&"  Main Thread".to_string()));
4648        assert!(entries.contains(&"  WT Thread {wt-feature-a}".to_string()));
4649
4650        let wt_thread_index = entries
4651            .iter()
4652            .position(|e| e.contains("WT Thread"))
4653            .expect("should find the worktree thread entry");
4654
4655        assert_eq!(
4656            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4657            0,
4658            "main workspace should be active initially"
4659        );
4660
4661        // Focus the sidebar and select the absorbed worktree thread.
4662        open_and_focus_sidebar(&sidebar, cx);
4663        sidebar.update_in(cx, |sidebar, _window, _cx| {
4664            sidebar.selection = Some(wt_thread_index);
4665        });
4666
4667        // Confirm to activate the worktree thread.
4668        cx.dispatch_action(Confirm);
4669        cx.run_until_parked();
4670
4671        // The worktree workspace should now be active, not the main one.
4672        let active_workspace = multi_workspace.read_with(cx, |mw, _| {
4673            mw.workspaces()[mw.active_workspace_index()].clone()
4674        });
4675        assert_eq!(
4676            active_workspace, worktree_workspace,
4677            "clicking an absorbed worktree thread should activate the worktree workspace"
4678        );
4679    }
4680
4681    #[gpui::test]
4682    async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace(
4683        cx: &mut TestAppContext,
4684    ) {
4685        // Thread has saved metadata in ThreadStore. A matching workspace is
4686        // already open. Expected: activates the matching workspace.
4687        init_test(cx);
4688        let fs = FakeFs::new(cx.executor());
4689        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4690            .await;
4691        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4692            .await;
4693        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4694
4695        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4696        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4697
4698        let (multi_workspace, cx) =
4699            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4700
4701        multi_workspace.update_in(cx, |mw, window, cx| {
4702            mw.test_add_workspace(project_b, window, cx);
4703        });
4704
4705        let sidebar = setup_sidebar(&multi_workspace, cx);
4706
4707        // Save a thread with path_list pointing to project-b.
4708        let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
4709        let session_id = acp::SessionId::new(Arc::from("archived-1"));
4710        save_thread_to_store(&session_id, &path_list_b, cx).await;
4711
4712        // Ensure workspace A is active.
4713        multi_workspace.update_in(cx, |mw, window, cx| {
4714            mw.activate_index(0, window, cx);
4715        });
4716        cx.run_until_parked();
4717        assert_eq!(
4718            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4719            0
4720        );
4721
4722        // Call activate_archived_thread – should resolve saved paths and
4723        // switch to the workspace for project-b.
4724        sidebar.update_in(cx, |sidebar, window, cx| {
4725            sidebar.activate_archived_thread(
4726                Agent::NativeAgent,
4727                acp_thread::AgentSessionInfo {
4728                    session_id: session_id.clone(),
4729                    cwd: Some("/project-b".into()),
4730                    title: Some("Archived Thread".into()),
4731                    updated_at: None,
4732                    created_at: None,
4733                    meta: None,
4734                },
4735                window,
4736                cx,
4737            );
4738        });
4739        cx.run_until_parked();
4740
4741        assert_eq!(
4742            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4743            1,
4744            "should have activated the workspace matching the saved path_list"
4745        );
4746    }
4747
4748    #[gpui::test]
4749    async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
4750        cx: &mut TestAppContext,
4751    ) {
4752        // Thread has no saved metadata but session_info has cwd. A matching
4753        // workspace is open. Expected: uses cwd to find and activate it.
4754        init_test(cx);
4755        let fs = FakeFs::new(cx.executor());
4756        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4757            .await;
4758        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4759            .await;
4760        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4761
4762        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4763        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4764
4765        let (multi_workspace, cx) =
4766            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4767
4768        multi_workspace.update_in(cx, |mw, window, cx| {
4769            mw.test_add_workspace(project_b, window, cx);
4770        });
4771
4772        let sidebar = setup_sidebar(&multi_workspace, cx);
4773
4774        // Start with workspace A active.
4775        multi_workspace.update_in(cx, |mw, window, cx| {
4776            mw.activate_index(0, window, cx);
4777        });
4778        cx.run_until_parked();
4779        assert_eq!(
4780            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4781            0
4782        );
4783
4784        // No thread saved to the store – cwd is the only path hint.
4785        sidebar.update_in(cx, |sidebar, window, cx| {
4786            sidebar.activate_archived_thread(
4787                Agent::NativeAgent,
4788                acp_thread::AgentSessionInfo {
4789                    session_id: acp::SessionId::new(Arc::from("unknown-session")),
4790                    cwd: Some(std::path::PathBuf::from("/project-b")),
4791                    title: Some("CWD Thread".into()),
4792                    updated_at: None,
4793                    created_at: None,
4794                    meta: None,
4795                },
4796                window,
4797                cx,
4798            );
4799        });
4800        cx.run_until_parked();
4801
4802        assert_eq!(
4803            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4804            1,
4805            "should have activated the workspace matching the cwd"
4806        );
4807    }
4808
4809    #[gpui::test]
4810    async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
4811        cx: &mut TestAppContext,
4812    ) {
4813        // Thread has no saved metadata and no cwd. Expected: falls back to
4814        // the currently active workspace.
4815        init_test(cx);
4816        let fs = FakeFs::new(cx.executor());
4817        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4818            .await;
4819        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4820            .await;
4821        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4822
4823        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4824        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4825
4826        let (multi_workspace, cx) =
4827            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4828
4829        multi_workspace.update_in(cx, |mw, window, cx| {
4830            mw.test_add_workspace(project_b, window, cx);
4831        });
4832
4833        let sidebar = setup_sidebar(&multi_workspace, cx);
4834
4835        // Activate workspace B (index 1) to make it the active one.
4836        multi_workspace.update_in(cx, |mw, window, cx| {
4837            mw.activate_index(1, window, cx);
4838        });
4839        cx.run_until_parked();
4840        assert_eq!(
4841            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4842            1
4843        );
4844
4845        // No saved thread, no cwd – should fall back to the active workspace.
4846        sidebar.update_in(cx, |sidebar, window, cx| {
4847            sidebar.activate_archived_thread(
4848                Agent::NativeAgent,
4849                acp_thread::AgentSessionInfo {
4850                    session_id: acp::SessionId::new(Arc::from("no-context-session")),
4851                    cwd: None,
4852                    title: Some("Contextless Thread".into()),
4853                    updated_at: None,
4854                    created_at: None,
4855                    meta: None,
4856                },
4857                window,
4858                cx,
4859            );
4860        });
4861        cx.run_until_parked();
4862
4863        assert_eq!(
4864            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4865            1,
4866            "should have stayed on the active workspace when no path info is available"
4867        );
4868    }
4869
4870    #[gpui::test]
4871    async fn test_activate_archived_thread_saved_paths_opens_new_workspace(
4872        cx: &mut TestAppContext,
4873    ) {
4874        // Thread has saved metadata pointing to a path with no open workspace.
4875        // Expected: opens a new workspace for that path.
4876        init_test(cx);
4877        let fs = FakeFs::new(cx.executor());
4878        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4879            .await;
4880        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4881            .await;
4882        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4883
4884        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4885
4886        let (multi_workspace, cx) =
4887            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4888
4889        let sidebar = setup_sidebar(&multi_workspace, cx);
4890
4891        // Save a thread with path_list pointing to project-b – which has no
4892        // open workspace.
4893        let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
4894        let session_id = acp::SessionId::new(Arc::from("archived-new-ws"));
4895        save_thread_to_store(&session_id, &path_list_b, cx).await;
4896
4897        assert_eq!(
4898            multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
4899            1,
4900            "should start with one workspace"
4901        );
4902
4903        sidebar.update_in(cx, |sidebar, window, cx| {
4904            sidebar.activate_archived_thread(
4905                Agent::NativeAgent,
4906                acp_thread::AgentSessionInfo {
4907                    session_id: session_id.clone(),
4908                    cwd: None,
4909                    title: Some("New WS Thread".into()),
4910                    updated_at: None,
4911                    created_at: None,
4912                    meta: None,
4913                },
4914                window,
4915                cx,
4916            );
4917        });
4918        cx.run_until_parked();
4919
4920        assert_eq!(
4921            multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
4922            2,
4923            "should have opened a second workspace for the archived thread's saved paths"
4924        );
4925    }
4926}