sidebar.rs

   1use acp_thread::ThreadStatus;
   2use agent::ThreadStore;
   3use agent_client_protocol as acp;
   4use agent_ui::{AgentPanel, AgentPanelEvent, NewThread};
   5use chrono::Utc;
   6use editor::{Editor, EditorElement, EditorStyle};
   7use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _};
   8use gpui::{
   9    AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, ListState,
  10    Pixels, Render, SharedString, TextStyle, WeakEntity, Window, actions, list, prelude::*, px,
  11    relative, rems,
  12};
  13use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
  14use project::Event as ProjectEvent;
  15use settings::Settings;
  16use std::collections::{HashMap, HashSet};
  17use std::mem;
  18use theme::{ActiveTheme, ThemeSettings};
  19use ui::utils::TRAFFIC_LIGHT_PADDING;
  20use ui::{
  21    AgentThreadStatus, HighlightedLabel, IconButtonShape, KeyBinding, ListItem, Tab, ThreadItem,
  22    Tooltip, WithScrollbar, prelude::*,
  23};
  24use util::path_list::PathList;
  25use workspace::{
  26    FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, Sidebar as WorkspaceSidebar,
  27    SidebarEvent, ToggleWorkspaceSidebar, Workspace,
  28};
  29use zed_actions::editor::{MoveDown, MoveUp};
  30
  31actions!(
  32    agents_sidebar,
  33    [
  34        /// Collapses the selected entry in the workspace sidebar.
  35        CollapseSelectedEntry,
  36        /// Expands the selected entry in the workspace sidebar.
  37        ExpandSelectedEntry,
  38    ]
  39);
  40
  41const DEFAULT_WIDTH: Pixels = px(320.0);
  42const MIN_WIDTH: Pixels = px(200.0);
  43const MAX_WIDTH: Pixels = px(800.0);
  44const DEFAULT_THREADS_SHOWN: usize = 5;
  45
  46#[derive(Clone, Debug)]
  47struct ActiveThreadInfo {
  48    session_id: acp::SessionId,
  49    title: SharedString,
  50    status: AgentThreadStatus,
  51    icon: IconName,
  52    icon_from_external_svg: Option<SharedString>,
  53    is_background: bool,
  54}
  55
  56impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo {
  57    fn from(info: &ActiveThreadInfo) -> Self {
  58        Self {
  59            session_id: info.session_id.clone(),
  60            cwd: None,
  61            title: Some(info.title.clone()),
  62            updated_at: Some(Utc::now()),
  63            meta: None,
  64        }
  65    }
  66}
  67
  68#[derive(Clone, Debug)]
  69#[allow(dead_code)]
  70enum ListEntry {
  71    ProjectHeader {
  72        path_list: PathList,
  73        label: SharedString,
  74        workspace: Entity<Workspace>,
  75        highlight_positions: Vec<usize>,
  76    },
  77    Thread {
  78        session_info: acp_thread::AgentSessionInfo,
  79        icon: IconName,
  80        icon_from_external_svg: Option<SharedString>,
  81        status: AgentThreadStatus,
  82        diff_stats: Option<(usize, usize)>,
  83        workspace: Entity<Workspace>,
  84        is_live: bool,
  85        is_background: bool,
  86        highlight_positions: Vec<usize>,
  87    },
  88    ViewMore {
  89        path_list: PathList,
  90        remaining_count: usize,
  91    },
  92    NewThread {
  93        path_list: PathList,
  94        workspace: Entity<Workspace>,
  95    },
  96}
  97
  98#[derive(Default)]
  99struct SidebarContents {
 100    entries: Vec<ListEntry>,
 101    notified_threads: HashSet<acp::SessionId>,
 102}
 103
 104impl SidebarContents {
 105    fn is_thread_notified(&self, session_id: &acp::SessionId) -> bool {
 106        self.notified_threads.contains(session_id)
 107    }
 108}
 109
 110fn fuzzy_match_positions(query: &str, candidate: &str) -> Option<Vec<usize>> {
 111    let mut positions = Vec::new();
 112    let mut query_chars = query.chars().peekable();
 113
 114    for (byte_idx, candidate_char) in candidate.char_indices() {
 115        if let Some(&query_char) = query_chars.peek() {
 116            if candidate_char.eq_ignore_ascii_case(&query_char) {
 117                positions.push(byte_idx);
 118                query_chars.next();
 119            }
 120        } else {
 121            break;
 122        }
 123    }
 124
 125    if query_chars.peek().is_none() {
 126        Some(positions)
 127    } else {
 128        None
 129    }
 130}
 131
 132fn workspace_path_list_and_label(
 133    workspace: &Entity<Workspace>,
 134    cx: &App,
 135) -> (PathList, SharedString) {
 136    let workspace_ref = workspace.read(cx);
 137    let mut paths = Vec::new();
 138    let mut names = Vec::new();
 139
 140    for worktree in workspace_ref.worktrees(cx) {
 141        let worktree_ref = worktree.read(cx);
 142        if !worktree_ref.is_visible() {
 143            continue;
 144        }
 145        let abs_path = worktree_ref.abs_path();
 146        paths.push(abs_path.to_path_buf());
 147        if let Some(name) = abs_path.file_name() {
 148            names.push(name.to_string_lossy().to_string());
 149        }
 150    }
 151
 152    let label: SharedString = if names.is_empty() {
 153        // TODO: Can we do something better in this case?
 154        "Empty Workspace".into()
 155    } else {
 156        names.join(", ").into()
 157    };
 158
 159    (PathList::new(&paths), label)
 160}
 161
 162pub struct Sidebar {
 163    multi_workspace: WeakEntity<MultiWorkspace>,
 164    width: Pixels,
 165    focus_handle: FocusHandle,
 166    filter_editor: Entity<Editor>,
 167    list_state: ListState,
 168    contents: SidebarContents,
 169    /// The index of the list item that currently has the keyboard focus
 170    ///
 171    /// Note: This is NOT the same as the active item.
 172    selection: Option<usize>,
 173    focused_thread: Option<acp::SessionId>,
 174    active_entry_index: Option<usize>,
 175    collapsed_groups: HashSet<PathList>,
 176    expanded_groups: HashSet<PathList>,
 177}
 178
 179impl EventEmitter<SidebarEvent> for Sidebar {}
 180
 181impl Sidebar {
 182    pub fn new(
 183        multi_workspace: Entity<MultiWorkspace>,
 184        window: &mut Window,
 185        cx: &mut Context<Self>,
 186    ) -> Self {
 187        let focus_handle = cx.focus_handle();
 188        cx.on_focus_in(&focus_handle, window, Self::focus_in)
 189            .detach();
 190
 191        let filter_editor = cx.new(|cx| {
 192            let mut editor = Editor::single_line(window, cx);
 193            editor.set_placeholder_text("Search…", window, cx);
 194            editor
 195        });
 196
 197        cx.subscribe_in(
 198            &multi_workspace,
 199            window,
 200            |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
 201                MultiWorkspaceEvent::ActiveWorkspaceChanged => {
 202                    this.focused_thread = None;
 203                    this.update_entries(cx);
 204                }
 205                MultiWorkspaceEvent::WorkspaceAdded(workspace) => {
 206                    this.subscribe_to_workspace(workspace, window, cx);
 207                    this.update_entries(cx);
 208                }
 209                MultiWorkspaceEvent::WorkspaceRemoved(_) => {
 210                    this.update_entries(cx);
 211                }
 212            },
 213        )
 214        .detach();
 215
 216        cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
 217            if let editor::EditorEvent::BufferEdited = event {
 218                let query = this.filter_editor.read(cx).text(cx);
 219                if !query.is_empty() {
 220                    this.selection.take();
 221                }
 222                this.update_entries(cx);
 223                if !query.is_empty() {
 224                    this.selection = this
 225                        .contents
 226                        .entries
 227                        .iter()
 228                        .position(|entry| matches!(entry, ListEntry::Thread { .. }))
 229                        .or_else(|| {
 230                            if this.contents.entries.is_empty() {
 231                                None
 232                            } else {
 233                                Some(0)
 234                            }
 235                        });
 236                }
 237            }
 238        })
 239        .detach();
 240
 241        let thread_store = ThreadStore::global(cx);
 242        cx.observe_in(&thread_store, window, |this, _, _window, cx| {
 243            this.update_entries(cx);
 244        })
 245        .detach();
 246
 247        cx.observe_flag::<AgentV2FeatureFlag, _>(window, |_is_enabled, this, _window, cx| {
 248            this.update_entries(cx);
 249        })
 250        .detach();
 251
 252        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
 253        cx.defer_in(window, move |this, window, cx| {
 254            for workspace in &workspaces {
 255                this.subscribe_to_workspace(workspace, window, cx);
 256            }
 257            this.update_entries(cx);
 258        });
 259
 260        Self {
 261            multi_workspace: multi_workspace.downgrade(),
 262            width: DEFAULT_WIDTH,
 263            focus_handle,
 264            filter_editor,
 265            list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
 266            contents: SidebarContents::default(),
 267            selection: None,
 268            focused_thread: None,
 269            active_entry_index: None,
 270            collapsed_groups: HashSet::new(),
 271            expanded_groups: HashSet::new(),
 272        }
 273    }
 274
 275    fn subscribe_to_workspace(
 276        &self,
 277        workspace: &Entity<Workspace>,
 278        window: &mut Window,
 279        cx: &mut Context<Self>,
 280    ) {
 281        let project = workspace.read(cx).project().clone();
 282        cx.subscribe_in(
 283            &project,
 284            window,
 285            |this, _project, event, _window, cx| match event {
 286                ProjectEvent::WorktreeAdded(_)
 287                | ProjectEvent::WorktreeRemoved(_)
 288                | ProjectEvent::WorktreeOrderChanged => {
 289                    this.update_entries(cx);
 290                }
 291                _ => {}
 292            },
 293        )
 294        .detach();
 295
 296        cx.subscribe_in(
 297            workspace,
 298            window,
 299            |this, _workspace, event: &workspace::Event, window, cx| {
 300                if let workspace::Event::PanelAdded(view) = event {
 301                    if let Ok(agent_panel) = view.clone().downcast::<AgentPanel>() {
 302                        this.subscribe_to_agent_panel(&agent_panel, window, cx);
 303                    }
 304                }
 305            },
 306        )
 307        .detach();
 308
 309        if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
 310            self.subscribe_to_agent_panel(&agent_panel, window, cx);
 311        }
 312    }
 313
 314    fn subscribe_to_agent_panel(
 315        &self,
 316        agent_panel: &Entity<AgentPanel>,
 317        window: &mut Window,
 318        cx: &mut Context<Self>,
 319    ) {
 320        cx.subscribe_in(
 321            agent_panel,
 322            window,
 323            |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event {
 324                AgentPanelEvent::ActiveViewChanged => {
 325                    if let Some(thread) = agent_panel.read(cx).active_connection_view()
 326                        && let Some(session_id) = thread.read(cx).parent_id(cx)
 327                    {
 328                        this.focused_thread = Some(session_id);
 329                    }
 330                    this.update_entries(cx);
 331                }
 332                AgentPanelEvent::ThreadFocused => {
 333                    let new_focused = agent_panel
 334                        .read(cx)
 335                        .active_connection_view()
 336                        .and_then(|thread| thread.read(cx).parent_id(cx));
 337                    if new_focused != this.focused_thread {
 338                        this.focused_thread = new_focused;
 339                        this.update_entries(cx);
 340                    }
 341                }
 342                AgentPanelEvent::BackgroundThreadChanged => {
 343                    this.update_entries(cx);
 344                }
 345            },
 346        )
 347        .detach();
 348    }
 349
 350    fn all_thread_infos_for_workspace(
 351        workspace: &Entity<Workspace>,
 352        cx: &App,
 353    ) -> Vec<ActiveThreadInfo> {
 354        let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
 355            return Vec::new();
 356        };
 357        let agent_panel_ref = agent_panel.read(cx);
 358
 359        agent_panel_ref
 360            .parent_threads(cx)
 361            .into_iter()
 362            .map(|thread_view| {
 363                let thread_view_ref = thread_view.read(cx);
 364                let thread = thread_view_ref.thread.read(cx);
 365
 366                let icon = thread_view_ref.agent_icon;
 367                let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone();
 368                let title = thread.title();
 369                let session_id = thread.session_id().clone();
 370                let is_background = agent_panel_ref.is_background_thread(&session_id);
 371
 372                let status = if thread.is_waiting_for_confirmation() {
 373                    AgentThreadStatus::WaitingForConfirmation
 374                } else if thread.had_error() {
 375                    AgentThreadStatus::Error
 376                } else {
 377                    match thread.status() {
 378                        ThreadStatus::Generating => AgentThreadStatus::Running,
 379                        ThreadStatus::Idle => AgentThreadStatus::Completed,
 380                    }
 381                };
 382
 383                ActiveThreadInfo {
 384                    session_id,
 385                    title,
 386                    status,
 387                    icon,
 388                    icon_from_external_svg,
 389                    is_background,
 390                }
 391            })
 392            .collect()
 393    }
 394
 395    fn rebuild_contents(&mut self, cx: &App) {
 396        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
 397            return;
 398        };
 399        let mw = multi_workspace.read(cx);
 400        let workspaces = mw.workspaces().to_vec();
 401        let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned();
 402
 403        let thread_store = ThreadStore::try_global(cx);
 404        let query = self.filter_editor.read(cx).text(cx);
 405
 406        let previous = mem::take(&mut self.contents);
 407
 408        let old_statuses: HashMap<acp::SessionId, AgentThreadStatus> = previous
 409            .entries
 410            .iter()
 411            .filter_map(|entry| match entry {
 412                ListEntry::Thread {
 413                    session_info,
 414                    status,
 415                    is_live: true,
 416                    ..
 417                } => Some((session_info.session_id.clone(), *status)),
 418                _ => None,
 419            })
 420            .collect();
 421
 422        let mut entries = Vec::new();
 423        let mut notified_threads = previous.notified_threads;
 424
 425        for workspace in workspaces.iter() {
 426            let (path_list, label) = workspace_path_list_and_label(workspace, cx);
 427
 428            let is_collapsed = self.collapsed_groups.contains(&path_list);
 429            let should_load_threads = !is_collapsed || !query.is_empty();
 430
 431            let mut threads: Vec<ListEntry> = Vec::new();
 432
 433            if should_load_threads {
 434                if let Some(ref thread_store) = thread_store {
 435                    for meta in thread_store.read(cx).threads_for_paths(&path_list) {
 436                        threads.push(ListEntry::Thread {
 437                            session_info: meta.into(),
 438                            icon: IconName::ZedAgent,
 439                            icon_from_external_svg: None,
 440                            status: AgentThreadStatus::default(),
 441                            diff_stats: None,
 442                            workspace: workspace.clone(),
 443                            is_live: false,
 444                            is_background: false,
 445                            highlight_positions: Vec::new(),
 446                        });
 447                    }
 448                }
 449
 450                let live_infos = Self::all_thread_infos_for_workspace(workspace, cx);
 451
 452                for info in &live_infos {
 453                    let Some(existing) = threads.iter_mut().find(|t| {
 454                        matches!(t, ListEntry::Thread { session_info, .. } if session_info.session_id == info.session_id)
 455                    }) else {
 456                        continue;
 457                    };
 458
 459                    if let ListEntry::Thread {
 460                        session_info,
 461                        status,
 462                        icon,
 463                        icon_from_external_svg,
 464                        workspace: _,
 465                        is_live,
 466                        is_background,
 467                        ..
 468                    } = existing
 469                    {
 470                        session_info.title = Some(info.title.clone());
 471                        *status = info.status;
 472                        *icon = info.icon;
 473                        *icon_from_external_svg = info.icon_from_external_svg.clone();
 474                        *is_live = true;
 475                        *is_background = info.is_background;
 476                    }
 477                }
 478
 479                // Update notification state for live threads.
 480                for thread in &threads {
 481                    if let ListEntry::Thread {
 482                        workspace: thread_workspace,
 483                        session_info,
 484                        status,
 485                        is_background,
 486                        ..
 487                    } = thread
 488                    {
 489                        let session_id = &session_info.session_id;
 490                        if *is_background && *status == AgentThreadStatus::Completed {
 491                            notified_threads.insert(session_id.clone());
 492                        } else if *status == AgentThreadStatus::Completed
 493                            && active_workspace
 494                                .as_ref()
 495                                .is_none_or(|active| active != thread_workspace)
 496                            && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running)
 497                        {
 498                            notified_threads.insert(session_id.clone());
 499                        }
 500
 501                        if active_workspace
 502                            .as_ref()
 503                            .is_some_and(|active| active == thread_workspace)
 504                            && !*is_background
 505                        {
 506                            notified_threads.remove(session_id);
 507                        }
 508                    }
 509                }
 510
 511                threads.sort_by(|a, b| {
 512                    let a_time = match a {
 513                        ListEntry::Thread { session_info, .. } => session_info.updated_at,
 514                        _ => unreachable!(),
 515                    };
 516                    let b_time = match b {
 517                        ListEntry::Thread { session_info, .. } => session_info.updated_at,
 518                        _ => unreachable!(),
 519                    };
 520                    b_time.cmp(&a_time)
 521                });
 522            }
 523
 524            if !query.is_empty() {
 525                let mut matched_threads = Vec::new();
 526                for mut thread in threads {
 527                    if let ListEntry::Thread {
 528                        session_info,
 529                        highlight_positions,
 530                        ..
 531                    } = &mut thread
 532                    {
 533                        let title = session_info
 534                            .title
 535                            .as_ref()
 536                            .map(|s| s.as_ref())
 537                            .unwrap_or("");
 538                        if let Some(positions) = fuzzy_match_positions(&query, title) {
 539                            *highlight_positions = positions;
 540                            matched_threads.push(thread);
 541                        }
 542                    }
 543                }
 544
 545                let workspace_highlight_positions =
 546                    fuzzy_match_positions(&query, &label).unwrap_or_default();
 547
 548                if matched_threads.is_empty() && workspace_highlight_positions.is_empty() {
 549                    continue;
 550                }
 551
 552                entries.push(ListEntry::ProjectHeader {
 553                    path_list: path_list.clone(),
 554                    label,
 555                    workspace: workspace.clone(),
 556                    highlight_positions: workspace_highlight_positions,
 557                });
 558                entries.extend(matched_threads);
 559            } else {
 560                entries.push(ListEntry::ProjectHeader {
 561                    path_list: path_list.clone(),
 562                    label,
 563                    workspace: workspace.clone(),
 564                    highlight_positions: Vec::new(),
 565                });
 566
 567                if is_collapsed {
 568                    continue;
 569                }
 570
 571                let total = threads.len();
 572                let show_view_more =
 573                    total > DEFAULT_THREADS_SHOWN && !self.expanded_groups.contains(&path_list);
 574
 575                let count = if show_view_more {
 576                    DEFAULT_THREADS_SHOWN
 577                } else {
 578                    total
 579                };
 580
 581                entries.extend(threads.into_iter().take(count));
 582
 583                if show_view_more {
 584                    entries.push(ListEntry::ViewMore {
 585                        path_list: path_list.clone(),
 586                        remaining_count: total - DEFAULT_THREADS_SHOWN,
 587                    });
 588                }
 589
 590                if total == 0 {
 591                    entries.push(ListEntry::NewThread {
 592                        path_list: path_list.clone(),
 593                        workspace: workspace.clone(),
 594                    });
 595                }
 596            }
 597        }
 598
 599        // Prune stale entries from notified_threads.
 600        let current_session_ids: HashSet<&acp::SessionId> = entries
 601            .iter()
 602            .filter_map(|e| match e {
 603                ListEntry::Thread { session_info, .. } => Some(&session_info.session_id),
 604                _ => None,
 605            })
 606            .collect();
 607        notified_threads.retain(|id| current_session_ids.contains(id));
 608
 609        self.contents = SidebarContents {
 610            entries,
 611            notified_threads,
 612        };
 613    }
 614
 615    fn update_entries(&mut self, cx: &mut Context<Self>) {
 616        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
 617            return;
 618        };
 619        if !multi_workspace.read(cx).multi_workspace_enabled(cx) {
 620            return;
 621        }
 622
 623        let had_notifications = self.has_notifications(cx);
 624
 625        self.rebuild_contents(cx);
 626        self.recompute_active_entry_index(cx);
 627
 628        self.list_state.reset(self.contents.entries.len());
 629
 630        if had_notifications != self.has_notifications(cx) {
 631            multi_workspace.update(cx, |_, cx| {
 632                cx.notify();
 633            });
 634        }
 635
 636        cx.notify();
 637    }
 638
 639    fn recompute_active_entry_index(&mut self, cx: &App) {
 640        self.active_entry_index = if let Some(session_id) = &self.focused_thread {
 641            self.contents.entries.iter().position(|entry| {
 642                matches!(entry, ListEntry::Thread { session_info, .. } if &session_info.session_id == session_id)
 643            })
 644        } else {
 645            let active_workspace = self
 646                .multi_workspace
 647                .upgrade()
 648                .map(|mw| mw.read(cx).workspace().clone());
 649            active_workspace.and_then(|active| {
 650                self.contents.entries.iter().position(|entry| {
 651                    matches!(entry, ListEntry::ProjectHeader { workspace, .. } if workspace == &active)
 652                })
 653            })
 654        };
 655    }
 656
 657    fn render_list_entry(
 658        &mut self,
 659        ix: usize,
 660        window: &mut Window,
 661        cx: &mut Context<Self>,
 662    ) -> AnyElement {
 663        let Some(entry) = self.contents.entries.get(ix) else {
 664            return div().into_any_element();
 665        };
 666        let is_focused = self.focus_handle.is_focused(window)
 667            || self.filter_editor.focus_handle(cx).is_focused(window);
 668        // is_selected means the keyboard selector is here.
 669        let is_selected = is_focused && self.selection == Some(ix);
 670
 671        let is_group_header_after_first =
 672            ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. });
 673
 674        let rendered = match entry {
 675            ListEntry::ProjectHeader {
 676                path_list,
 677                label,
 678                workspace,
 679                highlight_positions,
 680            } => self.render_project_header(
 681                ix,
 682                path_list,
 683                label,
 684                workspace,
 685                highlight_positions,
 686                is_selected,
 687                cx,
 688            ),
 689            ListEntry::Thread {
 690                session_info,
 691                icon,
 692                icon_from_external_svg,
 693                status,
 694                workspace,
 695                highlight_positions,
 696                ..
 697            } => self.render_thread(
 698                ix,
 699                session_info,
 700                *icon,
 701                icon_from_external_svg.clone(),
 702                *status,
 703                workspace,
 704                highlight_positions,
 705                is_selected,
 706                cx,
 707            ),
 708            ListEntry::ViewMore {
 709                path_list,
 710                remaining_count,
 711            } => self.render_view_more(ix, path_list, *remaining_count, is_selected, cx),
 712            ListEntry::NewThread {
 713                path_list,
 714                workspace,
 715            } => self.render_new_thread(ix, path_list, workspace, is_selected, cx),
 716        };
 717
 718        // add the blue border here, not in the sub methods
 719
 720        if is_group_header_after_first {
 721            v_flex()
 722                .w_full()
 723                .border_t_1()
 724                .border_color(cx.theme().colors().border_variant)
 725                .child(rendered)
 726                .into_any_element()
 727        } else {
 728            rendered
 729        }
 730    }
 731
 732    fn render_project_header(
 733        &self,
 734        ix: usize,
 735        path_list: &PathList,
 736        label: &SharedString,
 737        workspace: &Entity<Workspace>,
 738        highlight_positions: &[usize],
 739        is_selected: bool,
 740        cx: &mut Context<Self>,
 741    ) -> AnyElement {
 742        let id = SharedString::from(format!("project-header-{}", ix));
 743        let ib_id = SharedString::from(format!("project-header-new-thread-{}", ix));
 744        let group = SharedString::from(format!("group-{}", ix));
 745
 746        let is_collapsed = self.collapsed_groups.contains(path_list);
 747        let disclosure_icon = if is_collapsed {
 748            IconName::ChevronRight
 749        } else {
 750            IconName::ChevronDown
 751        };
 752        let workspace_for_new_thread = workspace.clone();
 753        let workspace_for_remove = workspace.clone();
 754        // let workspace_for_activate = workspace.clone();
 755        let path_list_for_toggle = path_list.clone();
 756        let multi_workspace = self.multi_workspace.upgrade();
 757        let workspace_count = multi_workspace
 758            .as_ref()
 759            .map_or(0, |mw| mw.read(cx).workspaces().len());
 760        let is_active_workspace = self.focused_thread.is_none()
 761            && multi_workspace
 762                .as_ref()
 763                .is_some_and(|mw| mw.read(cx).workspace() == workspace);
 764
 765        let label = if highlight_positions.is_empty() {
 766            Label::new(label.clone())
 767                .size(LabelSize::Small)
 768                .color(Color::Muted)
 769                .into_any_element()
 770        } else {
 771            HighlightedLabel::new(label.clone(), highlight_positions.to_vec())
 772                .size(LabelSize::Small)
 773                .color(Color::Muted)
 774                .into_any_element()
 775        };
 776
 777        // TODO: if is_selected, draw a blue border around the item.
 778
 779        ListItem::new(id)
 780            .selection_outlined(is_selected)
 781            .group_name(&group)
 782            .toggle_state(is_active_workspace)
 783            .child(
 784                h_flex().px_1().py_1p5().gap_0p5().child(label).child(
 785                    div().visible_on_hover(group).child(
 786                        Icon::new(disclosure_icon)
 787                            .size(IconSize::Small)
 788                            .color(Color::Muted),
 789                    ),
 790                ),
 791            )
 792            .end_hover_slot(
 793                h_flex()
 794                    .gap_0p5()
 795                    .when(workspace_count > 1, |this| {
 796                        this.child(
 797                            IconButton::new(
 798                                SharedString::from(format!("project-header-remove-{}", ix)),
 799                                IconName::Close,
 800                            )
 801                            .icon_size(IconSize::Small)
 802                            .icon_color(Color::Muted)
 803                            .tooltip(Tooltip::text("Remove Project"))
 804                            .on_click(cx.listener(
 805                                move |this, _, window, cx| {
 806                                    this.remove_workspace(&workspace_for_remove, window, cx);
 807                                },
 808                            )),
 809                        )
 810                    })
 811                    .child(
 812                        IconButton::new(ib_id, IconName::NewThread)
 813                            .icon_size(IconSize::Small)
 814                            .icon_color(Color::Muted)
 815                            .tooltip(Tooltip::text("New Thread"))
 816                            .on_click(cx.listener(move |this, _, window, cx| {
 817                                this.selection = None;
 818                                this.create_new_thread(&workspace_for_new_thread, window, cx);
 819                            })),
 820                    ),
 821            )
 822            .on_click(cx.listener(move |this, _, window, cx| {
 823                this.toggle_collapse(&path_list_for_toggle, window, cx);
 824            }))
 825            // TODO: Decide if we really want the header to be activating different workspaces
 826            // .on_click(cx.listener(move |this, _, window, cx| {
 827            //     this.selection = None;
 828            //     this.activate_workspace(&workspace_for_activate, window, cx);
 829            // }))
 830            .into_any_element()
 831    }
 832
 833    fn activate_workspace(
 834        &mut self,
 835        workspace: &Entity<Workspace>,
 836        window: &mut Window,
 837        cx: &mut Context<Self>,
 838    ) {
 839        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
 840            return;
 841        };
 842
 843        self.focused_thread = None;
 844
 845        multi_workspace.update(cx, |multi_workspace, cx| {
 846            multi_workspace.activate(workspace.clone(), cx);
 847        });
 848
 849        multi_workspace.update(cx, |multi_workspace, cx| {
 850            multi_workspace.focus_active_workspace(window, cx);
 851        });
 852    }
 853
 854    fn remove_workspace(
 855        &mut self,
 856        workspace: &Entity<Workspace>,
 857        window: &mut Window,
 858        cx: &mut Context<Self>,
 859    ) {
 860        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
 861            return;
 862        };
 863
 864        multi_workspace.update(cx, |multi_workspace, cx| {
 865            let Some(index) = multi_workspace
 866                .workspaces()
 867                .iter()
 868                .position(|w| w == workspace)
 869            else {
 870                return;
 871            };
 872            multi_workspace.remove_workspace(index, window, cx);
 873        });
 874    }
 875
 876    fn toggle_collapse(
 877        &mut self,
 878        path_list: &PathList,
 879        _window: &mut Window,
 880        cx: &mut Context<Self>,
 881    ) {
 882        if self.collapsed_groups.contains(path_list) {
 883            self.collapsed_groups.remove(path_list);
 884        } else {
 885            self.collapsed_groups.insert(path_list.clone());
 886        }
 887        self.update_entries(cx);
 888    }
 889
 890    fn focus_in(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
 891        if self.selection.is_none() && !self.contents.entries.is_empty() {
 892            self.selection = Some(0);
 893            cx.notify();
 894        }
 895    }
 896
 897    fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
 898        if self.reset_filter_editor_text(window, cx) {
 899            self.update_entries(cx);
 900        } else {
 901            self.focus_handle.focus(window, cx);
 902        }
 903    }
 904
 905    fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
 906        self.filter_editor.update(cx, |editor, cx| {
 907            if editor.buffer().read(cx).len(cx).0 > 0 {
 908                editor.set_text("", window, cx);
 909                true
 910            } else {
 911                false
 912            }
 913        })
 914    }
 915
 916    fn filter_query(&self, cx: &App) -> String {
 917        self.filter_editor.read(cx).text(cx)
 918    }
 919
 920    fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
 921        self.select_next(&SelectNext, window, cx);
 922    }
 923
 924    fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
 925        self.select_previous(&SelectPrevious, window, cx);
 926    }
 927
 928    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
 929        let next = match self.selection {
 930            Some(ix) if ix + 1 < self.contents.entries.len() => ix + 1,
 931            None if !self.contents.entries.is_empty() => 0,
 932            _ => return,
 933        };
 934        self.selection = Some(next);
 935        self.list_state.scroll_to_reveal_item(next);
 936        cx.notify();
 937    }
 938
 939    fn select_previous(
 940        &mut self,
 941        _: &SelectPrevious,
 942        _window: &mut Window,
 943        cx: &mut Context<Self>,
 944    ) {
 945        let prev = match self.selection {
 946            Some(ix) if ix > 0 => ix - 1,
 947            None if !self.contents.entries.is_empty() => self.contents.entries.len() - 1,
 948            _ => return,
 949        };
 950        self.selection = Some(prev);
 951        self.list_state.scroll_to_reveal_item(prev);
 952        cx.notify();
 953    }
 954
 955    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
 956        if !self.contents.entries.is_empty() {
 957            self.selection = Some(0);
 958            self.list_state.scroll_to_reveal_item(0);
 959            cx.notify();
 960        }
 961    }
 962
 963    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
 964        if let Some(last) = self.contents.entries.len().checked_sub(1) {
 965            self.selection = Some(last);
 966            self.list_state.scroll_to_reveal_item(last);
 967            cx.notify();
 968        }
 969    }
 970
 971    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
 972        let Some(ix) = self.selection else { return };
 973        let Some(entry) = self.contents.entries.get(ix) else {
 974            return;
 975        };
 976
 977        match entry {
 978            ListEntry::ProjectHeader { workspace, .. } => {
 979                let workspace = workspace.clone();
 980                self.activate_workspace(&workspace, window, cx);
 981            }
 982            ListEntry::Thread {
 983                session_info,
 984                workspace,
 985                ..
 986            } => {
 987                let session_info = session_info.clone();
 988                let workspace = workspace.clone();
 989                self.activate_thread(session_info, &workspace, window, cx);
 990            }
 991            ListEntry::ViewMore { path_list, .. } => {
 992                let path_list = path_list.clone();
 993                self.expanded_groups.insert(path_list);
 994                self.update_entries(cx);
 995            }
 996            ListEntry::NewThread { workspace, .. } => {
 997                let workspace = workspace.clone();
 998                self.create_new_thread(&workspace, window, cx);
 999            }
1000        }
1001    }
1002
1003    fn activate_thread(
1004        &mut self,
1005        session_info: acp_thread::AgentSessionInfo,
1006        workspace: &Entity<Workspace>,
1007        window: &mut Window,
1008        cx: &mut Context<Self>,
1009    ) {
1010        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1011            return;
1012        };
1013
1014        multi_workspace.update(cx, |multi_workspace, cx| {
1015            multi_workspace.activate(workspace.clone(), cx);
1016        });
1017
1018        workspace.update(cx, |workspace, cx| {
1019            workspace.open_panel::<AgentPanel>(window, cx);
1020        });
1021
1022        if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
1023            agent_panel.update(cx, |panel, cx| {
1024                panel.load_agent_thread(
1025                    session_info.session_id,
1026                    session_info.cwd,
1027                    session_info.title,
1028                    window,
1029                    cx,
1030                );
1031            });
1032        }
1033    }
1034
1035    fn expand_selected_entry(
1036        &mut self,
1037        _: &ExpandSelectedEntry,
1038        _window: &mut Window,
1039        cx: &mut Context<Self>,
1040    ) {
1041        let Some(ix) = self.selection else { return };
1042
1043        match self.contents.entries.get(ix) {
1044            Some(ListEntry::ProjectHeader { path_list, .. }) => {
1045                if self.collapsed_groups.contains(path_list) {
1046                    let path_list = path_list.clone();
1047                    self.collapsed_groups.remove(&path_list);
1048                    self.update_entries(cx);
1049                } else if ix + 1 < self.contents.entries.len() {
1050                    self.selection = Some(ix + 1);
1051                    self.list_state.scroll_to_reveal_item(ix + 1);
1052                    cx.notify();
1053                }
1054            }
1055            _ => {}
1056        }
1057    }
1058
1059    fn collapse_selected_entry(
1060        &mut self,
1061        _: &CollapseSelectedEntry,
1062        _window: &mut Window,
1063        cx: &mut Context<Self>,
1064    ) {
1065        let Some(ix) = self.selection else { return };
1066
1067        match self.contents.entries.get(ix) {
1068            Some(ListEntry::ProjectHeader { path_list, .. }) => {
1069                if !self.collapsed_groups.contains(path_list) {
1070                    let path_list = path_list.clone();
1071                    self.collapsed_groups.insert(path_list);
1072                    self.update_entries(cx);
1073                }
1074            }
1075            Some(
1076                ListEntry::Thread { .. } | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
1077            ) => {
1078                for i in (0..ix).rev() {
1079                    if let Some(ListEntry::ProjectHeader { path_list, .. }) =
1080                        self.contents.entries.get(i)
1081                    {
1082                        let path_list = path_list.clone();
1083                        self.selection = Some(i);
1084                        self.collapsed_groups.insert(path_list);
1085                        self.update_entries(cx);
1086                        break;
1087                    }
1088                }
1089            }
1090            None => {}
1091        }
1092    }
1093
1094    fn render_thread(
1095        &self,
1096        ix: usize,
1097        session_info: &acp_thread::AgentSessionInfo,
1098        icon: IconName,
1099        icon_from_external_svg: Option<SharedString>,
1100        status: AgentThreadStatus,
1101        workspace: &Entity<Workspace>,
1102        highlight_positions: &[usize],
1103        is_selected: bool,
1104        cx: &mut Context<Self>,
1105    ) -> AnyElement {
1106        let has_notification = self.contents.is_thread_notified(&session_info.session_id);
1107
1108        let title: SharedString = session_info
1109            .title
1110            .clone()
1111            .unwrap_or_else(|| "Untitled".into());
1112        let session_info = session_info.clone();
1113        let workspace = workspace.clone();
1114
1115        let id = SharedString::from(format!("thread-entry-{}", ix));
1116        ThreadItem::new(id, title)
1117            .icon(icon)
1118            .when_some(icon_from_external_svg, |this, svg| {
1119                this.custom_icon_from_external_svg(svg)
1120            })
1121            .highlight_positions(highlight_positions.to_vec())
1122            .status(status)
1123            .notified(has_notification)
1124            .selected(self.focused_thread.as_ref() == Some(&session_info.session_id))
1125            .outlined(is_selected)
1126            .on_click(cx.listener(move |this, _, window, cx| {
1127                this.selection = None;
1128                this.activate_thread(session_info.clone(), &workspace, window, cx);
1129            }))
1130            .into_any_element()
1131    }
1132
1133    fn render_filter_input(&self, cx: &mut Context<Self>) -> impl IntoElement {
1134        let settings = ThemeSettings::get_global(cx);
1135        let text_style = TextStyle {
1136            color: cx.theme().colors().text,
1137            font_family: settings.ui_font.family.clone(),
1138            font_features: settings.ui_font.features.clone(),
1139            font_fallbacks: settings.ui_font.fallbacks.clone(),
1140            font_size: rems(0.875).into(),
1141            font_weight: settings.ui_font.weight,
1142            font_style: FontStyle::Normal,
1143            line_height: relative(1.3),
1144            ..Default::default()
1145        };
1146
1147        EditorElement::new(
1148            &self.filter_editor,
1149            EditorStyle {
1150                local_player: cx.theme().players().local(),
1151                text: text_style,
1152                ..Default::default()
1153            },
1154        )
1155    }
1156
1157    fn render_view_more(
1158        &self,
1159        ix: usize,
1160        path_list: &PathList,
1161        remaining_count: usize,
1162        is_selected: bool,
1163        cx: &mut Context<Self>,
1164    ) -> AnyElement {
1165        let path_list = path_list.clone();
1166        let id = SharedString::from(format!("view-more-{}", ix));
1167
1168        let count = format!("({})", remaining_count);
1169
1170        ListItem::new(id)
1171            .selection_outlined(is_selected)
1172            .child(
1173                h_flex()
1174                    .px_1()
1175                    .py_1p5()
1176                    .gap_1p5()
1177                    .child(
1178                        Icon::new(IconName::Plus)
1179                            .size(IconSize::Small)
1180                            .color(Color::Muted),
1181                    )
1182                    .child(Label::new("View More").color(Color::Muted))
1183                    .child(Label::new(count).color(Color::Muted).size(LabelSize::Small)),
1184            )
1185            .on_click(cx.listener(move |this, _, _window, cx| {
1186                this.selection = None;
1187                this.expanded_groups.insert(path_list.clone());
1188                this.update_entries(cx);
1189            }))
1190            .into_any_element()
1191    }
1192
1193    fn create_new_thread(
1194        &mut self,
1195        workspace: &Entity<Workspace>,
1196        window: &mut Window,
1197        cx: &mut Context<Self>,
1198    ) {
1199        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1200            return;
1201        };
1202
1203        multi_workspace.update(cx, |multi_workspace, cx| {
1204            multi_workspace.activate(workspace.clone(), cx);
1205        });
1206
1207        workspace.update(cx, |workspace, cx| {
1208            if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx) {
1209                agent_panel.update(cx, |panel, cx| {
1210                    panel.new_thread(&NewThread, window, cx);
1211                });
1212            }
1213            workspace.focus_panel::<AgentPanel>(window, cx);
1214        });
1215    }
1216
1217    fn render_new_thread(
1218        &self,
1219        ix: usize,
1220        _path_list: &PathList,
1221        workspace: &Entity<Workspace>,
1222        is_selected: bool,
1223        cx: &mut Context<Self>,
1224    ) -> AnyElement {
1225        let workspace = workspace.clone();
1226
1227        div()
1228            .w_full()
1229            .p_2()
1230            .child(
1231                Button::new(
1232                    SharedString::from(format!("new-thread-btn-{}", ix)),
1233                    "New Thread",
1234                )
1235                .full_width()
1236                .style(ButtonStyle::Outlined)
1237                .icon(IconName::Plus)
1238                .icon_color(Color::Muted)
1239                .icon_size(IconSize::Small)
1240                .icon_position(IconPosition::Start)
1241                .toggle_state(is_selected)
1242                .on_click(cx.listener(move |this, _, window, cx| {
1243                    this.selection = None;
1244                    this.create_new_thread(&workspace, window, cx);
1245                })),
1246            )
1247            .into_any_element()
1248    }
1249}
1250
1251impl WorkspaceSidebar for Sidebar {
1252    fn width(&self, _cx: &App) -> Pixels {
1253        self.width
1254    }
1255
1256    fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
1257        self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
1258        cx.notify();
1259    }
1260
1261    fn has_notifications(&self, _cx: &App) -> bool {
1262        !self.contents.notified_threads.is_empty()
1263    }
1264}
1265
1266impl Focusable for Sidebar {
1267    fn focus_handle(&self, cx: &App) -> FocusHandle {
1268        self.filter_editor.focus_handle(cx)
1269    }
1270}
1271
1272impl Render for Sidebar {
1273    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1274        let titlebar_height = ui::utils::platform_title_bar_height(window);
1275        let ui_font = theme::setup_ui_font(window, cx);
1276        let is_focused = self.focus_handle.is_focused(window)
1277            || self.filter_editor.focus_handle(cx).is_focused(window);
1278        let has_query = !self.filter_query(cx).is_empty();
1279
1280        let focus_tooltip_label = if is_focused {
1281            "Focus Workspace"
1282        } else {
1283            "Focus Sidebar"
1284        };
1285
1286        v_flex()
1287            .id("workspace-sidebar")
1288            .key_context("WorkspaceSidebar")
1289            .track_focus(&self.focus_handle)
1290            .on_action(cx.listener(Self::select_next))
1291            .on_action(cx.listener(Self::select_previous))
1292            .on_action(cx.listener(Self::editor_move_down))
1293            .on_action(cx.listener(Self::editor_move_up))
1294            .on_action(cx.listener(Self::select_first))
1295            .on_action(cx.listener(Self::select_last))
1296            .on_action(cx.listener(Self::confirm))
1297            .on_action(cx.listener(Self::expand_selected_entry))
1298            .on_action(cx.listener(Self::collapse_selected_entry))
1299            .on_action(cx.listener(Self::cancel))
1300            .font(ui_font)
1301            .h_full()
1302            .w(self.width)
1303            .bg(cx.theme().colors().surface_background)
1304            .border_r_1()
1305            .border_color(cx.theme().colors().border)
1306            .child(
1307                h_flex()
1308                    .flex_none()
1309                    .h(titlebar_height)
1310                    .w_full()
1311                    .mt_px()
1312                    .pb_px()
1313                    .pr_1()
1314                    .when_else(
1315                        cfg!(target_os = "macos") && !window.is_fullscreen(),
1316                        |this| this.pl(px(TRAFFIC_LIGHT_PADDING)),
1317                        |this| this.pl_2(),
1318                    )
1319                    .justify_between()
1320                    .border_b_1()
1321                    .border_color(cx.theme().colors().border)
1322                    .child(
1323                        h_flex()
1324                            .gap_1()
1325                            .child({
1326                                let focus_handle_toggle = self.focus_handle.clone();
1327                                let focus_handle_focus = self.focus_handle.clone();
1328                                IconButton::new("close-sidebar", IconName::WorkspaceNavOpen)
1329                                    .icon_size(IconSize::Small)
1330                                    .tooltip(Tooltip::element(move |_, cx| {
1331                                        v_flex()
1332                                            .gap_1()
1333                                            .child(
1334                                                h_flex()
1335                                                    .gap_2()
1336                                                    .justify_between()
1337                                                    .child(Label::new("Close Sidebar"))
1338                                                    .child(KeyBinding::for_action_in(
1339                                                        &ToggleWorkspaceSidebar,
1340                                                        &focus_handle_toggle,
1341                                                        cx,
1342                                                    )),
1343                                            )
1344                                            .child(
1345                                                h_flex()
1346                                                    .pt_1()
1347                                                    .gap_2()
1348                                                    .border_t_1()
1349                                                    .border_color(
1350                                                        cx.theme().colors().border_variant,
1351                                                    )
1352                                                    .justify_between()
1353                                                    .child(Label::new(focus_tooltip_label))
1354                                                    .child(KeyBinding::for_action_in(
1355                                                        &FocusWorkspaceSidebar,
1356                                                        &focus_handle_focus,
1357                                                        cx,
1358                                                    )),
1359                                            )
1360                                            .into_any_element()
1361                                    }))
1362                                    .on_click(cx.listener(|_this, _, _window, cx| {
1363                                        cx.emit(SidebarEvent::Close);
1364                                    }))
1365                            })
1366                            .child(Label::new("Threads").size(LabelSize::Small)),
1367                    )
1368                    .child(
1369                        IconButton::new("open-project", IconName::OpenFolder)
1370                            .icon_size(IconSize::Small)
1371                            .tooltip(|_window, cx| {
1372                                Tooltip::for_action(
1373                                    "Open Project",
1374                                    &workspace::Open {
1375                                        create_new_window: false,
1376                                    },
1377                                    cx,
1378                                )
1379                            })
1380                            .on_click(|_event, window, cx| {
1381                                window.dispatch_action(
1382                                    Box::new(workspace::Open {
1383                                        create_new_window: false,
1384                                    }),
1385                                    cx,
1386                                );
1387                            }),
1388                    ),
1389            )
1390            .child(
1391                h_flex()
1392                    .flex_none()
1393                    .p_2()
1394                    .h(Tab::container_height(cx))
1395                    .gap_1p5()
1396                    .border_b_1()
1397                    .border_color(cx.theme().colors().border)
1398                    .child(
1399                        Icon::new(IconName::MagnifyingGlass)
1400                            .size(IconSize::Small)
1401                            .color(Color::Muted),
1402                    )
1403                    .child(self.render_filter_input(cx))
1404                    .when(has_query, |this| {
1405                        this.pr_1().child(
1406                            IconButton::new("clear_filter", IconName::Close)
1407                                .shape(IconButtonShape::Square)
1408                                .tooltip(Tooltip::text("Clear Search"))
1409                                .on_click(cx.listener(|this, _, window, cx| {
1410                                    this.reset_filter_editor_text(window, cx);
1411                                    this.update_entries(cx);
1412                                })),
1413                        )
1414                    }),
1415            )
1416            .child(
1417                v_flex()
1418                    .flex_1()
1419                    .overflow_hidden()
1420                    .child(
1421                        list(
1422                            self.list_state.clone(),
1423                            cx.processor(Self::render_list_entry),
1424                        )
1425                        .flex_1()
1426                        .size_full(),
1427                    )
1428                    .vertical_scrollbar_for(&self.list_state, window, cx),
1429            )
1430    }
1431}
1432
1433#[cfg(test)]
1434mod tests {
1435    use super::*;
1436    use acp_thread::StubAgentConnection;
1437    use agent::ThreadStore;
1438    use agent_ui::test_support::{active_session_id, open_thread_with_connection, send_message};
1439    use assistant_text_thread::TextThreadStore;
1440    use chrono::DateTime;
1441    use feature_flags::FeatureFlagAppExt as _;
1442    use fs::FakeFs;
1443    use gpui::TestAppContext;
1444    use settings::SettingsStore;
1445    use std::sync::Arc;
1446    use util::path_list::PathList;
1447
1448    fn init_test(cx: &mut TestAppContext) {
1449        cx.update(|cx| {
1450            let settings_store = SettingsStore::test(cx);
1451            cx.set_global(settings_store);
1452            theme::init(theme::LoadThemes::JustBase, cx);
1453            editor::init(cx);
1454            cx.update_flags(false, vec!["agent-v2".into()]);
1455            ThreadStore::init_global(cx);
1456        });
1457    }
1458
1459    fn make_test_thread(title: &str, updated_at: DateTime<Utc>) -> agent::DbThread {
1460        agent::DbThread {
1461            title: title.to_string().into(),
1462            messages: Vec::new(),
1463            updated_at,
1464            detailed_summary: None,
1465            initial_project_snapshot: None,
1466            cumulative_token_usage: Default::default(),
1467            request_token_usage: Default::default(),
1468            model: None,
1469            profile: None,
1470            imported: false,
1471            subagent_context: None,
1472            speed: None,
1473            thinking_enabled: false,
1474            thinking_effort: None,
1475            draft_prompt: None,
1476            ui_scroll_position: None,
1477        }
1478    }
1479
1480    async fn init_test_project(
1481        worktree_path: &str,
1482        cx: &mut TestAppContext,
1483    ) -> Entity<project::Project> {
1484        init_test(cx);
1485        let fs = FakeFs::new(cx.executor());
1486        fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
1487            .await;
1488        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
1489        project::Project::test(fs, [worktree_path.as_ref()], cx).await
1490    }
1491
1492    fn setup_sidebar(
1493        multi_workspace: &Entity<MultiWorkspace>,
1494        cx: &mut gpui::VisualTestContext,
1495    ) -> Entity<Sidebar> {
1496        let multi_workspace = multi_workspace.clone();
1497        let sidebar =
1498            cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
1499        multi_workspace.update_in(cx, |mw, window, cx| {
1500            mw.register_sidebar(sidebar.clone(), window, cx);
1501        });
1502        cx.run_until_parked();
1503        sidebar
1504    }
1505
1506    async fn save_n_test_threads(
1507        count: u32,
1508        path_list: &PathList,
1509        cx: &mut gpui::VisualTestContext,
1510    ) {
1511        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
1512        for i in 0..count {
1513            let save_task = thread_store.update(cx, |store, cx| {
1514                store.save_thread(
1515                    acp::SessionId::new(Arc::from(format!("thread-{}", i))),
1516                    make_test_thread(
1517                        &format!("Thread {}", i + 1),
1518                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
1519                    ),
1520                    path_list.clone(),
1521                    cx,
1522                )
1523            });
1524            save_task.await.unwrap();
1525        }
1526        cx.run_until_parked();
1527    }
1528
1529    async fn save_thread_to_store(
1530        session_id: &acp::SessionId,
1531        path_list: &PathList,
1532        cx: &mut gpui::VisualTestContext,
1533    ) {
1534        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
1535        let save_task = thread_store.update(cx, |store, cx| {
1536            store.save_thread(
1537                session_id.clone(),
1538                make_test_thread(
1539                    "Test",
1540                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
1541                ),
1542                path_list.clone(),
1543                cx,
1544            )
1545        });
1546        save_task.await.unwrap();
1547        cx.run_until_parked();
1548    }
1549
1550    fn open_and_focus_sidebar(
1551        sidebar: &Entity<Sidebar>,
1552        multi_workspace: &Entity<MultiWorkspace>,
1553        cx: &mut gpui::VisualTestContext,
1554    ) {
1555        multi_workspace.update_in(cx, |mw, window, cx| {
1556            mw.toggle_sidebar(window, cx);
1557        });
1558        cx.run_until_parked();
1559        sidebar.update_in(cx, |_, window, cx| {
1560            cx.focus_self(window);
1561        });
1562        cx.run_until_parked();
1563    }
1564
1565    fn visible_entries_as_strings(
1566        sidebar: &Entity<Sidebar>,
1567        cx: &mut gpui::VisualTestContext,
1568    ) -> Vec<String> {
1569        sidebar.read_with(cx, |sidebar, _cx| {
1570            sidebar
1571                .contents
1572                .entries
1573                .iter()
1574                .enumerate()
1575                .map(|(ix, entry)| {
1576                    let selected = if sidebar.selection == Some(ix) {
1577                        "  <== selected"
1578                    } else {
1579                        ""
1580                    };
1581                    match entry {
1582                        ListEntry::ProjectHeader {
1583                            label,
1584                            path_list,
1585                            highlight_positions: _,
1586                            ..
1587                        } => {
1588                            let icon = if sidebar.collapsed_groups.contains(path_list) {
1589                                ">"
1590                            } else {
1591                                "v"
1592                            };
1593                            format!("{} [{}]{}", icon, label, selected)
1594                        }
1595                        ListEntry::Thread {
1596                            session_info,
1597                            status,
1598                            is_live,
1599                            ..
1600                        } => {
1601                            let title = session_info
1602                                .title
1603                                .as_ref()
1604                                .map(|s| s.as_ref())
1605                                .unwrap_or("Untitled");
1606                            let active = if *is_live { " *" } else { "" };
1607                            let status_str = match status {
1608                                AgentThreadStatus::Running => " (running)",
1609                                AgentThreadStatus::Error => " (error)",
1610                                AgentThreadStatus::WaitingForConfirmation => " (waiting)",
1611                                _ => "",
1612                            };
1613                            let notified = if sidebar
1614                                .contents
1615                                .is_thread_notified(&session_info.session_id)
1616                            {
1617                                " (!)"
1618                            } else {
1619                                ""
1620                            };
1621                            format!(
1622                                "  {}{}{}{}{}",
1623                                title, active, status_str, notified, selected
1624                            )
1625                        }
1626                        ListEntry::ViewMore {
1627                            remaining_count, ..
1628                        } => {
1629                            format!("  + View More ({}){}", remaining_count, selected)
1630                        }
1631                        ListEntry::NewThread { .. } => {
1632                            format!("  [+ New Thread]{}", selected)
1633                        }
1634                    }
1635                })
1636                .collect()
1637        })
1638    }
1639
1640    #[gpui::test]
1641    async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
1642        let project = init_test_project("/my-project", cx).await;
1643        let (multi_workspace, cx) =
1644            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1645        let sidebar = setup_sidebar(&multi_workspace, cx);
1646
1647        assert_eq!(
1648            visible_entries_as_strings(&sidebar, cx),
1649            vec!["v [my-project]", "  [+ New Thread]"]
1650        );
1651    }
1652
1653    #[gpui::test]
1654    async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
1655        let project = init_test_project("/my-project", cx).await;
1656        let (multi_workspace, cx) =
1657            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1658        let sidebar = setup_sidebar(&multi_workspace, cx);
1659
1660        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
1661        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
1662
1663        let save_task = thread_store.update(cx, |store, cx| {
1664            store.save_thread(
1665                acp::SessionId::new(Arc::from("thread-1")),
1666                make_test_thread(
1667                    "Fix crash in project panel",
1668                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
1669                ),
1670                path_list.clone(),
1671                cx,
1672            )
1673        });
1674        save_task.await.unwrap();
1675
1676        let save_task = thread_store.update(cx, |store, cx| {
1677            store.save_thread(
1678                acp::SessionId::new(Arc::from("thread-2")),
1679                make_test_thread(
1680                    "Add inline diff view",
1681                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
1682                ),
1683                path_list.clone(),
1684                cx,
1685            )
1686        });
1687        save_task.await.unwrap();
1688        cx.run_until_parked();
1689
1690        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1691        cx.run_until_parked();
1692
1693        assert_eq!(
1694            visible_entries_as_strings(&sidebar, cx),
1695            vec![
1696                "v [my-project]",
1697                "  Fix crash in project panel",
1698                "  Add inline diff view",
1699            ]
1700        );
1701    }
1702
1703    #[gpui::test]
1704    async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
1705        let project = init_test_project("/project-a", cx).await;
1706        let (multi_workspace, cx) =
1707            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1708        let sidebar = setup_sidebar(&multi_workspace, cx);
1709
1710        // Single workspace with a thread
1711        let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]);
1712        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
1713
1714        let save_task = thread_store.update(cx, |store, cx| {
1715            store.save_thread(
1716                acp::SessionId::new(Arc::from("thread-a1")),
1717                make_test_thread(
1718                    "Thread A1",
1719                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
1720                ),
1721                path_list.clone(),
1722                cx,
1723            )
1724        });
1725        save_task.await.unwrap();
1726        cx.run_until_parked();
1727
1728        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1729        cx.run_until_parked();
1730
1731        assert_eq!(
1732            visible_entries_as_strings(&sidebar, cx),
1733            vec!["v [project-a]", "  Thread A1"]
1734        );
1735
1736        // Add a second workspace
1737        multi_workspace.update_in(cx, |mw, window, cx| {
1738            mw.create_workspace(window, cx);
1739        });
1740        cx.run_until_parked();
1741
1742        assert_eq!(
1743            visible_entries_as_strings(&sidebar, cx),
1744            vec![
1745                "v [project-a]",
1746                "  Thread A1",
1747                "v [Empty Workspace]",
1748                "  [+ New Thread]"
1749            ]
1750        );
1751
1752        // Remove the second workspace
1753        multi_workspace.update_in(cx, |mw, window, cx| {
1754            mw.remove_workspace(1, window, cx);
1755        });
1756        cx.run_until_parked();
1757
1758        assert_eq!(
1759            visible_entries_as_strings(&sidebar, cx),
1760            vec!["v [project-a]", "  Thread A1"]
1761        );
1762    }
1763
1764    #[gpui::test]
1765    async fn test_view_more_pagination(cx: &mut TestAppContext) {
1766        let project = init_test_project("/my-project", cx).await;
1767        let (multi_workspace, cx) =
1768            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1769        let sidebar = setup_sidebar(&multi_workspace, cx);
1770
1771        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
1772        save_n_test_threads(12, &path_list, cx).await;
1773
1774        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1775        cx.run_until_parked();
1776
1777        assert_eq!(
1778            visible_entries_as_strings(&sidebar, cx),
1779            vec![
1780                "v [my-project]",
1781                "  Thread 12",
1782                "  Thread 11",
1783                "  Thread 10",
1784                "  Thread 9",
1785                "  Thread 8",
1786                "  + View More (7)",
1787            ]
1788        );
1789    }
1790
1791    #[gpui::test]
1792    async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
1793        let project = init_test_project("/my-project", cx).await;
1794        let (multi_workspace, cx) =
1795            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1796        let sidebar = setup_sidebar(&multi_workspace, cx);
1797
1798        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
1799        save_n_test_threads(1, &path_list, cx).await;
1800
1801        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1802        cx.run_until_parked();
1803
1804        assert_eq!(
1805            visible_entries_as_strings(&sidebar, cx),
1806            vec!["v [my-project]", "  Thread 1"]
1807        );
1808
1809        // Collapse
1810        sidebar.update_in(cx, |s, window, cx| {
1811            s.toggle_collapse(&path_list, window, cx);
1812        });
1813        cx.run_until_parked();
1814
1815        assert_eq!(
1816            visible_entries_as_strings(&sidebar, cx),
1817            vec!["> [my-project]"]
1818        );
1819
1820        // Expand
1821        sidebar.update_in(cx, |s, window, cx| {
1822            s.toggle_collapse(&path_list, window, cx);
1823        });
1824        cx.run_until_parked();
1825
1826        assert_eq!(
1827            visible_entries_as_strings(&sidebar, cx),
1828            vec!["v [my-project]", "  Thread 1"]
1829        );
1830    }
1831
1832    #[gpui::test]
1833    async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
1834        let project = init_test_project("/my-project", cx).await;
1835        let (multi_workspace, cx) =
1836            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1837        let sidebar = setup_sidebar(&multi_workspace, cx);
1838
1839        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1840        let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]);
1841        let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]);
1842
1843        sidebar.update_in(cx, |s, _window, _cx| {
1844            s.collapsed_groups.insert(collapsed_path.clone());
1845            s.contents
1846                .notified_threads
1847                .insert(acp::SessionId::new(Arc::from("t-5")));
1848            s.contents.entries = vec![
1849                // Expanded project header
1850                ListEntry::ProjectHeader {
1851                    path_list: expanded_path.clone(),
1852                    label: "expanded-project".into(),
1853                    workspace: workspace.clone(),
1854                    highlight_positions: Vec::new(),
1855                },
1856                // Thread with default (Completed) status, not active
1857                ListEntry::Thread {
1858                    session_info: acp_thread::AgentSessionInfo {
1859                        session_id: acp::SessionId::new(Arc::from("t-1")),
1860                        cwd: None,
1861                        title: Some("Completed thread".into()),
1862                        updated_at: Some(Utc::now()),
1863                        meta: None,
1864                    },
1865                    icon: IconName::ZedAgent,
1866                    icon_from_external_svg: None,
1867                    status: AgentThreadStatus::Completed,
1868                    diff_stats: None,
1869                    workspace: workspace.clone(),
1870                    is_live: false,
1871                    is_background: false,
1872                    highlight_positions: Vec::new(),
1873                },
1874                // Active thread with Running status
1875                ListEntry::Thread {
1876                    session_info: acp_thread::AgentSessionInfo {
1877                        session_id: acp::SessionId::new(Arc::from("t-2")),
1878                        cwd: None,
1879                        title: Some("Running thread".into()),
1880                        updated_at: Some(Utc::now()),
1881                        meta: None,
1882                    },
1883                    icon: IconName::ZedAgent,
1884                    icon_from_external_svg: None,
1885                    status: AgentThreadStatus::Running,
1886                    diff_stats: None,
1887                    workspace: workspace.clone(),
1888                    is_live: true,
1889                    is_background: false,
1890                    highlight_positions: Vec::new(),
1891                },
1892                // Active thread with Error status
1893                ListEntry::Thread {
1894                    session_info: acp_thread::AgentSessionInfo {
1895                        session_id: acp::SessionId::new(Arc::from("t-3")),
1896                        cwd: None,
1897                        title: Some("Error thread".into()),
1898                        updated_at: Some(Utc::now()),
1899                        meta: None,
1900                    },
1901                    icon: IconName::ZedAgent,
1902                    icon_from_external_svg: None,
1903                    status: AgentThreadStatus::Error,
1904                    diff_stats: None,
1905                    workspace: workspace.clone(),
1906                    is_live: true,
1907                    is_background: false,
1908                    highlight_positions: Vec::new(),
1909                },
1910                // Thread with WaitingForConfirmation status, not active
1911                ListEntry::Thread {
1912                    session_info: acp_thread::AgentSessionInfo {
1913                        session_id: acp::SessionId::new(Arc::from("t-4")),
1914                        cwd: None,
1915                        title: Some("Waiting thread".into()),
1916                        updated_at: Some(Utc::now()),
1917                        meta: None,
1918                    },
1919                    icon: IconName::ZedAgent,
1920                    icon_from_external_svg: None,
1921                    status: AgentThreadStatus::WaitingForConfirmation,
1922                    diff_stats: None,
1923                    workspace: workspace.clone(),
1924                    is_live: false,
1925                    is_background: false,
1926                    highlight_positions: Vec::new(),
1927                },
1928                // Background thread that completed (should show notification)
1929                ListEntry::Thread {
1930                    session_info: acp_thread::AgentSessionInfo {
1931                        session_id: acp::SessionId::new(Arc::from("t-5")),
1932                        cwd: None,
1933                        title: Some("Notified thread".into()),
1934                        updated_at: Some(Utc::now()),
1935                        meta: None,
1936                    },
1937                    icon: IconName::ZedAgent,
1938                    icon_from_external_svg: None,
1939                    status: AgentThreadStatus::Completed,
1940                    diff_stats: None,
1941                    workspace: workspace.clone(),
1942                    is_live: true,
1943                    is_background: true,
1944                    highlight_positions: Vec::new(),
1945                },
1946                // View More entry
1947                ListEntry::ViewMore {
1948                    path_list: expanded_path.clone(),
1949                    remaining_count: 42,
1950                },
1951                // Collapsed project header
1952                ListEntry::ProjectHeader {
1953                    path_list: collapsed_path.clone(),
1954                    label: "collapsed-project".into(),
1955                    workspace: workspace.clone(),
1956                    highlight_positions: Vec::new(),
1957                },
1958            ];
1959            // Select the Running thread (index 2)
1960            s.selection = Some(2);
1961        });
1962
1963        assert_eq!(
1964            visible_entries_as_strings(&sidebar, cx),
1965            vec![
1966                "v [expanded-project]",
1967                "  Completed thread",
1968                "  Running thread * (running)  <== selected",
1969                "  Error thread * (error)",
1970                "  Waiting thread (waiting)",
1971                "  Notified thread * (!)",
1972                "  + View More (42)",
1973                "> [collapsed-project]",
1974            ]
1975        );
1976
1977        // Move selection to the collapsed header
1978        sidebar.update_in(cx, |s, _window, _cx| {
1979            s.selection = Some(7);
1980        });
1981
1982        assert_eq!(
1983            visible_entries_as_strings(&sidebar, cx).last().cloned(),
1984            Some("> [collapsed-project]  <== selected".to_string()),
1985        );
1986
1987        // Clear selection
1988        sidebar.update_in(cx, |s, _window, _cx| {
1989            s.selection = None;
1990        });
1991
1992        // No entry should have the selected marker
1993        let entries = visible_entries_as_strings(&sidebar, cx);
1994        for entry in &entries {
1995            assert!(
1996                !entry.contains("<== selected"),
1997                "unexpected selection marker in: {}",
1998                entry
1999            );
2000        }
2001    }
2002
2003    #[gpui::test]
2004    async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
2005        let project = init_test_project("/my-project", cx).await;
2006        let (multi_workspace, cx) =
2007            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2008        let sidebar = setup_sidebar(&multi_workspace, cx);
2009
2010        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2011        save_n_test_threads(3, &path_list, cx).await;
2012
2013        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2014        cx.run_until_parked();
2015
2016        // Entries: [header, thread3, thread2, thread1]
2017        // Focusing the sidebar triggers focus_in, which selects the first entry
2018        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
2019        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2020
2021        // Move down through all entries
2022        cx.dispatch_action(SelectNext);
2023        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
2024
2025        cx.dispatch_action(SelectNext);
2026        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
2027
2028        cx.dispatch_action(SelectNext);
2029        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
2030
2031        // At the end, selection stays on the last entry
2032        cx.dispatch_action(SelectNext);
2033        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
2034
2035        // Move back up
2036
2037        cx.dispatch_action(SelectPrevious);
2038        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
2039
2040        cx.dispatch_action(SelectPrevious);
2041        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
2042
2043        cx.dispatch_action(SelectPrevious);
2044        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2045
2046        // At the top, selection stays on the first entry
2047        cx.dispatch_action(SelectPrevious);
2048        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2049    }
2050
2051    #[gpui::test]
2052    async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
2053        let project = init_test_project("/my-project", cx).await;
2054        let (multi_workspace, cx) =
2055            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2056        let sidebar = setup_sidebar(&multi_workspace, cx);
2057
2058        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2059        save_n_test_threads(3, &path_list, cx).await;
2060        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2061        cx.run_until_parked();
2062
2063        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
2064
2065        // SelectLast jumps to the end
2066        cx.dispatch_action(SelectLast);
2067        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
2068
2069        // SelectFirst jumps to the beginning
2070        cx.dispatch_action(SelectFirst);
2071        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2072    }
2073
2074    #[gpui::test]
2075    async fn test_keyboard_focus_in_selects_first(cx: &mut TestAppContext) {
2076        let project = init_test_project("/my-project", cx).await;
2077        let (multi_workspace, cx) =
2078            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2079        let sidebar = setup_sidebar(&multi_workspace, cx);
2080
2081        // Initially no selection
2082        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
2083
2084        // Open the sidebar so it's rendered, then focus it to trigger focus_in
2085        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
2086        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2087
2088        // Blur the sidebar, then refocus — existing selection should be preserved
2089        cx.update(|window, _cx| {
2090            window.blur();
2091        });
2092        cx.run_until_parked();
2093
2094        sidebar.update_in(cx, |_, window, cx| {
2095            cx.focus_self(window);
2096        });
2097        cx.run_until_parked();
2098        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2099    }
2100
2101    #[gpui::test]
2102    async fn test_keyboard_confirm_on_project_header_activates_workspace(cx: &mut TestAppContext) {
2103        let project = init_test_project("/my-project", cx).await;
2104        let (multi_workspace, cx) =
2105            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2106        let sidebar = setup_sidebar(&multi_workspace, cx);
2107
2108        multi_workspace.update_in(cx, |mw, window, cx| {
2109            mw.create_workspace(window, cx);
2110        });
2111        cx.run_until_parked();
2112
2113        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2114        save_n_test_threads(1, &path_list, cx).await;
2115        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2116        cx.run_until_parked();
2117
2118        assert_eq!(
2119            visible_entries_as_strings(&sidebar, cx),
2120            vec![
2121                "v [my-project]",
2122                "  Thread 1",
2123                "v [Empty Workspace]",
2124                "  [+ New Thread]",
2125            ]
2126        );
2127
2128        // Switch to workspace 1 so we can verify confirm switches back.
2129        multi_workspace.update_in(cx, |mw, window, cx| {
2130            mw.activate_index(1, window, cx);
2131        });
2132        cx.run_until_parked();
2133        assert_eq!(
2134            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
2135            1
2136        );
2137
2138        // Focus the sidebar — focus_in selects the header (index 0)
2139        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
2140        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2141
2142        // Press confirm on project header (workspace 0) to activate it.
2143        cx.dispatch_action(Confirm);
2144        cx.run_until_parked();
2145
2146        assert_eq!(
2147            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
2148            0
2149        );
2150
2151        // Focus should have moved out of the sidebar to the workspace center.
2152        let workspace_0 = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone());
2153        workspace_0.update_in(cx, |workspace, window, cx| {
2154            let pane_focus = workspace.active_pane().read(cx).focus_handle(cx);
2155            assert!(
2156                pane_focus.contains_focused(window, cx),
2157                "Confirming a project header should focus the workspace center pane"
2158            );
2159        });
2160    }
2161
2162    #[gpui::test]
2163    async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) {
2164        let project = init_test_project("/my-project", cx).await;
2165        let (multi_workspace, cx) =
2166            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2167        let sidebar = setup_sidebar(&multi_workspace, cx);
2168
2169        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2170        save_n_test_threads(8, &path_list, cx).await;
2171        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2172        cx.run_until_parked();
2173
2174        // Should show header + 5 threads + "View More (3)"
2175        let entries = visible_entries_as_strings(&sidebar, cx);
2176        assert_eq!(entries.len(), 7);
2177        assert!(entries.iter().any(|e| e.contains("View More (3)")));
2178
2179        // Focus sidebar (selects index 0), then navigate down to the "View More" entry (index 6)
2180        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
2181        for _ in 0..6 {
2182            cx.dispatch_action(SelectNext);
2183        }
2184        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6));
2185
2186        // Confirm on "View More" to expand
2187        cx.dispatch_action(Confirm);
2188        cx.run_until_parked();
2189
2190        // All 8 threads should now be visible, no "View More"
2191        let entries = visible_entries_as_strings(&sidebar, cx);
2192        assert_eq!(entries.len(), 9); // header + 8 threads
2193        assert!(!entries.iter().any(|e| e.contains("View More")));
2194    }
2195
2196    #[gpui::test]
2197    async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
2198        let project = init_test_project("/my-project", cx).await;
2199        let (multi_workspace, cx) =
2200            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2201        let sidebar = setup_sidebar(&multi_workspace, cx);
2202
2203        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2204        save_n_test_threads(1, &path_list, cx).await;
2205        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2206        cx.run_until_parked();
2207
2208        assert_eq!(
2209            visible_entries_as_strings(&sidebar, cx),
2210            vec!["v [my-project]", "  Thread 1"]
2211        );
2212
2213        // Focus sidebar — focus_in selects the header (index 0). Press left to collapse.
2214        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
2215        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2216
2217        cx.dispatch_action(CollapseSelectedEntry);
2218        cx.run_until_parked();
2219
2220        assert_eq!(
2221            visible_entries_as_strings(&sidebar, cx),
2222            vec!["> [my-project]  <== selected"]
2223        );
2224
2225        // Press right to expand
2226        cx.dispatch_action(ExpandSelectedEntry);
2227        cx.run_until_parked();
2228
2229        assert_eq!(
2230            visible_entries_as_strings(&sidebar, cx),
2231            vec!["v [my-project]  <== selected", "  Thread 1",]
2232        );
2233
2234        // Press right again on already-expanded header moves selection down
2235        cx.dispatch_action(ExpandSelectedEntry);
2236        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
2237    }
2238
2239    #[gpui::test]
2240    async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) {
2241        let project = init_test_project("/my-project", cx).await;
2242        let (multi_workspace, cx) =
2243            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2244        let sidebar = setup_sidebar(&multi_workspace, cx);
2245
2246        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2247        save_n_test_threads(1, &path_list, cx).await;
2248        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2249        cx.run_until_parked();
2250
2251        // Focus sidebar (selects header at index 0), then navigate down to the thread (child)
2252        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
2253        cx.dispatch_action(SelectNext);
2254        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
2255
2256        assert_eq!(
2257            visible_entries_as_strings(&sidebar, cx),
2258            vec!["v [my-project]", "  Thread 1  <== selected",]
2259        );
2260
2261        // Pressing left on a child collapses the parent group and selects it
2262        cx.dispatch_action(CollapseSelectedEntry);
2263        cx.run_until_parked();
2264
2265        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2266        assert_eq!(
2267            visible_entries_as_strings(&sidebar, cx),
2268            vec!["> [my-project]  <== selected"]
2269        );
2270    }
2271
2272    #[gpui::test]
2273    async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
2274        let project = init_test_project("/empty-project", cx).await;
2275        let (multi_workspace, cx) =
2276            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2277        let sidebar = setup_sidebar(&multi_workspace, cx);
2278
2279        // Even an empty project has the header and a new thread button
2280        assert_eq!(
2281            visible_entries_as_strings(&sidebar, cx),
2282            vec!["v [empty-project]", "  [+ New Thread]"]
2283        );
2284
2285        // Focus sidebar — focus_in selects the first entry (header at 0)
2286        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
2287        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2288
2289        // SelectNext moves to the new thread button
2290        cx.dispatch_action(SelectNext);
2291        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
2292
2293        // At the end, selection stays on the last entry
2294        cx.dispatch_action(SelectNext);
2295        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
2296
2297        // SelectPrevious goes back to the header
2298        cx.dispatch_action(SelectPrevious);
2299        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2300    }
2301
2302    #[gpui::test]
2303    async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
2304        let project = init_test_project("/my-project", cx).await;
2305        let (multi_workspace, cx) =
2306            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2307        let sidebar = setup_sidebar(&multi_workspace, cx);
2308
2309        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2310        save_n_test_threads(1, &path_list, cx).await;
2311        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
2312        cx.run_until_parked();
2313
2314        // Focus sidebar (selects header at 0), navigate down to the thread (index 1)
2315        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
2316        cx.dispatch_action(SelectNext);
2317        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
2318
2319        // Collapse the group, which removes the thread from the list
2320        cx.dispatch_action(CollapseSelectedEntry);
2321        cx.run_until_parked();
2322
2323        // Selection should be clamped to the last valid index (0 = header)
2324        let selection = sidebar.read_with(cx, |s, _| s.selection);
2325        let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len());
2326        assert!(
2327            selection.unwrap_or(0) < entry_count,
2328            "selection {} should be within bounds (entries: {})",
2329            selection.unwrap_or(0),
2330            entry_count,
2331        );
2332    }
2333
2334    async fn init_test_project_with_agent_panel(
2335        worktree_path: &str,
2336        cx: &mut TestAppContext,
2337    ) -> Entity<project::Project> {
2338        agent_ui::test_support::init_test(cx);
2339        cx.update(|cx| {
2340            cx.update_flags(false, vec!["agent-v2".into()]);
2341            ThreadStore::init_global(cx);
2342            language_model::LanguageModelRegistry::test(cx);
2343        });
2344
2345        let fs = FakeFs::new(cx.executor());
2346        fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
2347            .await;
2348        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
2349        project::Project::test(fs, [worktree_path.as_ref()], cx).await
2350    }
2351
2352    fn add_agent_panel(
2353        workspace: &Entity<Workspace>,
2354        project: &Entity<project::Project>,
2355        cx: &mut gpui::VisualTestContext,
2356    ) -> Entity<AgentPanel> {
2357        workspace.update_in(cx, |workspace, window, cx| {
2358            let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2359            let panel = cx.new(|cx| AgentPanel::test_new(workspace, text_thread_store, window, cx));
2360            workspace.add_panel(panel.clone(), window, cx);
2361            panel
2362        })
2363    }
2364
2365    fn setup_sidebar_with_agent_panel(
2366        multi_workspace: &Entity<MultiWorkspace>,
2367        project: &Entity<project::Project>,
2368        cx: &mut gpui::VisualTestContext,
2369    ) -> (Entity<Sidebar>, Entity<AgentPanel>) {
2370        let sidebar = setup_sidebar(multi_workspace, cx);
2371        let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
2372        let panel = add_agent_panel(&workspace, project, cx);
2373        (sidebar, panel)
2374    }
2375
2376    #[gpui::test]
2377    async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
2378        let project = init_test_project_with_agent_panel("/my-project", cx).await;
2379        let (multi_workspace, cx) =
2380            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2381        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
2382
2383        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2384
2385        // Open thread A and keep it generating.
2386        let connection_a = StubAgentConnection::new();
2387        open_thread_with_connection(&panel, connection_a.clone(), cx);
2388        send_message(&panel, cx);
2389
2390        let session_id_a = active_session_id(&panel, cx);
2391        save_thread_to_store(&session_id_a, &path_list, cx).await;
2392
2393        cx.update(|_, cx| {
2394            connection_a.send_update(
2395                session_id_a.clone(),
2396                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
2397                cx,
2398            );
2399        });
2400        cx.run_until_parked();
2401
2402        // Open thread B (idle, default response) — thread A goes to background.
2403        let connection_b = StubAgentConnection::new();
2404        connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
2405            acp::ContentChunk::new("Done".into()),
2406        )]);
2407        open_thread_with_connection(&panel, connection_b, cx);
2408        send_message(&panel, cx);
2409
2410        let session_id_b = active_session_id(&panel, cx);
2411        save_thread_to_store(&session_id_b, &path_list, cx).await;
2412
2413        cx.run_until_parked();
2414
2415        let mut entries = visible_entries_as_strings(&sidebar, cx);
2416        entries[1..].sort();
2417        assert_eq!(
2418            entries,
2419            vec!["v [my-project]", "  Hello *", "  Hello * (running)",]
2420        );
2421    }
2422
2423    #[gpui::test]
2424    async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
2425        let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
2426        let (multi_workspace, cx) = cx
2427            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
2428        let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx);
2429
2430        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
2431
2432        // Open thread on workspace A and keep it generating.
2433        let connection_a = StubAgentConnection::new();
2434        open_thread_with_connection(&panel_a, connection_a.clone(), cx);
2435        send_message(&panel_a, cx);
2436
2437        let session_id_a = active_session_id(&panel_a, cx);
2438        save_thread_to_store(&session_id_a, &path_list_a, cx).await;
2439
2440        cx.update(|_, cx| {
2441            connection_a.send_update(
2442                session_id_a.clone(),
2443                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
2444                cx,
2445            );
2446        });
2447        cx.run_until_parked();
2448
2449        // Add a second workspace and activate it (making workspace A the background).
2450        let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
2451        let project_b = project::Project::test(fs, [], cx).await;
2452        multi_workspace.update_in(cx, |mw, window, cx| {
2453            mw.test_add_workspace(project_b, window, cx);
2454        });
2455        cx.run_until_parked();
2456
2457        // Thread A is still running; no notification yet.
2458        assert_eq!(
2459            visible_entries_as_strings(&sidebar, cx),
2460            vec![
2461                "v [project-a]",
2462                "  Hello * (running)",
2463                "v [Empty Workspace]",
2464                "  [+ New Thread]",
2465            ]
2466        );
2467
2468        // Complete thread A's turn (transition Running → Completed).
2469        connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn);
2470        cx.run_until_parked();
2471
2472        // The completed background thread shows a notification indicator.
2473        assert_eq!(
2474            visible_entries_as_strings(&sidebar, cx),
2475            vec![
2476                "v [project-a]",
2477                "  Hello * (!)",
2478                "v [Empty Workspace]",
2479                "  [+ New Thread]",
2480            ]
2481        );
2482    }
2483
2484    fn type_in_search(sidebar: &Entity<Sidebar>, query: &str, cx: &mut gpui::VisualTestContext) {
2485        sidebar.update_in(cx, |sidebar, window, cx| {
2486            window.focus(&sidebar.filter_editor.focus_handle(cx), cx);
2487            sidebar.filter_editor.update(cx, |editor, cx| {
2488                editor.set_text(query, window, cx);
2489            });
2490        });
2491        cx.run_until_parked();
2492    }
2493
2494    #[gpui::test]
2495    async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) {
2496        let project = init_test_project("/my-project", cx).await;
2497        let (multi_workspace, cx) =
2498            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2499        let sidebar = setup_sidebar(&multi_workspace, cx);
2500
2501        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2502        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2503
2504        for (id, title, hour) in [
2505            ("t-1", "Fix crash in project panel", 3),
2506            ("t-2", "Add inline diff view", 2),
2507            ("t-3", "Refactor settings module", 1),
2508        ] {
2509            let save_task = thread_store.update(cx, |store, cx| {
2510                store.save_thread(
2511                    acp::SessionId::new(Arc::from(id)),
2512                    make_test_thread(
2513                        title,
2514                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
2515                    ),
2516                    path_list.clone(),
2517                    cx,
2518                )
2519            });
2520            save_task.await.unwrap();
2521        }
2522        cx.run_until_parked();
2523
2524        assert_eq!(
2525            visible_entries_as_strings(&sidebar, cx),
2526            vec![
2527                "v [my-project]",
2528                "  Fix crash in project panel",
2529                "  Add inline diff view",
2530                "  Refactor settings module",
2531            ]
2532        );
2533
2534        // User types "diff" in the search box — only the matching thread remains,
2535        // with its workspace header preserved for context.
2536        type_in_search(&sidebar, "diff", cx);
2537        assert_eq!(
2538            visible_entries_as_strings(&sidebar, cx),
2539            vec!["v [my-project]", "  Add inline diff view  <== selected",]
2540        );
2541
2542        // User changes query to something with no matches — list is empty.
2543        type_in_search(&sidebar, "nonexistent", cx);
2544        assert_eq!(
2545            visible_entries_as_strings(&sidebar, cx),
2546            Vec::<String>::new()
2547        );
2548    }
2549
2550    #[gpui::test]
2551    async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
2552        // Scenario: A user remembers a thread title but not the exact casing.
2553        // Search should match case-insensitively so they can still find it.
2554        let project = init_test_project("/my-project", cx).await;
2555        let (multi_workspace, cx) =
2556            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2557        let sidebar = setup_sidebar(&multi_workspace, cx);
2558
2559        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2560        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2561
2562        let save_task = thread_store.update(cx, |store, cx| {
2563            store.save_thread(
2564                acp::SessionId::new(Arc::from("thread-1")),
2565                make_test_thread(
2566                    "Fix Crash In Project Panel",
2567                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
2568                ),
2569                path_list.clone(),
2570                cx,
2571            )
2572        });
2573        save_task.await.unwrap();
2574        cx.run_until_parked();
2575
2576        // Lowercase query matches mixed-case title.
2577        type_in_search(&sidebar, "fix crash", cx);
2578        assert_eq!(
2579            visible_entries_as_strings(&sidebar, cx),
2580            vec![
2581                "v [my-project]",
2582                "  Fix Crash In Project Panel  <== selected",
2583            ]
2584        );
2585
2586        // Uppercase query also matches the same title.
2587        type_in_search(&sidebar, "FIX CRASH", cx);
2588        assert_eq!(
2589            visible_entries_as_strings(&sidebar, cx),
2590            vec![
2591                "v [my-project]",
2592                "  Fix Crash In Project Panel  <== selected",
2593            ]
2594        );
2595    }
2596
2597    #[gpui::test]
2598    async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) {
2599        // Scenario: A user searches, finds what they need, then presses Escape
2600        // to dismiss the filter and see the full list again.
2601        let project = init_test_project("/my-project", cx).await;
2602        let (multi_workspace, cx) =
2603            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2604        let sidebar = setup_sidebar(&multi_workspace, cx);
2605
2606        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2607        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2608
2609        for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] {
2610            let save_task = thread_store.update(cx, |store, cx| {
2611                store.save_thread(
2612                    acp::SessionId::new(Arc::from(id)),
2613                    make_test_thread(
2614                        title,
2615                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
2616                    ),
2617                    path_list.clone(),
2618                    cx,
2619                )
2620            });
2621            save_task.await.unwrap();
2622        }
2623        cx.run_until_parked();
2624
2625        // Confirm the full list is showing.
2626        assert_eq!(
2627            visible_entries_as_strings(&sidebar, cx),
2628            vec!["v [my-project]", "  Alpha thread", "  Beta thread",]
2629        );
2630
2631        // User types a search query to filter down.
2632        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
2633        type_in_search(&sidebar, "alpha", cx);
2634        assert_eq!(
2635            visible_entries_as_strings(&sidebar, cx),
2636            vec!["v [my-project]", "  Alpha thread  <== selected",]
2637        );
2638
2639        // User presses Escape — filter clears, full list is restored.
2640        cx.dispatch_action(Cancel);
2641        cx.run_until_parked();
2642        assert_eq!(
2643            visible_entries_as_strings(&sidebar, cx),
2644            vec![
2645                "v [my-project]",
2646                "  Alpha thread  <== selected",
2647                "  Beta thread",
2648            ]
2649        );
2650    }
2651
2652    #[gpui::test]
2653    async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
2654        let project_a = init_test_project("/project-a", cx).await;
2655        let (multi_workspace, cx) =
2656            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
2657        let sidebar = setup_sidebar(&multi_workspace, cx);
2658
2659        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
2660        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2661
2662        for (id, title, hour) in [
2663            ("a1", "Fix bug in sidebar", 2),
2664            ("a2", "Add tests for editor", 1),
2665        ] {
2666            let save_task = thread_store.update(cx, |store, cx| {
2667                store.save_thread(
2668                    acp::SessionId::new(Arc::from(id)),
2669                    make_test_thread(
2670                        title,
2671                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
2672                    ),
2673                    path_list_a.clone(),
2674                    cx,
2675                )
2676            });
2677            save_task.await.unwrap();
2678        }
2679
2680        // Add a second workspace.
2681        multi_workspace.update_in(cx, |mw, window, cx| {
2682            mw.create_workspace(window, cx);
2683        });
2684        cx.run_until_parked();
2685
2686        let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
2687
2688        for (id, title, hour) in [
2689            ("b1", "Refactor sidebar layout", 3),
2690            ("b2", "Fix typo in README", 1),
2691        ] {
2692            let save_task = thread_store.update(cx, |store, cx| {
2693                store.save_thread(
2694                    acp::SessionId::new(Arc::from(id)),
2695                    make_test_thread(
2696                        title,
2697                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
2698                    ),
2699                    path_list_b.clone(),
2700                    cx,
2701                )
2702            });
2703            save_task.await.unwrap();
2704        }
2705        cx.run_until_parked();
2706
2707        assert_eq!(
2708            visible_entries_as_strings(&sidebar, cx),
2709            vec![
2710                "v [project-a]",
2711                "  Fix bug in sidebar",
2712                "  Add tests for editor",
2713                "v [Empty Workspace]",
2714                "  Refactor sidebar layout",
2715                "  Fix typo in README",
2716            ]
2717        );
2718
2719        // "sidebar" matches a thread in each workspace — both headers stay visible.
2720        type_in_search(&sidebar, "sidebar", cx);
2721        assert_eq!(
2722            visible_entries_as_strings(&sidebar, cx),
2723            vec![
2724                "v [project-a]",
2725                "  Fix bug in sidebar  <== selected",
2726                "v [Empty Workspace]",
2727                "  Refactor sidebar layout",
2728            ]
2729        );
2730
2731        // "typo" only matches in the second workspace — the first header disappears.
2732        type_in_search(&sidebar, "typo", cx);
2733        assert_eq!(
2734            visible_entries_as_strings(&sidebar, cx),
2735            vec!["v [Empty Workspace]", "  Fix typo in README  <== selected",]
2736        );
2737
2738        // "project-a" matches the first workspace name — the header appears alone
2739        // without any child threads (none of them match "project-a").
2740        type_in_search(&sidebar, "project-a", cx);
2741        assert_eq!(
2742            visible_entries_as_strings(&sidebar, cx),
2743            vec!["v [project-a]  <== selected"]
2744        );
2745    }
2746
2747    #[gpui::test]
2748    async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
2749        let project_a = init_test_project("/alpha-project", cx).await;
2750        let (multi_workspace, cx) =
2751            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
2752        let sidebar = setup_sidebar(&multi_workspace, cx);
2753
2754        let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]);
2755        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2756
2757        for (id, title, hour) in [
2758            ("a1", "Fix bug in sidebar", 2),
2759            ("a2", "Add tests for editor", 1),
2760        ] {
2761            let save_task = thread_store.update(cx, |store, cx| {
2762                store.save_thread(
2763                    acp::SessionId::new(Arc::from(id)),
2764                    make_test_thread(
2765                        title,
2766                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
2767                    ),
2768                    path_list_a.clone(),
2769                    cx,
2770                )
2771            });
2772            save_task.await.unwrap();
2773        }
2774
2775        // Add a second workspace.
2776        multi_workspace.update_in(cx, |mw, window, cx| {
2777            mw.create_workspace(window, cx);
2778        });
2779        cx.run_until_parked();
2780
2781        let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
2782
2783        for (id, title, hour) in [
2784            ("b1", "Refactor sidebar layout", 3),
2785            ("b2", "Fix typo in README", 1),
2786        ] {
2787            let save_task = thread_store.update(cx, |store, cx| {
2788                store.save_thread(
2789                    acp::SessionId::new(Arc::from(id)),
2790                    make_test_thread(
2791                        title,
2792                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
2793                    ),
2794                    path_list_b.clone(),
2795                    cx,
2796                )
2797            });
2798            save_task.await.unwrap();
2799        }
2800        cx.run_until_parked();
2801
2802        // "alpha" matches the workspace name "alpha-project" but no thread titles.
2803        // The workspace header should appear with no child threads.
2804        type_in_search(&sidebar, "alpha", cx);
2805        assert_eq!(
2806            visible_entries_as_strings(&sidebar, cx),
2807            vec!["v [alpha-project]  <== selected"]
2808        );
2809
2810        // "sidebar" matches thread titles in both workspaces but not workspace names.
2811        // Both headers appear with their matching threads.
2812        type_in_search(&sidebar, "sidebar", cx);
2813        assert_eq!(
2814            visible_entries_as_strings(&sidebar, cx),
2815            vec![
2816                "v [alpha-project]",
2817                "  Fix bug in sidebar  <== selected",
2818                "v [Empty Workspace]",
2819                "  Refactor sidebar layout",
2820            ]
2821        );
2822
2823        // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r
2824        // doesn't match) — but does not match either workspace name or any thread.
2825        // Actually let's test something simpler: a query that matches both a workspace
2826        // name AND some threads in that workspace. Matching threads should still appear.
2827        type_in_search(&sidebar, "fix", cx);
2828        assert_eq!(
2829            visible_entries_as_strings(&sidebar, cx),
2830            vec![
2831                "v [alpha-project]",
2832                "  Fix bug in sidebar  <== selected",
2833                "v [Empty Workspace]",
2834                "  Fix typo in README",
2835            ]
2836        );
2837
2838        // A query that matches a workspace name AND a thread in that same workspace.
2839        // Both the header (highlighted) and the matching thread should appear.
2840        type_in_search(&sidebar, "alpha", cx);
2841        assert_eq!(
2842            visible_entries_as_strings(&sidebar, cx),
2843            vec!["v [alpha-project]  <== selected"]
2844        );
2845
2846        // Now search for something that matches only a workspace name when there
2847        // are also threads with matching titles — the non-matching workspace's
2848        // threads should still appear if their titles match.
2849        type_in_search(&sidebar, "alp", cx);
2850        assert_eq!(
2851            visible_entries_as_strings(&sidebar, cx),
2852            vec!["v [alpha-project]  <== selected"]
2853        );
2854    }
2855
2856    #[gpui::test]
2857    async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) {
2858        let project = init_test_project("/my-project", cx).await;
2859        let (multi_workspace, cx) =
2860            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2861        let sidebar = setup_sidebar(&multi_workspace, cx);
2862
2863        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2864        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2865
2866        // Create 8 threads. The oldest one has a unique name and will be
2867        // behind View More (only 5 shown by default).
2868        for i in 0..8u32 {
2869            let title = if i == 0 {
2870                "Hidden gem thread".to_string()
2871            } else {
2872                format!("Thread {}", i + 1)
2873            };
2874            let save_task = thread_store.update(cx, |store, cx| {
2875                store.save_thread(
2876                    acp::SessionId::new(Arc::from(format!("thread-{}", i))),
2877                    make_test_thread(
2878                        &title,
2879                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
2880                    ),
2881                    path_list.clone(),
2882                    cx,
2883                )
2884            });
2885            save_task.await.unwrap();
2886        }
2887        cx.run_until_parked();
2888
2889        // Confirm the thread is not visible and View More is shown.
2890        let entries = visible_entries_as_strings(&sidebar, cx);
2891        assert!(
2892            entries.iter().any(|e| e.contains("View More")),
2893            "should have View More button"
2894        );
2895        assert!(
2896            !entries.iter().any(|e| e.contains("Hidden gem")),
2897            "Hidden gem should be behind View More"
2898        );
2899
2900        // User searches for the hidden thread — it appears, and View More is gone.
2901        type_in_search(&sidebar, "hidden gem", cx);
2902        let filtered = visible_entries_as_strings(&sidebar, cx);
2903        assert_eq!(
2904            filtered,
2905            vec!["v [my-project]", "  Hidden gem thread  <== selected",]
2906        );
2907        assert!(
2908            !filtered.iter().any(|e| e.contains("View More")),
2909            "View More should not appear when filtering"
2910        );
2911    }
2912
2913    #[gpui::test]
2914    async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
2915        let project = init_test_project("/my-project", cx).await;
2916        let (multi_workspace, cx) =
2917            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2918        let sidebar = setup_sidebar(&multi_workspace, cx);
2919
2920        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2921        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2922
2923        let save_task = thread_store.update(cx, |store, cx| {
2924            store.save_thread(
2925                acp::SessionId::new(Arc::from("thread-1")),
2926                make_test_thread(
2927                    "Important thread",
2928                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
2929                ),
2930                path_list.clone(),
2931                cx,
2932            )
2933        });
2934        save_task.await.unwrap();
2935        cx.run_until_parked();
2936
2937        // User focuses the sidebar and collapses the group using keyboard:
2938        // select the header, then press CollapseSelectedEntry to collapse.
2939        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
2940        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
2941        cx.dispatch_action(CollapseSelectedEntry);
2942        cx.run_until_parked();
2943
2944        assert_eq!(
2945            visible_entries_as_strings(&sidebar, cx),
2946            vec!["> [my-project]  <== selected"]
2947        );
2948
2949        // User types a search — the thread appears even though its group is collapsed.
2950        type_in_search(&sidebar, "important", cx);
2951        assert_eq!(
2952            visible_entries_as_strings(&sidebar, cx),
2953            vec!["> [my-project]", "  Important thread  <== selected",]
2954        );
2955    }
2956
2957    #[gpui::test]
2958    async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
2959        let project = init_test_project("/my-project", cx).await;
2960        let (multi_workspace, cx) =
2961            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2962        let sidebar = setup_sidebar(&multi_workspace, cx);
2963
2964        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
2965        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
2966
2967        for (id, title, hour) in [
2968            ("t-1", "Fix crash in panel", 3),
2969            ("t-2", "Fix lint warnings", 2),
2970            ("t-3", "Add new feature", 1),
2971        ] {
2972            let save_task = thread_store.update(cx, |store, cx| {
2973                store.save_thread(
2974                    acp::SessionId::new(Arc::from(id)),
2975                    make_test_thread(
2976                        title,
2977                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
2978                    ),
2979                    path_list.clone(),
2980                    cx,
2981                )
2982            });
2983            save_task.await.unwrap();
2984        }
2985        cx.run_until_parked();
2986
2987        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
2988
2989        // User types "fix" — two threads match.
2990        type_in_search(&sidebar, "fix", cx);
2991        assert_eq!(
2992            visible_entries_as_strings(&sidebar, cx),
2993            vec![
2994                "v [my-project]",
2995                "  Fix crash in panel  <== selected",
2996                "  Fix lint warnings",
2997            ]
2998        );
2999
3000        // Selection starts on the first matching thread. User presses
3001        // SelectNext to move to the second match.
3002        cx.dispatch_action(SelectNext);
3003        assert_eq!(
3004            visible_entries_as_strings(&sidebar, cx),
3005            vec![
3006                "v [my-project]",
3007                "  Fix crash in panel",
3008                "  Fix lint warnings  <== selected",
3009            ]
3010        );
3011
3012        // User can also jump back with SelectPrevious.
3013        cx.dispatch_action(SelectPrevious);
3014        assert_eq!(
3015            visible_entries_as_strings(&sidebar, cx),
3016            vec![
3017                "v [my-project]",
3018                "  Fix crash in panel  <== selected",
3019                "  Fix lint warnings",
3020            ]
3021        );
3022    }
3023
3024    #[gpui::test]
3025    async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) {
3026        let project = init_test_project("/my-project", cx).await;
3027        let (multi_workspace, cx) =
3028            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3029        let sidebar = setup_sidebar(&multi_workspace, cx);
3030
3031        multi_workspace.update_in(cx, |mw, window, cx| {
3032            mw.create_workspace(window, cx);
3033        });
3034        cx.run_until_parked();
3035
3036        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3037        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3038
3039        let save_task = thread_store.update(cx, |store, cx| {
3040            store.save_thread(
3041                acp::SessionId::new(Arc::from("hist-1")),
3042                make_test_thread(
3043                    "Historical Thread",
3044                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
3045                ),
3046                path_list.clone(),
3047                cx,
3048            )
3049        });
3050        save_task.await.unwrap();
3051        cx.run_until_parked();
3052        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3053        cx.run_until_parked();
3054
3055        assert_eq!(
3056            visible_entries_as_strings(&sidebar, cx),
3057            vec![
3058                "v [my-project]",
3059                "  Historical Thread",
3060                "v [Empty Workspace]",
3061                "  [+ New Thread]",
3062            ]
3063        );
3064
3065        // Switch to workspace 1 so we can verify the confirm switches back.
3066        multi_workspace.update_in(cx, |mw, window, cx| {
3067            mw.activate_index(1, window, cx);
3068        });
3069        cx.run_until_parked();
3070        assert_eq!(
3071            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
3072            1
3073        );
3074
3075        // Confirm on the historical (non-live) thread at index 1.
3076        // Before a previous fix, the workspace field was Option<usize> and
3077        // historical threads had None, so activate_thread early-returned
3078        // without switching the workspace.
3079        sidebar.update_in(cx, |sidebar, window, cx| {
3080            sidebar.selection = Some(1);
3081            sidebar.confirm(&Confirm, window, cx);
3082        });
3083        cx.run_until_parked();
3084
3085        assert_eq!(
3086            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
3087            0
3088        );
3089    }
3090
3091    #[gpui::test]
3092    async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
3093        let project = init_test_project("/my-project", cx).await;
3094        let (multi_workspace, cx) =
3095            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3096        let sidebar = setup_sidebar(&multi_workspace, cx);
3097
3098        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3099        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
3100
3101        let save_task = thread_store.update(cx, |store, cx| {
3102            store.save_thread(
3103                acp::SessionId::new(Arc::from("t-1")),
3104                make_test_thread(
3105                    "Thread A",
3106                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
3107                ),
3108                path_list.clone(),
3109                cx,
3110            )
3111        });
3112        save_task.await.unwrap();
3113        let save_task = thread_store.update(cx, |store, cx| {
3114            store.save_thread(
3115                acp::SessionId::new(Arc::from("t-2")),
3116                make_test_thread(
3117                    "Thread B",
3118                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
3119                ),
3120                path_list.clone(),
3121                cx,
3122            )
3123        });
3124        save_task.await.unwrap();
3125        cx.run_until_parked();
3126        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
3127        cx.run_until_parked();
3128
3129        assert_eq!(
3130            visible_entries_as_strings(&sidebar, cx),
3131            vec!["v [my-project]", "  Thread A", "  Thread B",]
3132        );
3133
3134        // Keyboard confirm preserves selection.
3135        sidebar.update_in(cx, |sidebar, window, cx| {
3136            sidebar.selection = Some(1);
3137            sidebar.confirm(&Confirm, window, cx);
3138        });
3139        assert_eq!(
3140            sidebar.read_with(cx, |sidebar, _| sidebar.selection),
3141            Some(1)
3142        );
3143
3144        // Click handlers clear selection to None so no highlight lingers
3145        // after a click regardless of focus state. The hover style provides
3146        // visual feedback during mouse interaction instead.
3147        sidebar.update_in(cx, |sidebar, window, cx| {
3148            sidebar.selection = None;
3149            let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3150            sidebar.toggle_collapse(&path_list, window, cx);
3151        });
3152        assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
3153
3154        // When the user tabs back into the sidebar, focus_in restores
3155        // selection to the first entry for keyboard navigation.
3156        sidebar.update_in(cx, |sidebar, window, cx| {
3157            sidebar.focus_in(window, cx);
3158        });
3159        assert_eq!(
3160            sidebar.read_with(cx, |sidebar, _| sidebar.selection),
3161            Some(0)
3162        );
3163    }
3164
3165    #[gpui::test]
3166    async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
3167        let project = init_test_project_with_agent_panel("/my-project", cx).await;
3168        let (multi_workspace, cx) =
3169            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3170        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
3171
3172        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
3173
3174        let connection = StubAgentConnection::new();
3175        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3176            acp::ContentChunk::new("Hi there!".into()),
3177        )]);
3178        open_thread_with_connection(&panel, connection, cx);
3179        send_message(&panel, cx);
3180
3181        let session_id = active_session_id(&panel, cx);
3182        save_thread_to_store(&session_id, &path_list, cx).await;
3183        cx.run_until_parked();
3184
3185        assert_eq!(
3186            visible_entries_as_strings(&sidebar, cx),
3187            vec!["v [my-project]", "  Hello *"]
3188        );
3189
3190        // Simulate the agent generating a title. The notification chain is:
3191        // AcpThread::set_title emits TitleUpdated →
3192        // ConnectionView::handle_thread_event calls cx.notify() →
3193        // AgentPanel observer fires and emits AgentPanelEvent →
3194        // Sidebar subscription calls update_entries / rebuild_contents.
3195        //
3196        // Before the fix, handle_thread_event did NOT call cx.notify() for
3197        // TitleUpdated, so the AgentPanel observer never fired and the
3198        // sidebar kept showing the old title.
3199        let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap());
3200        thread.update(cx, |thread, cx| {
3201            thread
3202                .set_title("Friendly Greeting with AI".into(), cx)
3203                .detach();
3204        });
3205        cx.run_until_parked();
3206
3207        assert_eq!(
3208            visible_entries_as_strings(&sidebar, cx),
3209            vec!["v [my-project]", "  Friendly Greeting with AI *"]
3210        );
3211    }
3212
3213    #[gpui::test]
3214    async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
3215        let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
3216        let (multi_workspace, cx) = cx
3217            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
3218        let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx);
3219
3220        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
3221
3222        // Save a thread so it appears in the list.
3223        let connection_a = StubAgentConnection::new();
3224        connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3225            acp::ContentChunk::new("Done".into()),
3226        )]);
3227        open_thread_with_connection(&panel_a, connection_a, cx);
3228        send_message(&panel_a, cx);
3229        let session_id_a = active_session_id(&panel_a, cx);
3230        save_thread_to_store(&session_id_a, &path_list_a, cx).await;
3231
3232        // Add a second workspace with its own agent panel.
3233        let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
3234        fs.as_fake()
3235            .insert_tree("/project-b", serde_json::json!({ "src": {} }))
3236            .await;
3237        let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
3238        let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
3239            mw.test_add_workspace(project_b.clone(), window, cx)
3240        });
3241        let panel_b = add_agent_panel(&workspace_b, &project_b, cx);
3242        cx.run_until_parked();
3243
3244        let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone());
3245
3246        // ── 1. Initial state: no focused thread ──────────────────────────────
3247        // Workspace B is active (just added), so its header is the active entry.
3248        sidebar.read_with(cx, |sidebar, _cx| {
3249            assert_eq!(
3250                sidebar.focused_thread, None,
3251                "Initially no thread should be focused"
3252            );
3253            let active_entry = sidebar
3254                .active_entry_index
3255                .and_then(|ix| sidebar.contents.entries.get(ix));
3256            assert!(
3257                matches!(active_entry, Some(ListEntry::ProjectHeader { .. })),
3258                "Active entry should be the active workspace header"
3259            );
3260        });
3261
3262        sidebar.update_in(cx, |sidebar, window, cx| {
3263            sidebar.activate_thread(
3264                acp_thread::AgentSessionInfo {
3265                    session_id: session_id_a.clone(),
3266                    cwd: None,
3267                    title: Some("Test".into()),
3268                    updated_at: None,
3269                    meta: None,
3270                },
3271                &workspace_a,
3272                window,
3273                cx,
3274            );
3275        });
3276        cx.run_until_parked();
3277
3278        sidebar.read_with(cx, |sidebar, _cx| {
3279            assert_eq!(
3280                sidebar.focused_thread.as_ref(),
3281                Some(&session_id_a),
3282                "After clicking a thread, it should be the focused thread"
3283            );
3284            let active_entry = sidebar.active_entry_index
3285                .and_then(|ix| sidebar.contents.entries.get(ix));
3286            assert!(
3287                matches!(active_entry, Some(ListEntry::Thread { session_info, .. }) if session_info.session_id == session_id_a),
3288                "Active entry should be the clicked thread"
3289            );
3290        });
3291
3292        workspace_a.read_with(cx, |workspace, cx| {
3293            assert!(
3294                workspace.panel::<AgentPanel>(cx).is_some(),
3295                "Agent panel should exist"
3296            );
3297            let dock = workspace.right_dock().read(cx);
3298            assert!(
3299                dock.is_open(),
3300                "Clicking a thread should open the agent panel dock"
3301            );
3302        });
3303
3304        let connection_b = StubAgentConnection::new();
3305        connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3306            acp::ContentChunk::new("Thread B".into()),
3307        )]);
3308        open_thread_with_connection(&panel_b, connection_b, cx);
3309        send_message(&panel_b, cx);
3310        let session_id_b = active_session_id(&panel_b, cx);
3311        let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
3312        save_thread_to_store(&session_id_b, &path_list_b, cx).await;
3313        cx.run_until_parked();
3314
3315        // Workspace A is currently active. Click a thread in workspace B,
3316        // which also triggers a workspace switch.
3317        sidebar.update_in(cx, |sidebar, window, cx| {
3318            sidebar.activate_thread(
3319                acp_thread::AgentSessionInfo {
3320                    session_id: session_id_b.clone(),
3321                    cwd: None,
3322                    title: Some("Thread B".into()),
3323                    updated_at: None,
3324                    meta: None,
3325                },
3326                &workspace_b,
3327                window,
3328                cx,
3329            );
3330        });
3331        cx.run_until_parked();
3332
3333        sidebar.read_with(cx, |sidebar, _cx| {
3334            assert_eq!(
3335                sidebar.focused_thread.as_ref(),
3336                Some(&session_id_b),
3337                "Clicking a thread in another workspace should focus that thread"
3338            );
3339            let active_entry = sidebar
3340                .active_entry_index
3341                .and_then(|ix| sidebar.contents.entries.get(ix));
3342            assert!(
3343                matches!(active_entry, Some(ListEntry::Thread { session_info, .. }) if session_info.session_id == session_id_b),
3344                "Active entry should be the cross-workspace thread"
3345            );
3346        });
3347
3348        multi_workspace.update_in(cx, |mw, window, cx| {
3349            mw.activate_next_workspace(window, cx);
3350        });
3351        cx.run_until_parked();
3352
3353        sidebar.read_with(cx, |sidebar, _cx| {
3354            assert_eq!(
3355                sidebar.focused_thread, None,
3356                "External workspace switch should clear focused_thread"
3357            );
3358            let active_entry = sidebar
3359                .active_entry_index
3360                .and_then(|ix| sidebar.contents.entries.get(ix));
3361            assert!(
3362                matches!(active_entry, Some(ListEntry::ProjectHeader { .. })),
3363                "Active entry should be the workspace header after external switch"
3364            );
3365        });
3366
3367        let connection_b2 = StubAgentConnection::new();
3368        connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
3369            acp::ContentChunk::new("New thread".into()),
3370        )]);
3371        open_thread_with_connection(&panel_b, connection_b2, cx);
3372        send_message(&panel_b, cx);
3373        let session_id_b2 = active_session_id(&panel_b, cx);
3374        save_thread_to_store(&session_id_b2, &path_list_b, cx).await;
3375        cx.run_until_parked();
3376
3377        sidebar.read_with(cx, |sidebar, _cx| {
3378            assert_eq!(
3379                sidebar.focused_thread.as_ref(),
3380                Some(&session_id_b2),
3381                "Opening a thread externally should set focused_thread"
3382            );
3383        });
3384
3385        workspace_b.update_in(cx, |workspace, window, cx| {
3386            workspace.focus_handle(cx).focus(window, cx);
3387        });
3388        cx.run_until_parked();
3389
3390        sidebar.read_with(cx, |sidebar, _cx| {
3391            assert_eq!(
3392                sidebar.focused_thread.as_ref(),
3393                Some(&session_id_b2),
3394                "Defocusing the sidebar should not clear focused_thread"
3395            );
3396        });
3397
3398        sidebar.update_in(cx, |sidebar, window, cx| {
3399            sidebar.activate_workspace(&workspace_b, window, cx);
3400        });
3401        cx.run_until_parked();
3402
3403        sidebar.read_with(cx, |sidebar, _cx| {
3404            assert_eq!(
3405                sidebar.focused_thread, None,
3406                "Clicking a workspace header should clear focused_thread"
3407            );
3408            let active_entry = sidebar
3409                .active_entry_index
3410                .and_then(|ix| sidebar.contents.entries.get(ix));
3411            assert!(
3412                matches!(active_entry, Some(ListEntry::ProjectHeader { .. })),
3413                "Active entry should be the workspace header"
3414            );
3415        });
3416
3417        // ── 8. Focusing the agent panel thread restores focused_thread ────
3418        // Workspace B still has session_id_b2 loaded in the agent panel.
3419        // Clicking into the thread (simulated by focusing its view) should
3420        // set focused_thread via the ThreadFocused event.
3421        panel_b.update_in(cx, |panel, window, cx| {
3422            if let Some(thread_view) = panel.active_connection_view() {
3423                thread_view.read(cx).focus_handle(cx).focus(window, cx);
3424            }
3425        });
3426        cx.run_until_parked();
3427
3428        sidebar.read_with(cx, |sidebar, _cx| {
3429            assert_eq!(
3430                sidebar.focused_thread.as_ref(),
3431                Some(&session_id_b2),
3432                "Focusing the agent panel thread should set focused_thread"
3433            );
3434            let active_entry = sidebar
3435                .active_entry_index
3436                .and_then(|ix| sidebar.contents.entries.get(ix));
3437            assert!(
3438                matches!(active_entry, Some(ListEntry::Thread { session_info, .. }) if session_info.session_id == session_id_b2),
3439                "Active entry should be the focused thread"
3440            );
3441        });
3442    }
3443}