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                .icon(IconName::Plus)
1779                .icon_color(Color::Muted)
1780                .icon_size(IconSize::Small)
1781                .icon_position(IconPosition::Start)
1782                .toggle_state(is_selected)
1783                .on_click(cx.listener(move |this, _, window, cx| {
1784                    this.selection = None;
1785                    this.create_new_thread(&workspace, window, cx);
1786                })),
1787            )
1788            .into_any_element()
1789    }
1790
1791    fn render_thread_list_header(
1792        &self,
1793        docked_right: bool,
1794        cx: &mut Context<Self>,
1795    ) -> impl IntoElement {
1796        let has_query = self.has_filter_query(cx);
1797
1798        h_flex()
1799            .h(Tab::container_height(cx))
1800            .flex_none()
1801            .gap_1p5()
1802            .border_b_1()
1803            .border_color(cx.theme().colors().border)
1804            .when(!docked_right, |this| {
1805                this.child(self.render_sidebar_toggle_button(false, cx))
1806            })
1807            .child(self.render_filter_input())
1808            .when(has_query, |this| {
1809                this.when(!docked_right, |this| this.pr_1p5()).child(
1810                    IconButton::new("clear_filter", IconName::Close)
1811                        .shape(IconButtonShape::Square)
1812                        .tooltip(Tooltip::text("Clear Search"))
1813                        .on_click(cx.listener(|this, _, window, cx| {
1814                            this.reset_filter_editor_text(window, cx);
1815                            this.update_entries(cx);
1816                        })),
1817                )
1818            })
1819            .when(docked_right, |this| {
1820                this.pl_2()
1821                    .pr_0p5()
1822                    .child(self.render_sidebar_toggle_button(true, cx))
1823            })
1824    }
1825
1826    fn render_thread_list_footer(&self, cx: &mut Context<Self>) -> impl IntoElement {
1827        h_flex()
1828            .p_1p5()
1829            .border_t_1()
1830            .border_color(cx.theme().colors().border_variant)
1831            .child(
1832                Button::new("view-archive", "Archive")
1833                    .full_width()
1834                    .label_size(LabelSize::Small)
1835                    .style(ButtonStyle::Outlined)
1836                    .icon(IconName::Archive)
1837                    .icon_color(Color::Muted)
1838                    .icon_size(IconSize::XSmall)
1839                    .icon_position(IconPosition::Start)
1840                    .on_click(cx.listener(|this, _, window, cx| {
1841                        this.show_archive(window, cx);
1842                    })),
1843            )
1844    }
1845
1846    fn render_sidebar_toggle_button(
1847        &self,
1848        docked_right: bool,
1849        cx: &mut Context<Self>,
1850    ) -> impl IntoElement {
1851        let icon = if docked_right {
1852            IconName::ThreadsSidebarRightOpen
1853        } else {
1854            IconName::ThreadsSidebarLeftOpen
1855        };
1856
1857        h_flex()
1858            .h_full()
1859            .px_1()
1860            .map(|this| {
1861                if docked_right {
1862                    this.pr_1p5().border_l_1()
1863                } else {
1864                    this.border_r_1()
1865                }
1866            })
1867            .border_color(cx.theme().colors().border_variant)
1868            .child(
1869                IconButton::new("sidebar-close-toggle", icon)
1870                    .icon_size(IconSize::Small)
1871                    .tooltip(move |_, cx| {
1872                        Tooltip::for_action("Close Threads Sidebar", &ToggleWorkspaceSidebar, cx)
1873                    })
1874                    .on_click(|_, window, cx| {
1875                        window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
1876                    }),
1877            )
1878    }
1879}
1880
1881impl Sidebar {
1882    pub fn is_open(&self) -> bool {
1883        self.is_open
1884    }
1885
1886    fn show_archive(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1887        let Some(active_workspace) = self.multi_workspace.upgrade().and_then(|w| {
1888            w.read(cx)
1889                .workspaces()
1890                .get(w.read(cx).active_workspace_index())
1891                .cloned()
1892        }) else {
1893            return;
1894        };
1895
1896        let Some(agent_panel) = active_workspace.read(cx).panel::<AgentPanel>(cx) else {
1897            return;
1898        };
1899
1900        let thread_store = agent_panel.read(cx).thread_store().clone();
1901        let fs = active_workspace.read(cx).project().read(cx).fs().clone();
1902        let agent_connection_store = agent_panel.read(cx).connection_store().clone();
1903        let agent_server_store = active_workspace
1904            .read(cx)
1905            .project()
1906            .read(cx)
1907            .agent_server_store()
1908            .clone();
1909
1910        let archive_view = cx.new(|cx| {
1911            ThreadsArchiveView::new(
1912                agent_connection_store,
1913                agent_server_store,
1914                thread_store,
1915                fs,
1916                window,
1917                cx,
1918            )
1919        });
1920        let subscription = cx.subscribe_in(
1921            &archive_view,
1922            window,
1923            |this, _, event: &ThreadsArchiveViewEvent, window, cx| match event {
1924                ThreadsArchiveViewEvent::Close => {
1925                    this.show_thread_list(window, cx);
1926                }
1927                ThreadsArchiveViewEvent::OpenThread {
1928                    agent,
1929                    session_info,
1930                } => {
1931                    this.show_thread_list(window, cx);
1932                    this.activate_archived_thread(agent.clone(), session_info.clone(), window, cx);
1933                }
1934            },
1935        );
1936
1937        self._subscriptions.push(subscription);
1938        self.archive_view = Some(archive_view);
1939        self.view = SidebarView::Archive;
1940        cx.notify();
1941    }
1942
1943    fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1944        self.view = SidebarView::ThreadList;
1945        self.archive_view = None;
1946        self._subscriptions.clear();
1947        window.focus(&self.focus_handle, cx);
1948        cx.notify();
1949    }
1950
1951    pub fn set_open(&mut self, open: bool, cx: &mut Context<Self>) {
1952        if self.is_open == open {
1953            return;
1954        }
1955        self.is_open = open;
1956        cx.notify();
1957        if let Some(key) = self.persistence_key {
1958            let is_open = self.is_open;
1959            cx.background_spawn(async move {
1960                save_sidebar_open_state(key, is_open).await;
1961            })
1962            .detach();
1963        }
1964    }
1965
1966    pub fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1967        let new_state = !self.is_open;
1968        self.set_open(new_state, cx);
1969        if new_state {
1970            cx.focus_self(window);
1971        }
1972    }
1973
1974    pub fn focus_or_unfocus(
1975        &mut self,
1976        workspace: &mut Workspace,
1977        window: &mut Window,
1978        cx: &mut Context<Self>,
1979    ) {
1980        if self.is_open {
1981            let sidebar_is_focused = self.focus_handle(cx).contains_focused(window, cx);
1982            if sidebar_is_focused {
1983                let active_pane = workspace.active_pane().clone();
1984                let pane_focus = active_pane.read(cx).focus_handle(cx);
1985                window.focus(&pane_focus, cx);
1986            } else {
1987                cx.focus_self(window);
1988            }
1989        } else {
1990            self.set_open(true, cx);
1991            cx.focus_self(window);
1992        }
1993    }
1994
1995    pub fn width(&self, _cx: &App) -> Pixels {
1996        self.width
1997    }
1998
1999    pub fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
2000        self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
2001        cx.notify();
2002    }
2003
2004    pub fn has_notifications(&self, _cx: &App) -> bool {
2005        !self.contents.notified_threads.is_empty()
2006    }
2007}
2008
2009impl Focusable for Sidebar {
2010    fn focus_handle(&self, cx: &App) -> FocusHandle {
2011        self.filter_editor.focus_handle(cx)
2012    }
2013}
2014
2015impl Render for Sidebar {
2016    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2017        let ui_font = theme::setup_ui_font(window, cx);
2018        let docked_right = AgentSettings::get_global(cx).dock == settings::DockPosition::Right;
2019        let sticky_header = self.render_sticky_header(docked_right, window, cx);
2020
2021        v_flex()
2022            .id("workspace-sidebar")
2023            .key_context("WorkspaceSidebar")
2024            .track_focus(&self.focus_handle)
2025            .on_action(cx.listener(Self::select_next))
2026            .on_action(cx.listener(Self::select_previous))
2027            .on_action(cx.listener(Self::editor_move_down))
2028            .on_action(cx.listener(Self::editor_move_up))
2029            .on_action(cx.listener(Self::select_first))
2030            .on_action(cx.listener(Self::select_last))
2031            .on_action(cx.listener(Self::confirm))
2032            .on_action(cx.listener(Self::expand_selected_entry))
2033            .on_action(cx.listener(Self::collapse_selected_entry))
2034            .on_action(cx.listener(Self::cancel))
2035            .font(ui_font)
2036            .size_full()
2037            .bg(cx.theme().colors().surface_background)
2038            .map(|this| match self.view {
2039                SidebarView::ThreadList => this
2040                    .child(self.render_thread_list_header(docked_right, cx))
2041                    .child(
2042                        v_flex()
2043                            .relative()
2044                            .flex_1()
2045                            .overflow_hidden()
2046                            .child(
2047                                list(
2048                                    self.list_state.clone(),
2049                                    cx.processor(Self::render_list_entry),
2050                                )
2051                                .flex_1()
2052                                .size_full(),
2053                            )
2054                            .when_some(sticky_header, |this, header| this.child(header))
2055                            .vertical_scrollbar_for(&self.list_state, window, cx),
2056                    )
2057                    .child(self.render_thread_list_footer(cx)),
2058                SidebarView::Archive => {
2059                    if let Some(archive_view) = &self.archive_view {
2060                        this.child(archive_view.clone())
2061                    } else {
2062                        this
2063                    }
2064                }
2065            })
2066    }
2067}
2068
2069#[cfg(test)]
2070mod tests {
2071    use super::*;
2072    use crate::test_support::{active_session_id, open_thread_with_connection, send_message};
2073    use acp_thread::StubAgentConnection;
2074    use agent::ThreadStore;
2075    use assistant_text_thread::TextThreadStore;
2076    use chrono::DateTime;
2077    use feature_flags::FeatureFlagAppExt as _;
2078    use fs::FakeFs;
2079    use gpui::TestAppContext;
2080    use std::sync::Arc;
2081    use util::path_list::PathList;
2082
2083    fn init_test(cx: &mut TestAppContext) {
2084        crate::test_support::init_test(cx);
2085        cx.update(|cx| {
2086            cx.update_flags(false, vec!["agent-v2".into()]);
2087            ThreadStore::init_global(cx);
2088            language_model::LanguageModelRegistry::test(cx);
2089            prompt_store::init(cx);
2090        });
2091    }
2092
2093    fn make_test_thread(title: &str, updated_at: DateTime<Utc>) -> agent::DbThread {
2094        agent::DbThread {
2095            title: title.to_string().into(),
2096            messages: Vec::new(),
2097            updated_at,
2098            detailed_summary: None,
2099            initial_project_snapshot: None,
2100            cumulative_token_usage: Default::default(),
2101            request_token_usage: Default::default(),
2102            model: None,
2103            profile: None,
2104            imported: false,
2105            subagent_context: None,
2106            speed: None,
2107            thinking_enabled: false,
2108            thinking_effort: None,
2109            draft_prompt: None,
2110            ui_scroll_position: None,
2111        }
2112    }
2113
2114    async fn init_test_project(
2115        worktree_path: &str,
2116        cx: &mut TestAppContext,
2117    ) -> Entity<project::Project> {
2118        init_test(cx);
2119        let fs = FakeFs::new(cx.executor());
2120        fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
2121            .await;
2122        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2123        project::Project::test(fs, [worktree_path.as_ref()], cx).await
2124    }
2125
2126    fn setup_sidebar(
2127        multi_workspace: &Entity<MultiWorkspace>,
2128        cx: &mut gpui::VisualTestContext,
2129    ) -> Entity<Sidebar> {
2130        let (sidebar, _panel) = setup_sidebar_with_agent_panel(multi_workspace, cx);
2131        sidebar
2132    }
2133
2134    fn setup_sidebar_with_agent_panel(
2135        multi_workspace: &Entity<MultiWorkspace>,
2136        cx: &mut gpui::VisualTestContext,
2137    ) -> (Entity<Sidebar>, Entity<AgentPanel>) {
2138        let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
2139        let project = workspace.read_with(cx, |ws, _cx| ws.project().clone());
2140        let panel = add_agent_panel(&workspace, &project, cx);
2141        workspace.update_in(cx, |workspace, window, cx| {
2142            workspace.right_dock().update(cx, |dock, cx| {
2143                if let Some(panel_ix) = dock.panel_index_for_type::<AgentPanel>() {
2144                    dock.activate_panel(panel_ix, window, cx);
2145                }
2146                dock.set_open(true, window, cx);
2147            });
2148        });
2149        cx.run_until_parked();
2150        let sidebar = panel.read_with(cx, |panel, _cx| {
2151            panel
2152                .sidebar
2153                .clone()
2154                .expect("AgentPanel should have created a sidebar")
2155        });
2156        (sidebar, panel)
2157    }
2158
2159    async fn save_n_test_threads(
2160        count: u32,
2161        path_list: &PathList,
2162        cx: &mut gpui::VisualTestContext,
2163    ) {
2164        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2165        for i in 0..count {
2166            let save_task = thread_store.update(cx, |store, cx| {
2167                store.save_thread(
2168                    acp::SessionId::new(Arc::from(format!("thread-{}", i))),
2169                    make_test_thread(
2170                        &format!("Thread {}", i + 1),
2171                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
2172                    ),
2173                    path_list.clone(),
2174                    cx,
2175                )
2176            });
2177            save_task.await.unwrap();
2178        }
2179        cx.run_until_parked();
2180    }
2181
2182    async fn save_thread_to_store(
2183        session_id: &acp::SessionId,
2184        path_list: &PathList,
2185        cx: &mut gpui::VisualTestContext,
2186    ) {
2187        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2188        let save_task = thread_store.update(cx, |store, cx| {
2189            store.save_thread(
2190                session_id.clone(),
2191                make_test_thread(
2192                    "Test",
2193                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
2194                ),
2195                path_list.clone(),
2196                cx,
2197            )
2198        });
2199        save_task.await.unwrap();
2200        cx.run_until_parked();
2201    }
2202
2203    fn open_and_focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
2204        cx.run_until_parked();
2205        sidebar.update_in(cx, |sidebar, window, cx| {
2206            sidebar.set_open(true, cx);
2207            cx.focus_self(window);
2208        });
2209        cx.run_until_parked();
2210    }
2211
2212    fn visible_entries_as_strings(
2213        sidebar: &Entity<Sidebar>,
2214        cx: &mut gpui::VisualTestContext,
2215    ) -> Vec<String> {
2216        sidebar.read_with(cx, |sidebar, _cx| {
2217            sidebar
2218                .contents
2219                .entries
2220                .iter()
2221                .enumerate()
2222                .map(|(ix, entry)| {
2223                    let selected = if sidebar.selection == Some(ix) {
2224                        "  <== selected"
2225                    } else {
2226                        ""
2227                    };
2228                    match entry {
2229                        ListEntry::ProjectHeader {
2230                            label,
2231                            path_list,
2232                            highlight_positions: _,
2233                            ..
2234                        } => {
2235                            let icon = if sidebar.collapsed_groups.contains(path_list) {
2236                                ">"
2237                            } else {
2238                                "v"
2239                            };
2240                            format!("{} [{}]{}", icon, label, selected)
2241                        }
2242                        ListEntry::Thread(thread) => {
2243                            let title = thread
2244                                .session_info
2245                                .title
2246                                .as_ref()
2247                                .map(|s| s.as_ref())
2248                                .unwrap_or("Untitled");
2249                            let active = if thread.is_live { " *" } else { "" };
2250                            let status_str = match thread.status {
2251                                AgentThreadStatus::Running => " (running)",
2252                                AgentThreadStatus::Error => " (error)",
2253                                AgentThreadStatus::WaitingForConfirmation => " (waiting)",
2254                                _ => "",
2255                            };
2256                            let notified = if sidebar
2257                                .contents
2258                                .is_thread_notified(&thread.session_info.session_id)
2259                            {
2260                                " (!)"
2261                            } else {
2262                                ""
2263                            };
2264                            let worktree = thread
2265                                .worktree_name
2266                                .as_ref()
2267                                .map(|name| format!(" {{{}}}", name))
2268                                .unwrap_or_default();
2269                            format!(
2270                                "  {}{}{}{}{}{}",
2271                                title, worktree, active, status_str, notified, selected
2272                            )
2273                        }
2274                        ListEntry::ViewMore {
2275                            remaining_count,
2276                            is_fully_expanded,
2277                            ..
2278                        } => {
2279                            if *is_fully_expanded {
2280                                format!("  - Collapse{}", selected)
2281                            } else {
2282                                format!("  + View More ({}){}", remaining_count, selected)
2283                            }
2284                        }
2285                        ListEntry::NewThread { .. } => {
2286                            format!("  [+ New Thread]{}", selected)
2287                        }
2288                    }
2289                })
2290                .collect()
2291        })
2292    }
2293
2294    #[gpui::test]
2295    async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
2296        let project = init_test_project("/my-project", cx).await;
2297        let (multi_workspace, cx) =
2298            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2299        let sidebar = setup_sidebar(&multi_workspace, cx);
2300
2301        assert_eq!(
2302            visible_entries_as_strings(&sidebar, cx),
2303            vec!["v [my-project]", "  [+ New Thread]"]
2304        );
2305    }
2306
2307    #[gpui::test]
2308    async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
2309        let project = init_test_project("/my-project", cx).await;
2310        let (multi_workspace, cx) =
2311            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2312        let sidebar = setup_sidebar(&multi_workspace, cx);
2313
2314        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2315        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2316
2317        let save_task = thread_store.update(cx, |store, cx| {
2318            store.save_thread(
2319                acp::SessionId::new(Arc::from("thread-1")),
2320                make_test_thread(
2321                    "Fix crash in project panel",
2322                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
2323                ),
2324                path_list.clone(),
2325                cx,
2326            )
2327        });
2328        save_task.await.unwrap();
2329
2330        let save_task = thread_store.update(cx, |store, cx| {
2331            store.save_thread(
2332                acp::SessionId::new(Arc::from("thread-2")),
2333                make_test_thread(
2334                    "Add inline diff view",
2335                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
2336                ),
2337                path_list.clone(),
2338                cx,
2339            )
2340        });
2341        save_task.await.unwrap();
2342        cx.run_until_parked();
2343
2344        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2345        cx.run_until_parked();
2346
2347        assert_eq!(
2348            visible_entries_as_strings(&sidebar, cx),
2349            vec![
2350                "v [my-project]",
2351                "  Fix crash in project panel",
2352                "  Add inline diff view",
2353            ]
2354        );
2355    }
2356
2357    #[gpui::test]
2358    async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
2359        let project = init_test_project("/project-a", cx).await;
2360        let (multi_workspace, cx) =
2361            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2362        let sidebar = setup_sidebar(&multi_workspace, cx);
2363
2364        // Single workspace with a thread
2365        let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]);
2366        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2367
2368        let save_task = thread_store.update(cx, |store, cx| {
2369            store.save_thread(
2370                acp::SessionId::new(Arc::from("thread-a1")),
2371                make_test_thread(
2372                    "Thread A1",
2373                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
2374                ),
2375                path_list.clone(),
2376                cx,
2377            )
2378        });
2379        save_task.await.unwrap();
2380        cx.run_until_parked();
2381
2382        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2383        cx.run_until_parked();
2384
2385        assert_eq!(
2386            visible_entries_as_strings(&sidebar, cx),
2387            vec!["v [project-a]", "  Thread A1"]
2388        );
2389
2390        // Add a second workspace
2391        multi_workspace.update_in(cx, |mw, window, cx| {
2392            mw.create_workspace(window, cx);
2393        });
2394        cx.run_until_parked();
2395
2396        assert_eq!(
2397            visible_entries_as_strings(&sidebar, cx),
2398            vec![
2399                "v [project-a]",
2400                "  Thread A1",
2401                "v [Empty Workspace]",
2402                "  [+ New Thread]"
2403            ]
2404        );
2405
2406        // Remove the second workspace
2407        multi_workspace.update_in(cx, |mw, window, cx| {
2408            mw.remove_workspace(1, window, cx);
2409        });
2410        cx.run_until_parked();
2411
2412        assert_eq!(
2413            visible_entries_as_strings(&sidebar, cx),
2414            vec!["v [project-a]", "  Thread A1"]
2415        );
2416    }
2417
2418    #[gpui::test]
2419    async fn test_view_more_pagination(cx: &mut TestAppContext) {
2420        let project = init_test_project("/my-project", cx).await;
2421        let (multi_workspace, cx) =
2422            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2423        let sidebar = setup_sidebar(&multi_workspace, cx);
2424
2425        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2426        save_n_test_threads(12, &path_list, cx).await;
2427
2428        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2429        cx.run_until_parked();
2430
2431        assert_eq!(
2432            visible_entries_as_strings(&sidebar, cx),
2433            vec![
2434                "v [my-project]",
2435                "  Thread 12",
2436                "  Thread 11",
2437                "  Thread 10",
2438                "  Thread 9",
2439                "  Thread 8",
2440                "  + View More (7)",
2441            ]
2442        );
2443    }
2444
2445    #[gpui::test]
2446    async fn test_view_more_batched_expansion(cx: &mut TestAppContext) {
2447        let project = init_test_project("/my-project", cx).await;
2448        let (multi_workspace, cx) =
2449            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2450        let sidebar = setup_sidebar(&multi_workspace, cx);
2451
2452        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2453        // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse
2454        save_n_test_threads(17, &path_list, cx).await;
2455
2456        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2457        cx.run_until_parked();
2458
2459        // Initially shows 5 threads + View More (12 remaining)
2460        let entries = visible_entries_as_strings(&sidebar, cx);
2461        assert_eq!(entries.len(), 7); // header + 5 threads + View More
2462        assert!(entries.iter().any(|e| e.contains("View More (12)")));
2463
2464        // Focus and navigate to View More, then confirm to expand by one batch
2465        open_and_focus_sidebar(&sidebar, cx);
2466        for _ in 0..7 {
2467            cx.dispatch_action(SelectNext);
2468        }
2469        cx.dispatch_action(Confirm);
2470        cx.run_until_parked();
2471
2472        // Now shows 10 threads + View More (7 remaining)
2473        let entries = visible_entries_as_strings(&sidebar, cx);
2474        assert_eq!(entries.len(), 12); // header + 10 threads + View More
2475        assert!(entries.iter().any(|e| e.contains("View More (7)")));
2476
2477        // Expand again by one batch
2478        sidebar.update_in(cx, |s, _window, cx| {
2479            let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0);
2480            s.expanded_groups.insert(path_list.clone(), current + 1);
2481            s.update_entries(cx);
2482        });
2483        cx.run_until_parked();
2484
2485        // Now shows 15 threads + View More (2 remaining)
2486        let entries = visible_entries_as_strings(&sidebar, cx);
2487        assert_eq!(entries.len(), 17); // header + 15 threads + View More
2488        assert!(entries.iter().any(|e| e.contains("View More (2)")));
2489
2490        // Expand one more time - should show all 17 threads with Collapse button
2491        sidebar.update_in(cx, |s, _window, cx| {
2492            let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0);
2493            s.expanded_groups.insert(path_list.clone(), current + 1);
2494            s.update_entries(cx);
2495        });
2496        cx.run_until_parked();
2497
2498        // All 17 threads shown with Collapse button
2499        let entries = visible_entries_as_strings(&sidebar, cx);
2500        assert_eq!(entries.len(), 19); // header + 17 threads + Collapse
2501        assert!(!entries.iter().any(|e| e.contains("View More")));
2502        assert!(entries.iter().any(|e| e.contains("Collapse")));
2503
2504        // Click collapse - should go back to showing 5 threads
2505        sidebar.update_in(cx, |s, _window, cx| {
2506            s.expanded_groups.remove(&path_list);
2507            s.update_entries(cx);
2508        });
2509        cx.run_until_parked();
2510
2511        // Back to initial state: 5 threads + View More (12 remaining)
2512        let entries = visible_entries_as_strings(&sidebar, cx);
2513        assert_eq!(entries.len(), 7); // header + 5 threads + View More
2514        assert!(entries.iter().any(|e| e.contains("View More (12)")));
2515    }
2516
2517    #[gpui::test]
2518    async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
2519        let project = init_test_project("/my-project", cx).await;
2520        let (multi_workspace, cx) =
2521            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2522        let sidebar = setup_sidebar(&multi_workspace, cx);
2523
2524        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2525        save_n_test_threads(1, &path_list, cx).await;
2526
2527        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2528        cx.run_until_parked();
2529
2530        assert_eq!(
2531            visible_entries_as_strings(&sidebar, cx),
2532            vec!["v [my-project]", "  Thread 1"]
2533        );
2534
2535        // Collapse
2536        sidebar.update_in(cx, |s, window, cx| {
2537            s.toggle_collapse(&path_list, window, cx);
2538        });
2539        cx.run_until_parked();
2540
2541        assert_eq!(
2542            visible_entries_as_strings(&sidebar, cx),
2543            vec!["> [my-project]"]
2544        );
2545
2546        // Expand
2547        sidebar.update_in(cx, |s, window, cx| {
2548            s.toggle_collapse(&path_list, window, cx);
2549        });
2550        cx.run_until_parked();
2551
2552        assert_eq!(
2553            visible_entries_as_strings(&sidebar, cx),
2554            vec!["v [my-project]", "  Thread 1"]
2555        );
2556    }
2557
2558    #[gpui::test]
2559    async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
2560        let project = init_test_project("/my-project", cx).await;
2561        let (multi_workspace, cx) =
2562            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2563        let sidebar = setup_sidebar(&multi_workspace, cx);
2564
2565        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2566        let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]);
2567        let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]);
2568
2569        sidebar.update_in(cx, |s, _window, _cx| {
2570            s.collapsed_groups.insert(collapsed_path.clone());
2571            s.contents
2572                .notified_threads
2573                .insert(acp::SessionId::new(Arc::from("t-5")));
2574            s.contents.entries = vec![
2575                // Expanded project header
2576                ListEntry::ProjectHeader {
2577                    path_list: expanded_path.clone(),
2578                    label: "expanded-project".into(),
2579                    workspace: workspace.clone(),
2580                    highlight_positions: Vec::new(),
2581                    has_threads: true,
2582                },
2583                // Thread with default (Completed) status, not active
2584                ListEntry::Thread(ThreadEntry {
2585                    agent: Agent::NativeAgent,
2586                    session_info: acp_thread::AgentSessionInfo {
2587                        session_id: acp::SessionId::new(Arc::from("t-1")),
2588                        cwd: None,
2589                        title: Some("Completed thread".into()),
2590                        updated_at: Some(Utc::now()),
2591                        created_at: Some(Utc::now()),
2592                        meta: None,
2593                    },
2594                    icon: IconName::ZedAgent,
2595                    icon_from_external_svg: None,
2596                    status: AgentThreadStatus::Completed,
2597                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
2598                    is_live: false,
2599                    is_background: false,
2600                    highlight_positions: Vec::new(),
2601                    worktree_name: None,
2602                    worktree_highlight_positions: Vec::new(),
2603                    diff_stats: DiffStats::default(),
2604                }),
2605                // Active thread with Running status
2606                ListEntry::Thread(ThreadEntry {
2607                    agent: Agent::NativeAgent,
2608                    session_info: acp_thread::AgentSessionInfo {
2609                        session_id: acp::SessionId::new(Arc::from("t-2")),
2610                        cwd: None,
2611                        title: Some("Running thread".into()),
2612                        updated_at: Some(Utc::now()),
2613                        created_at: Some(Utc::now()),
2614                        meta: None,
2615                    },
2616                    icon: IconName::ZedAgent,
2617                    icon_from_external_svg: None,
2618                    status: AgentThreadStatus::Running,
2619                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
2620                    is_live: true,
2621                    is_background: false,
2622                    highlight_positions: Vec::new(),
2623                    worktree_name: None,
2624                    worktree_highlight_positions: Vec::new(),
2625                    diff_stats: DiffStats::default(),
2626                }),
2627                // Active thread with Error status
2628                ListEntry::Thread(ThreadEntry {
2629                    agent: Agent::NativeAgent,
2630                    session_info: acp_thread::AgentSessionInfo {
2631                        session_id: acp::SessionId::new(Arc::from("t-3")),
2632                        cwd: None,
2633                        title: Some("Error thread".into()),
2634                        updated_at: Some(Utc::now()),
2635                        created_at: Some(Utc::now()),
2636                        meta: None,
2637                    },
2638                    icon: IconName::ZedAgent,
2639                    icon_from_external_svg: None,
2640                    status: AgentThreadStatus::Error,
2641                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
2642                    is_live: true,
2643                    is_background: false,
2644                    highlight_positions: Vec::new(),
2645                    worktree_name: None,
2646                    worktree_highlight_positions: Vec::new(),
2647                    diff_stats: DiffStats::default(),
2648                }),
2649                // Thread with WaitingForConfirmation status, not active
2650                ListEntry::Thread(ThreadEntry {
2651                    agent: Agent::NativeAgent,
2652                    session_info: acp_thread::AgentSessionInfo {
2653                        session_id: acp::SessionId::new(Arc::from("t-4")),
2654                        cwd: None,
2655                        title: Some("Waiting thread".into()),
2656                        updated_at: Some(Utc::now()),
2657                        created_at: Some(Utc::now()),
2658                        meta: None,
2659                    },
2660                    icon: IconName::ZedAgent,
2661                    icon_from_external_svg: None,
2662                    status: AgentThreadStatus::WaitingForConfirmation,
2663                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
2664                    is_live: false,
2665                    is_background: false,
2666                    highlight_positions: Vec::new(),
2667                    worktree_name: None,
2668                    worktree_highlight_positions: Vec::new(),
2669                    diff_stats: DiffStats::default(),
2670                }),
2671                // Background thread that completed (should show notification)
2672                ListEntry::Thread(ThreadEntry {
2673                    agent: Agent::NativeAgent,
2674                    session_info: acp_thread::AgentSessionInfo {
2675                        session_id: acp::SessionId::new(Arc::from("t-5")),
2676                        cwd: None,
2677                        title: Some("Notified thread".into()),
2678                        updated_at: Some(Utc::now()),
2679                        created_at: Some(Utc::now()),
2680                        meta: None,
2681                    },
2682                    icon: IconName::ZedAgent,
2683                    icon_from_external_svg: None,
2684                    status: AgentThreadStatus::Completed,
2685                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
2686                    is_live: true,
2687                    is_background: true,
2688                    highlight_positions: Vec::new(),
2689                    worktree_name: None,
2690                    worktree_highlight_positions: Vec::new(),
2691                    diff_stats: DiffStats::default(),
2692                }),
2693                // View More entry
2694                ListEntry::ViewMore {
2695                    path_list: expanded_path.clone(),
2696                    remaining_count: 42,
2697                    is_fully_expanded: false,
2698                },
2699                // Collapsed project header
2700                ListEntry::ProjectHeader {
2701                    path_list: collapsed_path.clone(),
2702                    label: "collapsed-project".into(),
2703                    workspace: workspace.clone(),
2704                    highlight_positions: Vec::new(),
2705                    has_threads: true,
2706                },
2707            ];
2708            // Select the Running thread (index 2)
2709            s.selection = Some(2);
2710        });
2711
2712        assert_eq!(
2713            visible_entries_as_strings(&sidebar, cx),
2714            vec![
2715                "v [expanded-project]",
2716                "  Completed thread",
2717                "  Running thread * (running)  <== selected",
2718                "  Error thread * (error)",
2719                "  Waiting thread (waiting)",
2720                "  Notified thread * (!)",
2721                "  + View More (42)",
2722                "> [collapsed-project]",
2723            ]
2724        );
2725
2726        // Move selection to the collapsed header
2727        sidebar.update_in(cx, |s, _window, _cx| {
2728            s.selection = Some(7);
2729        });
2730
2731        assert_eq!(
2732            visible_entries_as_strings(&sidebar, cx).last().cloned(),
2733            Some("> [collapsed-project]  <== selected".to_string()),
2734        );
2735
2736        // Clear selection
2737        sidebar.update_in(cx, |s, _window, _cx| {
2738            s.selection = None;
2739        });
2740
2741        // No entry should have the selected marker
2742        let entries = visible_entries_as_strings(&sidebar, cx);
2743        for entry in &entries {
2744            assert!(
2745                !entry.contains("<== selected"),
2746                "unexpected selection marker in: {}",
2747                entry
2748            );
2749        }
2750    }
2751
2752    #[gpui::test]
2753    async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
2754        let project = init_test_project("/my-project", cx).await;
2755        let (multi_workspace, cx) =
2756            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2757        let sidebar = setup_sidebar(&multi_workspace, cx);
2758
2759        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2760        save_n_test_threads(3, &path_list, cx).await;
2761
2762        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2763        cx.run_until_parked();
2764
2765        // Entries: [header, thread3, thread2, thread1]
2766        // Focusing the sidebar does not set a selection; select_next/select_previous
2767        // handle None gracefully by starting from the first or last entry.
2768        open_and_focus_sidebar(&sidebar, cx);
2769        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
2770
2771        // First SelectNext from None starts at index 0
2772        cx.dispatch_action(SelectNext);
2773        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2774
2775        // Move down through remaining entries
2776        cx.dispatch_action(SelectNext);
2777        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
2778
2779        cx.dispatch_action(SelectNext);
2780        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
2781
2782        cx.dispatch_action(SelectNext);
2783        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
2784
2785        // At the end, selection stays on the last entry
2786        cx.dispatch_action(SelectNext);
2787        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
2788
2789        // Move back up
2790
2791        cx.dispatch_action(SelectPrevious);
2792        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
2793
2794        cx.dispatch_action(SelectPrevious);
2795        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
2796
2797        cx.dispatch_action(SelectPrevious);
2798        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2799
2800        // At the top, selection stays on the first entry
2801        cx.dispatch_action(SelectPrevious);
2802        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2803    }
2804
2805    #[gpui::test]
2806    async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
2807        let project = init_test_project("/my-project", cx).await;
2808        let (multi_workspace, cx) =
2809            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2810        let sidebar = setup_sidebar(&multi_workspace, cx);
2811
2812        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2813        save_n_test_threads(3, &path_list, cx).await;
2814        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2815        cx.run_until_parked();
2816
2817        open_and_focus_sidebar(&sidebar, cx);
2818
2819        // SelectLast jumps to the end
2820        cx.dispatch_action(SelectLast);
2821        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
2822
2823        // SelectFirst jumps to the beginning
2824        cx.dispatch_action(SelectFirst);
2825        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2826    }
2827
2828    #[gpui::test]
2829    async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) {
2830        let project = init_test_project("/my-project", cx).await;
2831        let (multi_workspace, cx) =
2832            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2833        let sidebar = setup_sidebar(&multi_workspace, cx);
2834
2835        // Initially no selection
2836        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
2837
2838        // Open the sidebar so it's rendered, then focus it to trigger focus_in.
2839        // focus_in no longer sets a default selection.
2840        open_and_focus_sidebar(&sidebar, cx);
2841        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
2842
2843        // Manually set a selection, blur, then refocus — selection should be preserved
2844        sidebar.update_in(cx, |sidebar, _window, _cx| {
2845            sidebar.selection = Some(0);
2846        });
2847
2848        cx.update(|window, _cx| {
2849            window.blur();
2850        });
2851        cx.run_until_parked();
2852
2853        sidebar.update_in(cx, |_, window, cx| {
2854            cx.focus_self(window);
2855        });
2856        cx.run_until_parked();
2857        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2858    }
2859
2860    #[gpui::test]
2861    async fn test_keyboard_confirm_on_project_header_activates_workspace(cx: &mut TestAppContext) {
2862        let project = init_test_project("/my-project", cx).await;
2863        let (multi_workspace, cx) =
2864            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2865        let sidebar = setup_sidebar(&multi_workspace, cx);
2866
2867        multi_workspace.update_in(cx, |mw, window, cx| {
2868            mw.create_workspace(window, cx);
2869        });
2870        cx.run_until_parked();
2871
2872        // Add an agent panel to workspace 1 so the sidebar renders when it's active.
2873        setup_sidebar_with_agent_panel(&multi_workspace, cx);
2874
2875        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2876        save_n_test_threads(1, &path_list, cx).await;
2877        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2878        cx.run_until_parked();
2879
2880        assert_eq!(
2881            visible_entries_as_strings(&sidebar, cx),
2882            vec![
2883                "v [my-project]",
2884                "  Thread 1",
2885                "v [Empty Workspace]",
2886                "  [+ New Thread]",
2887            ]
2888        );
2889
2890        // Switch to workspace 1 so we can verify confirm switches back.
2891        multi_workspace.update_in(cx, |mw, window, cx| {
2892            mw.activate_index(1, window, cx);
2893        });
2894        cx.run_until_parked();
2895        assert_eq!(
2896            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
2897            1
2898        );
2899
2900        // Focus the sidebar and manually select the header (index 0)
2901        open_and_focus_sidebar(&sidebar, cx);
2902        sidebar.update_in(cx, |sidebar, _window, _cx| {
2903            sidebar.selection = Some(0);
2904        });
2905
2906        // Press confirm on project header (workspace 0) to activate it.
2907        cx.dispatch_action(Confirm);
2908        cx.run_until_parked();
2909
2910        assert_eq!(
2911            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
2912            0
2913        );
2914
2915        // Focus should have moved out of the sidebar to the workspace center.
2916        let workspace_0 = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone());
2917        workspace_0.update_in(cx, |workspace, window, cx| {
2918            let pane_focus = workspace.active_pane().read(cx).focus_handle(cx);
2919            assert!(
2920                pane_focus.contains_focused(window, cx),
2921                "Confirming a project header should focus the workspace center pane"
2922            );
2923        });
2924    }
2925
2926    #[gpui::test]
2927    async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) {
2928        let project = init_test_project("/my-project", cx).await;
2929        let (multi_workspace, cx) =
2930            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2931        let sidebar = setup_sidebar(&multi_workspace, cx);
2932
2933        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2934        save_n_test_threads(8, &path_list, cx).await;
2935        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2936        cx.run_until_parked();
2937
2938        // Should show header + 5 threads + "View More (3)"
2939        let entries = visible_entries_as_strings(&sidebar, cx);
2940        assert_eq!(entries.len(), 7);
2941        assert!(entries.iter().any(|e| e.contains("View More (3)")));
2942
2943        // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6)
2944        open_and_focus_sidebar(&sidebar, cx);
2945        for _ in 0..7 {
2946            cx.dispatch_action(SelectNext);
2947        }
2948        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6));
2949
2950        // Confirm on "View More" to expand
2951        cx.dispatch_action(Confirm);
2952        cx.run_until_parked();
2953
2954        // All 8 threads should now be visible with a "Collapse" button
2955        let entries = visible_entries_as_strings(&sidebar, cx);
2956        assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button
2957        assert!(!entries.iter().any(|e| e.contains("View More")));
2958        assert!(entries.iter().any(|e| e.contains("Collapse")));
2959    }
2960
2961    #[gpui::test]
2962    async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
2963        let project = init_test_project("/my-project", cx).await;
2964        let (multi_workspace, cx) =
2965            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2966        let sidebar = setup_sidebar(&multi_workspace, cx);
2967
2968        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2969        save_n_test_threads(1, &path_list, cx).await;
2970        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2971        cx.run_until_parked();
2972
2973        assert_eq!(
2974            visible_entries_as_strings(&sidebar, cx),
2975            vec!["v [my-project]", "  Thread 1"]
2976        );
2977
2978        // Focus sidebar and manually select the header (index 0). Press left to collapse.
2979        open_and_focus_sidebar(&sidebar, cx);
2980        sidebar.update_in(cx, |sidebar, _window, _cx| {
2981            sidebar.selection = Some(0);
2982        });
2983
2984        cx.dispatch_action(CollapseSelectedEntry);
2985        cx.run_until_parked();
2986
2987        assert_eq!(
2988            visible_entries_as_strings(&sidebar, cx),
2989            vec!["> [my-project]  <== selected"]
2990        );
2991
2992        // Press right to expand
2993        cx.dispatch_action(ExpandSelectedEntry);
2994        cx.run_until_parked();
2995
2996        assert_eq!(
2997            visible_entries_as_strings(&sidebar, cx),
2998            vec!["v [my-project]  <== selected", "  Thread 1",]
2999        );
3000
3001        // Press right again on already-expanded header moves selection down
3002        cx.dispatch_action(ExpandSelectedEntry);
3003        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
3004    }
3005
3006    #[gpui::test]
3007    async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) {
3008        let project = init_test_project("/my-project", cx).await;
3009        let (multi_workspace, cx) =
3010            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3011        let sidebar = setup_sidebar(&multi_workspace, cx);
3012
3013        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3014        save_n_test_threads(1, &path_list, cx).await;
3015        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3016        cx.run_until_parked();
3017
3018        // Focus sidebar (selection starts at None), then navigate down to the thread (child)
3019        open_and_focus_sidebar(&sidebar, cx);
3020        cx.dispatch_action(SelectNext);
3021        cx.dispatch_action(SelectNext);
3022        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
3023
3024        assert_eq!(
3025            visible_entries_as_strings(&sidebar, cx),
3026            vec!["v [my-project]", "  Thread 1  <== selected",]
3027        );
3028
3029        // Pressing left on a child collapses the parent group and selects it
3030        cx.dispatch_action(CollapseSelectedEntry);
3031        cx.run_until_parked();
3032
3033        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
3034        assert_eq!(
3035            visible_entries_as_strings(&sidebar, cx),
3036            vec!["> [my-project]  <== selected"]
3037        );
3038    }
3039
3040    #[gpui::test]
3041    async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
3042        let project = init_test_project("/empty-project", cx).await;
3043        let (multi_workspace, cx) =
3044            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3045        let sidebar = setup_sidebar(&multi_workspace, cx);
3046
3047        // Even an empty project has the header and a new thread button
3048        assert_eq!(
3049            visible_entries_as_strings(&sidebar, cx),
3050            vec!["v [empty-project]", "  [+ New Thread]"]
3051        );
3052
3053        // Focus sidebar — focus_in does not set a selection
3054        open_and_focus_sidebar(&sidebar, cx);
3055        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
3056
3057        // First SelectNext from None starts at index 0 (header)
3058        cx.dispatch_action(SelectNext);
3059        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
3060
3061        // SelectNext moves to the new thread button
3062        cx.dispatch_action(SelectNext);
3063        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
3064
3065        // At the end, selection stays on the last entry
3066        cx.dispatch_action(SelectNext);
3067        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
3068
3069        // SelectPrevious goes back to the header
3070        cx.dispatch_action(SelectPrevious);
3071        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
3072    }
3073
3074    #[gpui::test]
3075    async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
3076        let project = init_test_project("/my-project", cx).await;
3077        let (multi_workspace, cx) =
3078            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3079        let sidebar = setup_sidebar(&multi_workspace, cx);
3080
3081        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3082        save_n_test_threads(1, &path_list, cx).await;
3083        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3084        cx.run_until_parked();
3085
3086        // Focus sidebar (selection starts at None), navigate down to the thread (index 1)
3087        open_and_focus_sidebar(&sidebar, cx);
3088        cx.dispatch_action(SelectNext);
3089        cx.dispatch_action(SelectNext);
3090        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
3091
3092        // Collapse the group, which removes the thread from the list
3093        cx.dispatch_action(CollapseSelectedEntry);
3094        cx.run_until_parked();
3095
3096        // Selection should be clamped to the last valid index (0 = header)
3097        let selection = sidebar.read_with(cx, |s, _| s.selection);
3098        let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len());
3099        assert!(
3100            selection.unwrap_or(0) < entry_count,
3101            "selection {} should be within bounds (entries: {})",
3102            selection.unwrap_or(0),
3103            entry_count,
3104        );
3105    }
3106
3107    fn add_agent_panel(
3108        workspace: &Entity<Workspace>,
3109        project: &Entity<project::Project>,
3110        cx: &mut gpui::VisualTestContext,
3111    ) -> Entity<AgentPanel> {
3112        workspace.update_in(cx, |workspace, window, cx| {
3113            let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
3114            let panel = cx.new(|cx| AgentPanel::test_new(workspace, text_thread_store, window, cx));
3115            workspace.add_panel(panel.clone(), window, cx);
3116            panel
3117        })
3118    }
3119
3120    #[gpui::test]
3121    async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
3122        let project = init_test_project("/my-project", cx).await;
3123        let (multi_workspace, cx) =
3124            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3125        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
3126
3127        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3128
3129        // Open thread A and keep it generating.
3130        let connection = StubAgentConnection::new();
3131        open_thread_with_connection(&panel, connection.clone(), cx);
3132        send_message(&panel, cx);
3133
3134        let session_id_a = active_session_id(&panel, cx);
3135        save_thread_to_store(&session_id_a, &path_list, cx).await;
3136
3137        cx.update(|_, cx| {
3138            connection.send_update(
3139                session_id_a.clone(),
3140                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
3141                cx,
3142            );
3143        });
3144        cx.run_until_parked();
3145
3146        // Open thread B (idle, default response) — thread A goes to background.
3147        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3148            acp::ContentChunk::new("Done".into()),
3149        )]);
3150        open_thread_with_connection(&panel, connection, cx);
3151        send_message(&panel, cx);
3152
3153        let session_id_b = active_session_id(&panel, cx);
3154        save_thread_to_store(&session_id_b, &path_list, cx).await;
3155
3156        cx.run_until_parked();
3157
3158        let mut entries = visible_entries_as_strings(&sidebar, cx);
3159        entries[1..].sort();
3160        assert_eq!(
3161            entries,
3162            vec!["v [my-project]", "  Hello *", "  Hello * (running)",]
3163        );
3164    }
3165
3166    #[gpui::test]
3167    async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
3168        let project_a = init_test_project("/project-a", cx).await;
3169        let (multi_workspace, cx) = cx
3170            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
3171        let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
3172
3173        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
3174
3175        // Open thread on workspace A and keep it generating.
3176        let connection_a = StubAgentConnection::new();
3177        open_thread_with_connection(&panel_a, connection_a.clone(), cx);
3178        send_message(&panel_a, cx);
3179
3180        let session_id_a = active_session_id(&panel_a, cx);
3181        save_thread_to_store(&session_id_a, &path_list_a, cx).await;
3182
3183        cx.update(|_, cx| {
3184            connection_a.send_update(
3185                session_id_a.clone(),
3186                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
3187                cx,
3188            );
3189        });
3190        cx.run_until_parked();
3191
3192        // Add a second workspace and activate it (making workspace A the background).
3193        let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
3194        let project_b = project::Project::test(fs, [], cx).await;
3195        multi_workspace.update_in(cx, |mw, window, cx| {
3196            mw.test_add_workspace(project_b, window, cx);
3197        });
3198        cx.run_until_parked();
3199
3200        // Thread A is still running; no notification yet.
3201        assert_eq!(
3202            visible_entries_as_strings(&sidebar, cx),
3203            vec![
3204                "v [project-a]",
3205                "  Hello * (running)",
3206                "v [Empty Workspace]",
3207                "  [+ New Thread]",
3208            ]
3209        );
3210
3211        // Complete thread A's turn (transition Running → Completed).
3212        connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn);
3213        cx.run_until_parked();
3214
3215        // The completed background thread shows a notification indicator.
3216        assert_eq!(
3217            visible_entries_as_strings(&sidebar, cx),
3218            vec![
3219                "v [project-a]",
3220                "  Hello * (!)",
3221                "v [Empty Workspace]",
3222                "  [+ New Thread]",
3223            ]
3224        );
3225    }
3226
3227    fn type_in_search(sidebar: &Entity<Sidebar>, query: &str, cx: &mut gpui::VisualTestContext) {
3228        sidebar.update_in(cx, |sidebar, window, cx| {
3229            window.focus(&sidebar.filter_editor.focus_handle(cx), cx);
3230            sidebar.filter_editor.update(cx, |editor, cx| {
3231                editor.set_text(query, window, cx);
3232            });
3233        });
3234        cx.run_until_parked();
3235    }
3236
3237    #[gpui::test]
3238    async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) {
3239        let project = init_test_project("/my-project", cx).await;
3240        let (multi_workspace, cx) =
3241            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3242        let sidebar = setup_sidebar(&multi_workspace, cx);
3243
3244        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3245        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3246
3247        for (id, title, hour) in [
3248            ("t-1", "Fix crash in project panel", 3),
3249            ("t-2", "Add inline diff view", 2),
3250            ("t-3", "Refactor settings module", 1),
3251        ] {
3252            let save_task = thread_store.update(cx, |store, cx| {
3253                store.save_thread(
3254                    acp::SessionId::new(Arc::from(id)),
3255                    make_test_thread(
3256                        title,
3257                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3258                    ),
3259                    path_list.clone(),
3260                    cx,
3261                )
3262            });
3263            save_task.await.unwrap();
3264        }
3265        cx.run_until_parked();
3266
3267        assert_eq!(
3268            visible_entries_as_strings(&sidebar, cx),
3269            vec![
3270                "v [my-project]",
3271                "  Fix crash in project panel",
3272                "  Add inline diff view",
3273                "  Refactor settings module",
3274            ]
3275        );
3276
3277        // User types "diff" in the search box — only the matching thread remains,
3278        // with its workspace header preserved for context.
3279        type_in_search(&sidebar, "diff", cx);
3280        assert_eq!(
3281            visible_entries_as_strings(&sidebar, cx),
3282            vec!["v [my-project]", "  Add inline diff view  <== selected",]
3283        );
3284
3285        // User changes query to something with no matches — list is empty.
3286        type_in_search(&sidebar, "nonexistent", cx);
3287        assert_eq!(
3288            visible_entries_as_strings(&sidebar, cx),
3289            Vec::<String>::new()
3290        );
3291    }
3292
3293    #[gpui::test]
3294    async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
3295        // Scenario: A user remembers a thread title but not the exact casing.
3296        // Search should match case-insensitively so they can still find it.
3297        let project = init_test_project("/my-project", cx).await;
3298        let (multi_workspace, cx) =
3299            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3300        let sidebar = setup_sidebar(&multi_workspace, cx);
3301
3302        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3303        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3304
3305        let save_task = thread_store.update(cx, |store, cx| {
3306            store.save_thread(
3307                acp::SessionId::new(Arc::from("thread-1")),
3308                make_test_thread(
3309                    "Fix Crash In Project Panel",
3310                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3311                ),
3312                path_list.clone(),
3313                cx,
3314            )
3315        });
3316        save_task.await.unwrap();
3317        cx.run_until_parked();
3318
3319        // Lowercase query matches mixed-case title.
3320        type_in_search(&sidebar, "fix crash", cx);
3321        assert_eq!(
3322            visible_entries_as_strings(&sidebar, cx),
3323            vec![
3324                "v [my-project]",
3325                "  Fix Crash In Project Panel  <== selected",
3326            ]
3327        );
3328
3329        // Uppercase query also matches the same title.
3330        type_in_search(&sidebar, "FIX CRASH", cx);
3331        assert_eq!(
3332            visible_entries_as_strings(&sidebar, cx),
3333            vec![
3334                "v [my-project]",
3335                "  Fix Crash In Project Panel  <== selected",
3336            ]
3337        );
3338    }
3339
3340    #[gpui::test]
3341    async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) {
3342        // Scenario: A user searches, finds what they need, then presses Escape
3343        // to dismiss the filter and see the full list again.
3344        let project = init_test_project("/my-project", cx).await;
3345        let (multi_workspace, cx) =
3346            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3347        let sidebar = setup_sidebar(&multi_workspace, cx);
3348
3349        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3350        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3351
3352        for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] {
3353            let save_task = thread_store.update(cx, |store, cx| {
3354                store.save_thread(
3355                    acp::SessionId::new(Arc::from(id)),
3356                    make_test_thread(
3357                        title,
3358                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3359                    ),
3360                    path_list.clone(),
3361                    cx,
3362                )
3363            });
3364            save_task.await.unwrap();
3365        }
3366        cx.run_until_parked();
3367
3368        // Confirm the full list is showing.
3369        assert_eq!(
3370            visible_entries_as_strings(&sidebar, cx),
3371            vec!["v [my-project]", "  Alpha thread", "  Beta thread",]
3372        );
3373
3374        // User types a search query to filter down.
3375        open_and_focus_sidebar(&sidebar, cx);
3376        type_in_search(&sidebar, "alpha", cx);
3377        assert_eq!(
3378            visible_entries_as_strings(&sidebar, cx),
3379            vec!["v [my-project]", "  Alpha thread  <== selected",]
3380        );
3381
3382        // User presses Escape — filter clears, full list is restored.
3383        cx.dispatch_action(Cancel);
3384        cx.run_until_parked();
3385        assert_eq!(
3386            visible_entries_as_strings(&sidebar, cx),
3387            vec![
3388                "v [my-project]",
3389                "  Alpha thread  <== selected",
3390                "  Beta thread",
3391            ]
3392        );
3393    }
3394
3395    #[gpui::test]
3396    async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
3397        let project_a = init_test_project("/project-a", cx).await;
3398        let (multi_workspace, cx) =
3399            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3400        let sidebar = setup_sidebar(&multi_workspace, cx);
3401
3402        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
3403        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3404
3405        for (id, title, hour) in [
3406            ("a1", "Fix bug in sidebar", 2),
3407            ("a2", "Add tests for editor", 1),
3408        ] {
3409            let save_task = thread_store.update(cx, |store, cx| {
3410                store.save_thread(
3411                    acp::SessionId::new(Arc::from(id)),
3412                    make_test_thread(
3413                        title,
3414                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3415                    ),
3416                    path_list_a.clone(),
3417                    cx,
3418                )
3419            });
3420            save_task.await.unwrap();
3421        }
3422
3423        // Add a second workspace.
3424        multi_workspace.update_in(cx, |mw, window, cx| {
3425            mw.create_workspace(window, cx);
3426        });
3427        cx.run_until_parked();
3428
3429        let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
3430
3431        for (id, title, hour) in [
3432            ("b1", "Refactor sidebar layout", 3),
3433            ("b2", "Fix typo in README", 1),
3434        ] {
3435            let save_task = thread_store.update(cx, |store, cx| {
3436                store.save_thread(
3437                    acp::SessionId::new(Arc::from(id)),
3438                    make_test_thread(
3439                        title,
3440                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3441                    ),
3442                    path_list_b.clone(),
3443                    cx,
3444                )
3445            });
3446            save_task.await.unwrap();
3447        }
3448        cx.run_until_parked();
3449
3450        assert_eq!(
3451            visible_entries_as_strings(&sidebar, cx),
3452            vec![
3453                "v [project-a]",
3454                "  Fix bug in sidebar",
3455                "  Add tests for editor",
3456                "v [Empty Workspace]",
3457                "  Refactor sidebar layout",
3458                "  Fix typo in README",
3459            ]
3460        );
3461
3462        // "sidebar" matches a thread in each workspace — both headers stay visible.
3463        type_in_search(&sidebar, "sidebar", cx);
3464        assert_eq!(
3465            visible_entries_as_strings(&sidebar, cx),
3466            vec![
3467                "v [project-a]",
3468                "  Fix bug in sidebar  <== selected",
3469                "v [Empty Workspace]",
3470                "  Refactor sidebar layout",
3471            ]
3472        );
3473
3474        // "typo" only matches in the second workspace — the first header disappears.
3475        type_in_search(&sidebar, "typo", cx);
3476        assert_eq!(
3477            visible_entries_as_strings(&sidebar, cx),
3478            vec!["v [Empty Workspace]", "  Fix typo in README  <== selected",]
3479        );
3480
3481        // "project-a" matches the first workspace name — the header appears
3482        // with all child threads included.
3483        type_in_search(&sidebar, "project-a", cx);
3484        assert_eq!(
3485            visible_entries_as_strings(&sidebar, cx),
3486            vec![
3487                "v [project-a]",
3488                "  Fix bug in sidebar  <== selected",
3489                "  Add tests for editor",
3490            ]
3491        );
3492    }
3493
3494    #[gpui::test]
3495    async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
3496        let project_a = init_test_project("/alpha-project", cx).await;
3497        let (multi_workspace, cx) =
3498            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
3499        let sidebar = setup_sidebar(&multi_workspace, cx);
3500
3501        let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]);
3502        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3503
3504        for (id, title, hour) in [
3505            ("a1", "Fix bug in sidebar", 2),
3506            ("a2", "Add tests for editor", 1),
3507        ] {
3508            let save_task = thread_store.update(cx, |store, cx| {
3509                store.save_thread(
3510                    acp::SessionId::new(Arc::from(id)),
3511                    make_test_thread(
3512                        title,
3513                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3514                    ),
3515                    path_list_a.clone(),
3516                    cx,
3517                )
3518            });
3519            save_task.await.unwrap();
3520        }
3521
3522        // Add a second workspace.
3523        multi_workspace.update_in(cx, |mw, window, cx| {
3524            mw.create_workspace(window, cx);
3525        });
3526        cx.run_until_parked();
3527
3528        let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
3529
3530        for (id, title, hour) in [
3531            ("b1", "Refactor sidebar layout", 3),
3532            ("b2", "Fix typo in README", 1),
3533        ] {
3534            let save_task = thread_store.update(cx, |store, cx| {
3535                store.save_thread(
3536                    acp::SessionId::new(Arc::from(id)),
3537                    make_test_thread(
3538                        title,
3539                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3540                    ),
3541                    path_list_b.clone(),
3542                    cx,
3543                )
3544            });
3545            save_task.await.unwrap();
3546        }
3547        cx.run_until_parked();
3548
3549        // "alpha" matches the workspace name "alpha-project" but no thread titles.
3550        // The workspace header should appear with all child threads included.
3551        type_in_search(&sidebar, "alpha", cx);
3552        assert_eq!(
3553            visible_entries_as_strings(&sidebar, cx),
3554            vec![
3555                "v [alpha-project]",
3556                "  Fix bug in sidebar  <== selected",
3557                "  Add tests for editor",
3558            ]
3559        );
3560
3561        // "sidebar" matches thread titles in both workspaces but not workspace names.
3562        // Both headers appear with their matching threads.
3563        type_in_search(&sidebar, "sidebar", cx);
3564        assert_eq!(
3565            visible_entries_as_strings(&sidebar, cx),
3566            vec![
3567                "v [alpha-project]",
3568                "  Fix bug in sidebar  <== selected",
3569                "v [Empty Workspace]",
3570                "  Refactor sidebar layout",
3571            ]
3572        );
3573
3574        // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r
3575        // doesn't match) — but does not match either workspace name or any thread.
3576        // Actually let's test something simpler: a query that matches both a workspace
3577        // name AND some threads in that workspace. Matching threads should still appear.
3578        type_in_search(&sidebar, "fix", cx);
3579        assert_eq!(
3580            visible_entries_as_strings(&sidebar, cx),
3581            vec![
3582                "v [alpha-project]",
3583                "  Fix bug in sidebar  <== selected",
3584                "v [Empty Workspace]",
3585                "  Fix typo in README",
3586            ]
3587        );
3588
3589        // A query that matches a workspace name AND a thread in that same workspace.
3590        // Both the header (highlighted) and all child threads should appear.
3591        type_in_search(&sidebar, "alpha", cx);
3592        assert_eq!(
3593            visible_entries_as_strings(&sidebar, cx),
3594            vec![
3595                "v [alpha-project]",
3596                "  Fix bug in sidebar  <== selected",
3597                "  Add tests for editor",
3598            ]
3599        );
3600
3601        // Now search for something that matches only a workspace name when there
3602        // are also threads with matching titles — the non-matching workspace's
3603        // threads should still appear if their titles match.
3604        type_in_search(&sidebar, "alp", cx);
3605        assert_eq!(
3606            visible_entries_as_strings(&sidebar, cx),
3607            vec![
3608                "v [alpha-project]",
3609                "  Fix bug in sidebar  <== selected",
3610                "  Add tests for editor",
3611            ]
3612        );
3613    }
3614
3615    #[gpui::test]
3616    async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) {
3617        let project = init_test_project("/my-project", cx).await;
3618        let (multi_workspace, cx) =
3619            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3620        let sidebar = setup_sidebar(&multi_workspace, cx);
3621
3622        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3623        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3624
3625        // Create 8 threads. The oldest one has a unique name and will be
3626        // behind View More (only 5 shown by default).
3627        for i in 0..8u32 {
3628            let title = if i == 0 {
3629                "Hidden gem thread".to_string()
3630            } else {
3631                format!("Thread {}", i + 1)
3632            };
3633            let save_task = thread_store.update(cx, |store, cx| {
3634                store.save_thread(
3635                    acp::SessionId::new(Arc::from(format!("thread-{}", i))),
3636                    make_test_thread(
3637                        &title,
3638                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
3639                    ),
3640                    path_list.clone(),
3641                    cx,
3642                )
3643            });
3644            save_task.await.unwrap();
3645        }
3646        cx.run_until_parked();
3647
3648        // Confirm the thread is not visible and View More is shown.
3649        let entries = visible_entries_as_strings(&sidebar, cx);
3650        assert!(
3651            entries.iter().any(|e| e.contains("View More")),
3652            "should have View More button"
3653        );
3654        assert!(
3655            !entries.iter().any(|e| e.contains("Hidden gem")),
3656            "Hidden gem should be behind View More"
3657        );
3658
3659        // User searches for the hidden thread — it appears, and View More is gone.
3660        type_in_search(&sidebar, "hidden gem", cx);
3661        let filtered = visible_entries_as_strings(&sidebar, cx);
3662        assert_eq!(
3663            filtered,
3664            vec!["v [my-project]", "  Hidden gem thread  <== selected",]
3665        );
3666        assert!(
3667            !filtered.iter().any(|e| e.contains("View More")),
3668            "View More should not appear when filtering"
3669        );
3670    }
3671
3672    #[gpui::test]
3673    async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
3674        let project = init_test_project("/my-project", cx).await;
3675        let (multi_workspace, cx) =
3676            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3677        let sidebar = setup_sidebar(&multi_workspace, cx);
3678
3679        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3680        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3681
3682        let save_task = thread_store.update(cx, |store, cx| {
3683            store.save_thread(
3684                acp::SessionId::new(Arc::from("thread-1")),
3685                make_test_thread(
3686                    "Important thread",
3687                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3688                ),
3689                path_list.clone(),
3690                cx,
3691            )
3692        });
3693        save_task.await.unwrap();
3694        cx.run_until_parked();
3695
3696        // User focuses the sidebar and collapses the group using keyboard:
3697        // manually select the header, then press CollapseSelectedEntry to collapse.
3698        open_and_focus_sidebar(&sidebar, cx);
3699        sidebar.update_in(cx, |sidebar, _window, _cx| {
3700            sidebar.selection = Some(0);
3701        });
3702        cx.dispatch_action(CollapseSelectedEntry);
3703        cx.run_until_parked();
3704
3705        assert_eq!(
3706            visible_entries_as_strings(&sidebar, cx),
3707            vec!["> [my-project]  <== selected"]
3708        );
3709
3710        // User types a search — the thread appears even though its group is collapsed.
3711        type_in_search(&sidebar, "important", cx);
3712        assert_eq!(
3713            visible_entries_as_strings(&sidebar, cx),
3714            vec!["> [my-project]", "  Important thread  <== selected",]
3715        );
3716    }
3717
3718    #[gpui::test]
3719    async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
3720        let project = init_test_project("/my-project", cx).await;
3721        let (multi_workspace, cx) =
3722            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3723        let sidebar = setup_sidebar(&multi_workspace, cx);
3724
3725        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3726        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3727
3728        for (id, title, hour) in [
3729            ("t-1", "Fix crash in panel", 3),
3730            ("t-2", "Fix lint warnings", 2),
3731            ("t-3", "Add new feature", 1),
3732        ] {
3733            let save_task = thread_store.update(cx, |store, cx| {
3734                store.save_thread(
3735                    acp::SessionId::new(Arc::from(id)),
3736                    make_test_thread(
3737                        title,
3738                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
3739                    ),
3740                    path_list.clone(),
3741                    cx,
3742                )
3743            });
3744            save_task.await.unwrap();
3745        }
3746        cx.run_until_parked();
3747
3748        open_and_focus_sidebar(&sidebar, cx);
3749
3750        // User types "fix" — two threads match.
3751        type_in_search(&sidebar, "fix", cx);
3752        assert_eq!(
3753            visible_entries_as_strings(&sidebar, cx),
3754            vec![
3755                "v [my-project]",
3756                "  Fix crash in panel  <== selected",
3757                "  Fix lint warnings",
3758            ]
3759        );
3760
3761        // Selection starts on the first matching thread. User presses
3762        // SelectNext to move to the second match.
3763        cx.dispatch_action(SelectNext);
3764        assert_eq!(
3765            visible_entries_as_strings(&sidebar, cx),
3766            vec![
3767                "v [my-project]",
3768                "  Fix crash in panel",
3769                "  Fix lint warnings  <== selected",
3770            ]
3771        );
3772
3773        // User can also jump back with SelectPrevious.
3774        cx.dispatch_action(SelectPrevious);
3775        assert_eq!(
3776            visible_entries_as_strings(&sidebar, cx),
3777            vec![
3778                "v [my-project]",
3779                "  Fix crash in panel  <== selected",
3780                "  Fix lint warnings",
3781            ]
3782        );
3783    }
3784
3785    #[gpui::test]
3786    async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) {
3787        let project = init_test_project("/my-project", cx).await;
3788        let (multi_workspace, cx) =
3789            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3790        let sidebar = setup_sidebar(&multi_workspace, cx);
3791
3792        multi_workspace.update_in(cx, |mw, window, cx| {
3793            mw.create_workspace(window, cx);
3794        });
3795        cx.run_until_parked();
3796
3797        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3798        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3799
3800        let save_task = thread_store.update(cx, |store, cx| {
3801            store.save_thread(
3802                acp::SessionId::new(Arc::from("hist-1")),
3803                make_test_thread(
3804                    "Historical Thread",
3805                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
3806                ),
3807                path_list.clone(),
3808                cx,
3809            )
3810        });
3811        save_task.await.unwrap();
3812        cx.run_until_parked();
3813        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3814        cx.run_until_parked();
3815
3816        assert_eq!(
3817            visible_entries_as_strings(&sidebar, cx),
3818            vec![
3819                "v [my-project]",
3820                "  Historical Thread",
3821                "v [Empty Workspace]",
3822                "  [+ New Thread]",
3823            ]
3824        );
3825
3826        // Switch to workspace 1 so we can verify the confirm switches back.
3827        multi_workspace.update_in(cx, |mw, window, cx| {
3828            mw.activate_index(1, window, cx);
3829        });
3830        cx.run_until_parked();
3831        assert_eq!(
3832            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
3833            1
3834        );
3835
3836        // Confirm on the historical (non-live) thread at index 1.
3837        // Before a previous fix, the workspace field was Option<usize> and
3838        // historical threads had None, so activate_thread early-returned
3839        // without switching the workspace.
3840        sidebar.update_in(cx, |sidebar, window, cx| {
3841            sidebar.selection = Some(1);
3842            sidebar.confirm(&Confirm, window, cx);
3843        });
3844        cx.run_until_parked();
3845
3846        assert_eq!(
3847            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
3848            0
3849        );
3850    }
3851
3852    #[gpui::test]
3853    async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
3854        let project = init_test_project("/my-project", cx).await;
3855        let (multi_workspace, cx) =
3856            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3857        let sidebar = setup_sidebar(&multi_workspace, cx);
3858
3859        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3860        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3861
3862        let save_task = thread_store.update(cx, |store, cx| {
3863            store.save_thread(
3864                acp::SessionId::new(Arc::from("t-1")),
3865                make_test_thread(
3866                    "Thread A",
3867                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
3868                ),
3869                path_list.clone(),
3870                cx,
3871            )
3872        });
3873        save_task.await.unwrap();
3874        let save_task = thread_store.update(cx, |store, cx| {
3875            store.save_thread(
3876                acp::SessionId::new(Arc::from("t-2")),
3877                make_test_thread(
3878                    "Thread B",
3879                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3880                ),
3881                path_list.clone(),
3882                cx,
3883            )
3884        });
3885        save_task.await.unwrap();
3886        cx.run_until_parked();
3887        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3888        cx.run_until_parked();
3889
3890        assert_eq!(
3891            visible_entries_as_strings(&sidebar, cx),
3892            vec!["v [my-project]", "  Thread A", "  Thread B",]
3893        );
3894
3895        // Keyboard confirm preserves selection.
3896        sidebar.update_in(cx, |sidebar, window, cx| {
3897            sidebar.selection = Some(1);
3898            sidebar.confirm(&Confirm, window, cx);
3899        });
3900        assert_eq!(
3901            sidebar.read_with(cx, |sidebar, _| sidebar.selection),
3902            Some(1)
3903        );
3904
3905        // Click handlers clear selection to None so no highlight lingers
3906        // after a click regardless of focus state. The hover style provides
3907        // visual feedback during mouse interaction instead.
3908        sidebar.update_in(cx, |sidebar, window, cx| {
3909            sidebar.selection = None;
3910            let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3911            sidebar.toggle_collapse(&path_list, window, cx);
3912        });
3913        assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
3914
3915        // When the user tabs back into the sidebar, focus_in no longer
3916        // restores selection — it stays None.
3917        sidebar.update_in(cx, |sidebar, window, cx| {
3918            sidebar.focus_in(window, cx);
3919        });
3920        assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
3921    }
3922
3923    #[gpui::test]
3924    async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
3925        let project = init_test_project("/my-project", cx).await;
3926        let (multi_workspace, cx) =
3927            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3928        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
3929
3930        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3931
3932        let connection = StubAgentConnection::new();
3933        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3934            acp::ContentChunk::new("Hi there!".into()),
3935        )]);
3936        open_thread_with_connection(&panel, connection, cx);
3937        send_message(&panel, cx);
3938
3939        let session_id = active_session_id(&panel, cx);
3940        save_thread_to_store(&session_id, &path_list, cx).await;
3941        cx.run_until_parked();
3942
3943        assert_eq!(
3944            visible_entries_as_strings(&sidebar, cx),
3945            vec!["v [my-project]", "  Hello *"]
3946        );
3947
3948        // Simulate the agent generating a title. The notification chain is:
3949        // AcpThread::set_title emits TitleUpdated →
3950        // ConnectionView::handle_thread_event calls cx.notify() →
3951        // AgentPanel observer fires and emits AgentPanelEvent →
3952        // Sidebar subscription calls update_entries / rebuild_contents.
3953        //
3954        // Before the fix, handle_thread_event did NOT call cx.notify() for
3955        // TitleUpdated, so the AgentPanel observer never fired and the
3956        // sidebar kept showing the old title.
3957        let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap());
3958        thread.update(cx, |thread, cx| {
3959            thread
3960                .set_title("Friendly Greeting with AI".into(), cx)
3961                .detach();
3962        });
3963        cx.run_until_parked();
3964
3965        assert_eq!(
3966            visible_entries_as_strings(&sidebar, cx),
3967            vec!["v [my-project]", "  Friendly Greeting with AI *"]
3968        );
3969    }
3970
3971    #[gpui::test]
3972    async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
3973        let project_a = init_test_project("/project-a", cx).await;
3974        let (multi_workspace, cx) = cx
3975            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
3976        let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
3977
3978        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
3979
3980        // Save a thread so it appears in the list.
3981        let connection_a = StubAgentConnection::new();
3982        connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3983            acp::ContentChunk::new("Done".into()),
3984        )]);
3985        open_thread_with_connection(&panel_a, connection_a, cx);
3986        send_message(&panel_a, cx);
3987        let session_id_a = active_session_id(&panel_a, cx);
3988        save_thread_to_store(&session_id_a, &path_list_a, cx).await;
3989
3990        // Add a second workspace with its own agent panel.
3991        let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
3992        fs.as_fake()
3993            .insert_tree("/project-b", serde_json::json!({ "src": {} }))
3994            .await;
3995        let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
3996        let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
3997            mw.test_add_workspace(project_b.clone(), window, cx)
3998        });
3999        let panel_b = add_agent_panel(&workspace_b, &project_b, cx);
4000        cx.run_until_parked();
4001
4002        let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone());
4003
4004        // ── 1. Initial state: no focused thread ──────────────────────────────
4005        // Workspace B is active (just added) and has no thread, so its header
4006        // is the active entry.
4007        sidebar.read_with(cx, |sidebar, _cx| {
4008            assert_eq!(
4009                sidebar.focused_thread, None,
4010                "Initially no thread should be focused"
4011            );
4012            let active_entry = sidebar
4013                .active_entry_index
4014                .and_then(|ix| sidebar.contents.entries.get(ix));
4015            assert!(
4016                matches!(active_entry, Some(ListEntry::ProjectHeader { .. })),
4017                "Active entry should be the active workspace header"
4018            );
4019        });
4020
4021        // ── 2. Click thread in workspace A via sidebar ───────────────────────
4022        sidebar.update_in(cx, |sidebar, window, cx| {
4023            sidebar.activate_thread(
4024                Agent::NativeAgent,
4025                acp_thread::AgentSessionInfo {
4026                    session_id: session_id_a.clone(),
4027                    cwd: None,
4028                    title: Some("Test".into()),
4029                    updated_at: None,
4030                    created_at: None,
4031                    meta: None,
4032                },
4033                &workspace_a,
4034                window,
4035                cx,
4036            );
4037        });
4038        cx.run_until_parked();
4039
4040        sidebar.read_with(cx, |sidebar, _cx| {
4041            assert_eq!(
4042                sidebar.focused_thread.as_ref(),
4043                Some(&session_id_a),
4044                "After clicking a thread, it should be the focused thread"
4045            );
4046            let active_entry = sidebar.active_entry_index
4047                .and_then(|ix| sidebar.contents.entries.get(ix));
4048            assert!(
4049                matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_a),
4050                "Active entry should be the clicked thread"
4051            );
4052        });
4053
4054        workspace_a.read_with(cx, |workspace, cx| {
4055            assert!(
4056                workspace.panel::<AgentPanel>(cx).is_some(),
4057                "Agent panel should exist"
4058            );
4059            let dock = workspace.right_dock().read(cx);
4060            assert!(
4061                dock.is_open(),
4062                "Clicking a thread should open the agent panel dock"
4063            );
4064        });
4065
4066        // ── 3. Open thread in workspace B, then click it via sidebar ─────────
4067        let connection_b = StubAgentConnection::new();
4068        connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4069            acp::ContentChunk::new("Thread B".into()),
4070        )]);
4071        open_thread_with_connection(&panel_b, connection_b, cx);
4072        send_message(&panel_b, cx);
4073        let session_id_b = active_session_id(&panel_b, cx);
4074        let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
4075        save_thread_to_store(&session_id_b, &path_list_b, cx).await;
4076        cx.run_until_parked();
4077
4078        // Opening a thread in a non-active workspace should NOT change
4079        // focused_thread — it's derived from the active workspace.
4080        sidebar.read_with(cx, |sidebar, _cx| {
4081            assert_eq!(
4082                sidebar.focused_thread.as_ref(),
4083                Some(&session_id_a),
4084                "Opening a thread in a non-active workspace should not affect focused_thread"
4085            );
4086        });
4087
4088        // Workspace A is currently active. Click a thread in workspace B,
4089        // which also triggers a workspace switch.
4090        sidebar.update_in(cx, |sidebar, window, cx| {
4091            sidebar.activate_thread(
4092                Agent::NativeAgent,
4093                acp_thread::AgentSessionInfo {
4094                    session_id: session_id_b.clone(),
4095                    cwd: None,
4096                    title: Some("Thread B".into()),
4097                    updated_at: None,
4098                    created_at: None,
4099                    meta: None,
4100                },
4101                &workspace_b,
4102                window,
4103                cx,
4104            );
4105        });
4106        cx.run_until_parked();
4107
4108        sidebar.read_with(cx, |sidebar, _cx| {
4109            assert_eq!(
4110                sidebar.focused_thread.as_ref(),
4111                Some(&session_id_b),
4112                "Clicking a thread in another workspace should focus that thread"
4113            );
4114            let active_entry = sidebar
4115                .active_entry_index
4116                .and_then(|ix| sidebar.contents.entries.get(ix));
4117            assert!(
4118                matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b),
4119                "Active entry should be the cross-workspace thread"
4120            );
4121        });
4122
4123        // ── 4. Switch workspace → focused_thread reflects new workspace ──────
4124        multi_workspace.update_in(cx, |mw, window, cx| {
4125            mw.activate_next_workspace(window, cx);
4126        });
4127        cx.run_until_parked();
4128
4129        // Workspace A is now active. Its agent panel still has session_id_a
4130        // loaded, so focused_thread should reflect that.
4131        sidebar.read_with(cx, |sidebar, _cx| {
4132            assert_eq!(
4133                sidebar.focused_thread.as_ref(),
4134                Some(&session_id_a),
4135                "Switching workspaces should derive focused_thread from the new active workspace"
4136            );
4137            let active_entry = sidebar
4138                .active_entry_index
4139                .and_then(|ix| sidebar.contents.entries.get(ix));
4140            assert!(
4141                matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_a),
4142                "Active entry should be workspace_a's active thread"
4143            );
4144        });
4145
4146        // ── 5. Opening a thread in a non-active workspace is ignored ──────────
4147        let connection_b2 = StubAgentConnection::new();
4148        connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
4149            acp::ContentChunk::new("New thread".into()),
4150        )]);
4151        open_thread_with_connection(&panel_b, connection_b2, cx);
4152        send_message(&panel_b, cx);
4153        let session_id_b2 = active_session_id(&panel_b, cx);
4154        save_thread_to_store(&session_id_b2, &path_list_b, cx).await;
4155        cx.run_until_parked();
4156
4157        // Workspace A is still active, so focused_thread stays on session_id_a.
4158        sidebar.read_with(cx, |sidebar, _cx| {
4159            assert_eq!(
4160                sidebar.focused_thread.as_ref(),
4161                Some(&session_id_a),
4162                "Opening a thread in a non-active workspace should not affect focused_thread"
4163            );
4164        });
4165
4166        // ── 6. Activating workspace B shows its active thread ────────────────
4167        sidebar.update_in(cx, |sidebar, window, cx| {
4168            sidebar.activate_workspace(&workspace_b, window, cx);
4169        });
4170        cx.run_until_parked();
4171
4172        // Workspace B is now active with session_id_b2 loaded.
4173        sidebar.read_with(cx, |sidebar, _cx| {
4174            assert_eq!(
4175                sidebar.focused_thread.as_ref(),
4176                Some(&session_id_b2),
4177                "Activating workspace_b should show workspace_b's active thread"
4178            );
4179            let active_entry = sidebar
4180                .active_entry_index
4181                .and_then(|ix| sidebar.contents.entries.get(ix));
4182            assert!(
4183                matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b2),
4184                "Active entry should be workspace_b's active thread"
4185            );
4186        });
4187
4188        // ── 7. Switching back to workspace A reflects its thread ─────────────
4189        multi_workspace.update_in(cx, |mw, window, cx| {
4190            mw.activate_next_workspace(window, cx);
4191        });
4192        cx.run_until_parked();
4193
4194        sidebar.read_with(cx, |sidebar, _cx| {
4195            assert_eq!(
4196                sidebar.focused_thread.as_ref(),
4197                Some(&session_id_a),
4198                "Switching back to workspace_a should show its active thread"
4199            );
4200        });
4201    }
4202
4203    async fn save_named_thread(
4204        session_id: &str,
4205        title: &str,
4206        path_list: &PathList,
4207        cx: &mut gpui::VisualTestContext,
4208    ) {
4209        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
4210        let save_task = thread_store.update(cx, |store, cx| {
4211            store.save_thread(
4212                acp::SessionId::new(Arc::from(session_id)),
4213                make_test_thread(
4214                    title,
4215                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
4216                ),
4217                path_list.clone(),
4218                cx,
4219            )
4220        });
4221        save_task.await.unwrap();
4222        cx.run_until_parked();
4223    }
4224
4225    async fn init_test_project_with_git(
4226        worktree_path: &str,
4227        cx: &mut TestAppContext,
4228    ) -> (Entity<project::Project>, Arc<dyn fs::Fs>) {
4229        init_test(cx);
4230        let fs = FakeFs::new(cx.executor());
4231        fs.insert_tree(
4232            worktree_path,
4233            serde_json::json!({
4234                ".git": {},
4235                "src": {},
4236            }),
4237        )
4238        .await;
4239        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4240        let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await;
4241        (project, fs)
4242    }
4243
4244    #[gpui::test]
4245    async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
4246        let (project, fs) = init_test_project_with_git("/project", cx).await;
4247
4248        fs.as_fake()
4249            .with_git_state(std::path::Path::new("/project/.git"), false, |state| {
4250                state.worktrees.push(git::repository::Worktree {
4251                    path: std::path::PathBuf::from("/wt/rosewood"),
4252                    ref_name: "refs/heads/rosewood".into(),
4253                    sha: "abc".into(),
4254                });
4255            })
4256            .unwrap();
4257
4258        project
4259            .update(cx, |project, cx| project.git_scans_complete(cx))
4260            .await;
4261
4262        let (multi_workspace, cx) =
4263            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4264        let sidebar = setup_sidebar(&multi_workspace, cx);
4265
4266        let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]);
4267        let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
4268        save_named_thread("main-t", "Unrelated Thread", &main_paths, cx).await;
4269        save_named_thread("wt-t", "Fix Bug", &wt_paths, cx).await;
4270
4271        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4272        cx.run_until_parked();
4273
4274        // Search for "rosewood" — should match the worktree name, not the title.
4275        type_in_search(&sidebar, "rosewood", cx);
4276
4277        assert_eq!(
4278            visible_entries_as_strings(&sidebar, cx),
4279            vec!["v [project]", "  Fix Bug {rosewood}  <== selected"],
4280        );
4281    }
4282
4283    #[gpui::test]
4284    async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
4285        let (project, fs) = init_test_project_with_git("/project", cx).await;
4286
4287        project
4288            .update(cx, |project, cx| project.git_scans_complete(cx))
4289            .await;
4290
4291        let (multi_workspace, cx) =
4292            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4293        let sidebar = setup_sidebar(&multi_workspace, cx);
4294
4295        // Save a thread against a worktree path that doesn't exist yet.
4296        let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
4297        save_named_thread("wt-thread", "Worktree Thread", &wt_paths, cx).await;
4298
4299        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4300        cx.run_until_parked();
4301
4302        // Thread is not visible yet — no worktree knows about this path.
4303        assert_eq!(
4304            visible_entries_as_strings(&sidebar, cx),
4305            vec!["v [project]", "  [+ New Thread]"]
4306        );
4307
4308        // Now add the worktree to the git state and trigger a rescan.
4309        fs.as_fake()
4310            .with_git_state(std::path::Path::new("/project/.git"), true, |state| {
4311                state.worktrees.push(git::repository::Worktree {
4312                    path: std::path::PathBuf::from("/wt/rosewood"),
4313                    ref_name: "refs/heads/rosewood".into(),
4314                    sha: "abc".into(),
4315                });
4316            })
4317            .unwrap();
4318
4319        cx.run_until_parked();
4320
4321        assert_eq!(
4322            visible_entries_as_strings(&sidebar, cx),
4323            vec!["v [project]", "  Worktree Thread {rosewood}",]
4324        );
4325    }
4326
4327    #[gpui::test]
4328    async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) {
4329        init_test(cx);
4330        let fs = FakeFs::new(cx.executor());
4331
4332        // Create the main repo directory (not opened as a workspace yet).
4333        fs.insert_tree(
4334            "/project",
4335            serde_json::json!({
4336                ".git": {
4337                    "worktrees": {
4338                        "feature-a": {
4339                            "commondir": "../../",
4340                            "HEAD": "ref: refs/heads/feature-a",
4341                        },
4342                        "feature-b": {
4343                            "commondir": "../../",
4344                            "HEAD": "ref: refs/heads/feature-b",
4345                        },
4346                    },
4347                },
4348                "src": {},
4349            }),
4350        )
4351        .await;
4352
4353        // Two worktree checkouts whose .git files point back to the main repo.
4354        fs.insert_tree(
4355            "/wt-feature-a",
4356            serde_json::json!({
4357                ".git": "gitdir: /project/.git/worktrees/feature-a",
4358                "src": {},
4359            }),
4360        )
4361        .await;
4362        fs.insert_tree(
4363            "/wt-feature-b",
4364            serde_json::json!({
4365                ".git": "gitdir: /project/.git/worktrees/feature-b",
4366                "src": {},
4367            }),
4368        )
4369        .await;
4370
4371        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4372
4373        let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4374        let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
4375
4376        project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
4377        project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
4378
4379        // Open both worktrees as workspaces — no main repo yet.
4380        let (multi_workspace, cx) = cx
4381            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4382        multi_workspace.update_in(cx, |mw, window, cx| {
4383            mw.test_add_workspace(project_b.clone(), window, cx);
4384        });
4385        let sidebar = setup_sidebar(&multi_workspace, cx);
4386
4387        let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
4388        let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]);
4389        save_named_thread("thread-a", "Thread A", &paths_a, cx).await;
4390        save_named_thread("thread-b", "Thread B", &paths_b, cx).await;
4391
4392        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4393        cx.run_until_parked();
4394
4395        // Without the main repo, each worktree has its own header.
4396        assert_eq!(
4397            visible_entries_as_strings(&sidebar, cx),
4398            vec![
4399                "v [wt-feature-a]",
4400                "  Thread A",
4401                "v [wt-feature-b]",
4402                "  Thread B",
4403            ]
4404        );
4405
4406        // Configure the main repo to list both worktrees before opening
4407        // it so the initial git scan picks them up.
4408        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
4409            state.worktrees.push(git::repository::Worktree {
4410                path: std::path::PathBuf::from("/wt-feature-a"),
4411                ref_name: "refs/heads/feature-a".into(),
4412                sha: "aaa".into(),
4413            });
4414            state.worktrees.push(git::repository::Worktree {
4415                path: std::path::PathBuf::from("/wt-feature-b"),
4416                ref_name: "refs/heads/feature-b".into(),
4417                sha: "bbb".into(),
4418            });
4419        })
4420        .unwrap();
4421
4422        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4423        main_project
4424            .update(cx, |p, cx| p.git_scans_complete(cx))
4425            .await;
4426
4427        multi_workspace.update_in(cx, |mw, window, cx| {
4428            mw.test_add_workspace(main_project.clone(), window, cx);
4429        });
4430        cx.run_until_parked();
4431
4432        // Both worktree workspaces should now be absorbed under the main
4433        // repo header, with worktree chips.
4434        assert_eq!(
4435            visible_entries_as_strings(&sidebar, cx),
4436            vec![
4437                "v [project]",
4438                "  Thread A {wt-feature-a}",
4439                "  Thread B {wt-feature-b}",
4440            ]
4441        );
4442
4443        // Remove feature-b from the main repo's linked worktrees.
4444        // The feature-b workspace should be pruned automatically.
4445        fs.with_git_state(std::path::Path::new("/project/.git"), true, |state| {
4446            state
4447                .worktrees
4448                .retain(|wt| wt.path != std::path::Path::new("/wt-feature-b"));
4449        })
4450        .unwrap();
4451
4452        cx.run_until_parked();
4453
4454        // feature-b's workspace is pruned; feature-a remains absorbed
4455        // under the main repo.
4456        assert_eq!(
4457            visible_entries_as_strings(&sidebar, cx),
4458            vec!["v [project]", "  Thread A {wt-feature-a}",]
4459        );
4460    }
4461
4462    #[gpui::test]
4463    async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(
4464        cx: &mut TestAppContext,
4465    ) {
4466        init_test(cx);
4467        let fs = FakeFs::new(cx.executor());
4468
4469        fs.insert_tree(
4470            "/project",
4471            serde_json::json!({
4472                ".git": {
4473                    "worktrees": {
4474                        "feature-a": {
4475                            "commondir": "../../",
4476                            "HEAD": "ref: refs/heads/feature-a",
4477                        },
4478                    },
4479                },
4480                "src": {},
4481            }),
4482        )
4483        .await;
4484
4485        fs.insert_tree(
4486            "/wt-feature-a",
4487            serde_json::json!({
4488                ".git": "gitdir: /project/.git/worktrees/feature-a",
4489                "src": {},
4490            }),
4491        )
4492        .await;
4493
4494        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
4495            state.worktrees.push(git::repository::Worktree {
4496                path: std::path::PathBuf::from("/wt-feature-a"),
4497                ref_name: "refs/heads/feature-a".into(),
4498                sha: "aaa".into(),
4499            });
4500        })
4501        .unwrap();
4502
4503        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4504
4505        // Only open the main repo — no workspace for the worktree.
4506        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4507        main_project
4508            .update(cx, |p, cx| p.git_scans_complete(cx))
4509            .await;
4510
4511        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
4512            MultiWorkspace::test_new(main_project.clone(), window, cx)
4513        });
4514        let sidebar = setup_sidebar(&multi_workspace, cx);
4515
4516        // Save a thread for the worktree path (no workspace for it).
4517        let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
4518        save_named_thread("thread-wt", "WT Thread", &paths_wt, cx).await;
4519
4520        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4521        cx.run_until_parked();
4522
4523        // Thread should appear under the main repo with a worktree chip.
4524        assert_eq!(
4525            visible_entries_as_strings(&sidebar, cx),
4526            vec!["v [project]", "  WT Thread {wt-feature-a}"],
4527        );
4528
4529        // Only 1 workspace should exist.
4530        assert_eq!(
4531            multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
4532            1,
4533        );
4534
4535        // Focus the sidebar and select the worktree thread.
4536        open_and_focus_sidebar(&sidebar, cx);
4537        sidebar.update_in(cx, |sidebar, _window, _cx| {
4538            sidebar.selection = Some(1); // index 0 is header, 1 is the thread
4539        });
4540
4541        // Confirm to open the worktree thread.
4542        cx.dispatch_action(Confirm);
4543        cx.run_until_parked();
4544
4545        // A new workspace should have been created for the worktree path.
4546        let new_workspace = multi_workspace.read_with(cx, |mw, _| {
4547            assert_eq!(
4548                mw.workspaces().len(),
4549                2,
4550                "confirming a worktree thread without a workspace should open one",
4551            );
4552            mw.workspaces()[1].clone()
4553        });
4554
4555        let new_path_list =
4556            new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
4557        assert_eq!(
4558            new_path_list,
4559            PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
4560            "the new workspace should have been opened for the worktree path",
4561        );
4562    }
4563
4564    #[gpui::test]
4565    async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
4566        cx: &mut TestAppContext,
4567    ) {
4568        init_test(cx);
4569        let fs = FakeFs::new(cx.executor());
4570
4571        fs.insert_tree(
4572            "/project",
4573            serde_json::json!({
4574                ".git": {
4575                    "worktrees": {
4576                        "feature-a": {
4577                            "commondir": "../../",
4578                            "HEAD": "ref: refs/heads/feature-a",
4579                        },
4580                    },
4581                },
4582                "src": {},
4583            }),
4584        )
4585        .await;
4586
4587        fs.insert_tree(
4588            "/wt-feature-a",
4589            serde_json::json!({
4590                ".git": "gitdir: /project/.git/worktrees/feature-a",
4591                "src": {},
4592            }),
4593        )
4594        .await;
4595
4596        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
4597            state.worktrees.push(git::repository::Worktree {
4598                path: std::path::PathBuf::from("/wt-feature-a"),
4599                ref_name: "refs/heads/feature-a".into(),
4600                sha: "aaa".into(),
4601            });
4602        })
4603        .unwrap();
4604
4605        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4606
4607        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
4608        let worktree_project =
4609            project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
4610
4611        main_project
4612            .update(cx, |p, cx| p.git_scans_complete(cx))
4613            .await;
4614        worktree_project
4615            .update(cx, |p, cx| p.git_scans_complete(cx))
4616            .await;
4617
4618        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
4619            MultiWorkspace::test_new(main_project.clone(), window, cx)
4620        });
4621
4622        let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
4623            mw.test_add_workspace(worktree_project.clone(), window, cx)
4624        });
4625
4626        // Activate the main workspace before setting up the sidebar.
4627        multi_workspace.update_in(cx, |mw, window, cx| {
4628            mw.activate_index(0, window, cx);
4629        });
4630
4631        let sidebar = setup_sidebar(&multi_workspace, cx);
4632
4633        let paths_main = PathList::new(&[std::path::PathBuf::from("/project")]);
4634        let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
4635        save_named_thread("thread-main", "Main Thread", &paths_main, cx).await;
4636        save_named_thread("thread-wt", "WT Thread", &paths_wt, cx).await;
4637
4638        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
4639        cx.run_until_parked();
4640
4641        // The worktree workspace should be absorbed under the main repo.
4642        let entries = visible_entries_as_strings(&sidebar, cx);
4643        assert_eq!(entries.len(), 3);
4644        assert_eq!(entries[0], "v [project]");
4645        assert!(entries.contains(&"  Main Thread".to_string()));
4646        assert!(entries.contains(&"  WT Thread {wt-feature-a}".to_string()));
4647
4648        let wt_thread_index = entries
4649            .iter()
4650            .position(|e| e.contains("WT Thread"))
4651            .expect("should find the worktree thread entry");
4652
4653        assert_eq!(
4654            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4655            0,
4656            "main workspace should be active initially"
4657        );
4658
4659        // Focus the sidebar and select the absorbed worktree thread.
4660        open_and_focus_sidebar(&sidebar, cx);
4661        sidebar.update_in(cx, |sidebar, _window, _cx| {
4662            sidebar.selection = Some(wt_thread_index);
4663        });
4664
4665        // Confirm to activate the worktree thread.
4666        cx.dispatch_action(Confirm);
4667        cx.run_until_parked();
4668
4669        // The worktree workspace should now be active, not the main one.
4670        let active_workspace = multi_workspace.read_with(cx, |mw, _| {
4671            mw.workspaces()[mw.active_workspace_index()].clone()
4672        });
4673        assert_eq!(
4674            active_workspace, worktree_workspace,
4675            "clicking an absorbed worktree thread should activate the worktree workspace"
4676        );
4677    }
4678
4679    #[gpui::test]
4680    async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace(
4681        cx: &mut TestAppContext,
4682    ) {
4683        // Thread has saved metadata in ThreadStore. A matching workspace is
4684        // already open. Expected: activates the matching workspace.
4685        init_test(cx);
4686        let fs = FakeFs::new(cx.executor());
4687        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4688            .await;
4689        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4690            .await;
4691        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4692
4693        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4694        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4695
4696        let (multi_workspace, cx) =
4697            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4698
4699        multi_workspace.update_in(cx, |mw, window, cx| {
4700            mw.test_add_workspace(project_b, window, cx);
4701        });
4702
4703        let sidebar = setup_sidebar(&multi_workspace, cx);
4704
4705        // Save a thread with path_list pointing to project-b.
4706        let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
4707        let session_id = acp::SessionId::new(Arc::from("archived-1"));
4708        save_thread_to_store(&session_id, &path_list_b, cx).await;
4709
4710        // Ensure workspace A is active.
4711        multi_workspace.update_in(cx, |mw, window, cx| {
4712            mw.activate_index(0, window, cx);
4713        });
4714        cx.run_until_parked();
4715        assert_eq!(
4716            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4717            0
4718        );
4719
4720        // Call activate_archived_thread – should resolve saved paths and
4721        // switch to the workspace for project-b.
4722        sidebar.update_in(cx, |sidebar, window, cx| {
4723            sidebar.activate_archived_thread(
4724                Agent::NativeAgent,
4725                acp_thread::AgentSessionInfo {
4726                    session_id: session_id.clone(),
4727                    cwd: Some("/project-b".into()),
4728                    title: Some("Archived Thread".into()),
4729                    updated_at: None,
4730                    created_at: None,
4731                    meta: None,
4732                },
4733                window,
4734                cx,
4735            );
4736        });
4737        cx.run_until_parked();
4738
4739        assert_eq!(
4740            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4741            1,
4742            "should have activated the workspace matching the saved path_list"
4743        );
4744    }
4745
4746    #[gpui::test]
4747    async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
4748        cx: &mut TestAppContext,
4749    ) {
4750        // Thread has no saved metadata but session_info has cwd. A matching
4751        // workspace is open. Expected: uses cwd to find and activate it.
4752        init_test(cx);
4753        let fs = FakeFs::new(cx.executor());
4754        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4755            .await;
4756        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4757            .await;
4758        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4759
4760        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4761        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4762
4763        let (multi_workspace, cx) =
4764            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4765
4766        multi_workspace.update_in(cx, |mw, window, cx| {
4767            mw.test_add_workspace(project_b, window, cx);
4768        });
4769
4770        let sidebar = setup_sidebar(&multi_workspace, cx);
4771
4772        // Start with workspace A active.
4773        multi_workspace.update_in(cx, |mw, window, cx| {
4774            mw.activate_index(0, window, cx);
4775        });
4776        cx.run_until_parked();
4777        assert_eq!(
4778            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4779            0
4780        );
4781
4782        // No thread saved to the store – cwd is the only path hint.
4783        sidebar.update_in(cx, |sidebar, window, cx| {
4784            sidebar.activate_archived_thread(
4785                Agent::NativeAgent,
4786                acp_thread::AgentSessionInfo {
4787                    session_id: acp::SessionId::new(Arc::from("unknown-session")),
4788                    cwd: Some(std::path::PathBuf::from("/project-b")),
4789                    title: Some("CWD Thread".into()),
4790                    updated_at: None,
4791                    created_at: None,
4792                    meta: None,
4793                },
4794                window,
4795                cx,
4796            );
4797        });
4798        cx.run_until_parked();
4799
4800        assert_eq!(
4801            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4802            1,
4803            "should have activated the workspace matching the cwd"
4804        );
4805    }
4806
4807    #[gpui::test]
4808    async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
4809        cx: &mut TestAppContext,
4810    ) {
4811        // Thread has no saved metadata and no cwd. Expected: falls back to
4812        // the currently active workspace.
4813        init_test(cx);
4814        let fs = FakeFs::new(cx.executor());
4815        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4816            .await;
4817        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4818            .await;
4819        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4820
4821        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4822        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
4823
4824        let (multi_workspace, cx) =
4825            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4826
4827        multi_workspace.update_in(cx, |mw, window, cx| {
4828            mw.test_add_workspace(project_b, window, cx);
4829        });
4830
4831        let sidebar = setup_sidebar(&multi_workspace, cx);
4832
4833        // Activate workspace B (index 1) to make it the active one.
4834        multi_workspace.update_in(cx, |mw, window, cx| {
4835            mw.activate_index(1, window, cx);
4836        });
4837        cx.run_until_parked();
4838        assert_eq!(
4839            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4840            1
4841        );
4842
4843        // No saved thread, no cwd – should fall back to the active workspace.
4844        sidebar.update_in(cx, |sidebar, window, cx| {
4845            sidebar.activate_archived_thread(
4846                Agent::NativeAgent,
4847                acp_thread::AgentSessionInfo {
4848                    session_id: acp::SessionId::new(Arc::from("no-context-session")),
4849                    cwd: None,
4850                    title: Some("Contextless Thread".into()),
4851                    updated_at: None,
4852                    created_at: None,
4853                    meta: None,
4854                },
4855                window,
4856                cx,
4857            );
4858        });
4859        cx.run_until_parked();
4860
4861        assert_eq!(
4862            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
4863            1,
4864            "should have stayed on the active workspace when no path info is available"
4865        );
4866    }
4867
4868    #[gpui::test]
4869    async fn test_activate_archived_thread_saved_paths_opens_new_workspace(
4870        cx: &mut TestAppContext,
4871    ) {
4872        // Thread has saved metadata pointing to a path with no open workspace.
4873        // Expected: opens a new workspace for that path.
4874        init_test(cx);
4875        let fs = FakeFs::new(cx.executor());
4876        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
4877            .await;
4878        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
4879            .await;
4880        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
4881
4882        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
4883
4884        let (multi_workspace, cx) =
4885            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
4886
4887        let sidebar = setup_sidebar(&multi_workspace, cx);
4888
4889        // Save a thread with path_list pointing to project-b – which has no
4890        // open workspace.
4891        let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
4892        let session_id = acp::SessionId::new(Arc::from("archived-new-ws"));
4893        save_thread_to_store(&session_id, &path_list_b, cx).await;
4894
4895        assert_eq!(
4896            multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
4897            1,
4898            "should start with one workspace"
4899        );
4900
4901        sidebar.update_in(cx, |sidebar, window, cx| {
4902            sidebar.activate_archived_thread(
4903                Agent::NativeAgent,
4904                acp_thread::AgentSessionInfo {
4905                    session_id: session_id.clone(),
4906                    cwd: None,
4907                    title: Some("New WS Thread".into()),
4908                    updated_at: None,
4909                    created_at: None,
4910                    meta: None,
4911                },
4912                window,
4913                cx,
4914            );
4915        });
4916        cx.run_until_parked();
4917
4918        assert_eq!(
4919            multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
4920            2,
4921            "should have opened a second workspace for the archived thread's saved paths"
4922        );
4923    }
4924}