sidebar.rs

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