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