sidebar.rs

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