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