threads_panel.rs

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