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