use crate::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore}; use crate::threads_archive_view::{ThreadsArchiveView, ThreadsArchiveViewEvent}; use crate::{Agent, AgentPanel, AgentPanelEvent, NewThread, RemoveSelectedThread}; use acp_thread::ThreadStatus; use action_log::DiffStats; use agent::ThreadStore; use agent_client_protocol::{self as acp}; use agent_settings::AgentSettings; use chrono::Utc; use db::kvp::KEY_VALUE_STORE; use editor::Editor; use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _}; use gpui::{ Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, ListState, Pixels, Render, SharedString, WeakEntity, Window, actions, list, prelude::*, px, }; use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use project::{AgentId, Event as ProjectEvent}; use settings::Settings; use std::collections::{HashMap, HashSet}; use std::mem; use std::path::Path; use std::sync::Arc; use theme::ActiveTheme; use ui::{ AgentThreadStatus, ButtonStyle, HighlightedLabel, IconButtonShape, ListItem, Tab, ThreadItem, Tooltip, WithScrollbar, prelude::*, }; use util::ResultExt as _; use util::path_list::PathList; use workspace::{ MultiWorkspace, MultiWorkspaceEvent, ToggleWorkspaceSidebar, Workspace, multi_workspace_enabled, }; use zed_actions::editor::{MoveDown, MoveUp}; actions!( agents_sidebar, [ /// Collapses the selected entry in the workspace sidebar. CollapseSelectedEntry, /// Expands the selected entry in the workspace sidebar. ExpandSelectedEntry, ] ); const DEFAULT_WIDTH: Pixels = px(320.0); const MIN_WIDTH: Pixels = px(200.0); const MAX_WIDTH: Pixels = px(800.0); const DEFAULT_THREADS_SHOWN: usize = 5; const SIDEBAR_STATE_KEY: &str = "sidebar_state"; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] enum SidebarView { #[default] ThreadList, Archive, } fn read_sidebar_open_state(multi_workspace_id: u64) -> bool { KEY_VALUE_STORE .scoped(SIDEBAR_STATE_KEY) .read(&multi_workspace_id.to_string()) .log_err() .flatten() .and_then(|json| serde_json::from_str::(&json).ok()) .unwrap_or(false) } async fn save_sidebar_open_state(multi_workspace_id: u64, is_open: bool) { if let Ok(json) = serde_json::to_string(&is_open) { KEY_VALUE_STORE .scoped(SIDEBAR_STATE_KEY) .write(multi_workspace_id.to_string(), json) .await .log_err(); } } #[derive(Clone, Debug)] struct ActiveThreadInfo { session_id: acp::SessionId, title: SharedString, status: AgentThreadStatus, icon: IconName, icon_from_external_svg: Option, is_background: bool, is_title_generating: bool, diff_stats: DiffStats, } impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo { fn from(info: &ActiveThreadInfo) -> Self { Self { session_id: info.session_id.clone(), work_dirs: None, title: Some(info.title.clone()), updated_at: Some(Utc::now()), created_at: Some(Utc::now()), meta: None, } } } #[derive(Clone)] enum ThreadEntryWorkspace { Open(Entity), Closed(PathList), } #[derive(Clone)] struct ThreadEntry { agent: Agent, session_info: acp_thread::AgentSessionInfo, icon: IconName, icon_from_external_svg: Option, status: AgentThreadStatus, workspace: ThreadEntryWorkspace, is_live: bool, is_background: bool, is_title_generating: bool, highlight_positions: Vec, worktree_name: Option, worktree_highlight_positions: Vec, diff_stats: DiffStats, } #[derive(Clone)] enum ListEntry { ProjectHeader { path_list: PathList, label: SharedString, workspace: Entity, highlight_positions: Vec, has_threads: bool, }, Thread(ThreadEntry), ViewMore { path_list: PathList, remaining_count: usize, is_fully_expanded: bool, }, NewThread { path_list: PathList, workspace: Entity, }, } impl From for ListEntry { fn from(thread: ThreadEntry) -> Self { ListEntry::Thread(thread) } } #[derive(Default)] struct SidebarContents { entries: Vec, notified_threads: HashSet, project_header_indices: Vec, } impl SidebarContents { fn is_thread_notified(&self, session_id: &acp::SessionId) -> bool { self.notified_threads.contains(session_id) } } fn fuzzy_match_positions(query: &str, candidate: &str) -> Option> { let mut positions = Vec::new(); let mut query_chars = query.chars().peekable(); for (byte_idx, candidate_char) in candidate.char_indices() { if let Some(&query_char) = query_chars.peek() { if candidate_char.eq_ignore_ascii_case(&query_char) { positions.push(byte_idx); query_chars.next(); } } else { break; } } if query_chars.peek().is_none() { Some(positions) } else { None } } // TODO: The mapping from workspace root paths to git repositories needs a // unified approach across the codebase: this function, `AgentPanel::classify_worktrees`, // thread persistence (which PathList is saved to the database), and thread // querying (which PathList is used to read threads back). All of these need // to agree on how repos are resolved for a given workspace, especially in // multi-root and nested-repo configurations. fn root_repository_snapshots( workspace: &Entity, cx: &App, ) -> Vec { let path_list = workspace_path_list(workspace, cx); let project = workspace.read(cx).project().read(cx); project .repositories(cx) .values() .filter_map(|repo| { let snapshot = repo.read(cx).snapshot(); let is_root = path_list .paths() .iter() .any(|p| p.as_path() == snapshot.work_directory_abs_path.as_ref()); is_root.then_some(snapshot) }) .collect() } fn workspace_path_list(workspace: &Entity, cx: &App) -> PathList { PathList::new(&workspace.read(cx).root_paths(cx)) } fn workspace_label_from_path_list(path_list: &PathList) -> SharedString { let mut names = Vec::with_capacity(path_list.paths().len()); for abs_path in path_list.paths() { if let Some(name) = abs_path.file_name() { names.push(name.to_string_lossy().to_string()); } } if names.is_empty() { // TODO: Can we do something better in this case? "Empty Workspace".into() } else { names.join(", ").into() } } pub struct Sidebar { multi_workspace: WeakEntity, persistence_key: Option, is_open: bool, width: Pixels, focus_handle: FocusHandle, filter_editor: Entity, list_state: ListState, contents: SidebarContents, /// The index of the list item that currently has the keyboard focus /// /// Note: This is NOT the same as the active item. selection: Option, focused_thread: Option, active_entry_index: Option, hovered_thread_index: Option, collapsed_groups: HashSet, expanded_groups: HashMap, view: SidebarView, archive_view: Option>, _subscriptions: Vec, _update_entries_task: Option>, } impl Sidebar { pub fn new( multi_workspace: Entity, window: &mut Window, cx: &mut Context, ) -> Self { let focus_handle = cx.focus_handle(); cx.on_focus_in(&focus_handle, window, Self::focus_in) .detach(); let filter_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); editor.set_placeholder_text("Search…", window, cx); editor }); cx.subscribe_in( &multi_workspace, window, |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event { MultiWorkspaceEvent::ActiveWorkspaceChanged => { this.update_entries(false, cx); } MultiWorkspaceEvent::WorkspaceAdded(workspace) => { this.subscribe_to_workspace(workspace, window, cx); this.update_entries(false, cx); } MultiWorkspaceEvent::WorkspaceRemoved(_) => { this.update_entries(false, cx); } }, ) .detach(); cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| { if let editor::EditorEvent::BufferEdited = event { let query = this.filter_editor.read(cx).text(cx); if !query.is_empty() { this.selection.take(); } this.update_entries(!query.is_empty(), cx); } }) .detach(); cx.observe(&ThreadMetadataStore::global(cx), |this, _store, cx| { this.update_entries(false, cx); }) .detach(); cx.observe_flag::(window, |_is_enabled, this, _window, cx| { this.update_entries(false, cx); }) .detach(); let workspaces = multi_workspace.read(cx).workspaces().to_vec(); cx.defer_in(window, move |this, window, cx| { for workspace in &workspaces { this.subscribe_to_workspace(workspace, window, cx); } this.update_entries(false, cx); }); let persistence_key = multi_workspace.read(cx).database_id().map(|id| id.0); let is_open = persistence_key .map(read_sidebar_open_state) .unwrap_or(false); Self { _update_entries_task: None, multi_workspace: multi_workspace.downgrade(), persistence_key, is_open, width: DEFAULT_WIDTH, focus_handle, filter_editor, list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)), contents: SidebarContents::default(), selection: None, focused_thread: None, active_entry_index: None, hovered_thread_index: None, collapsed_groups: HashSet::new(), expanded_groups: HashMap::new(), view: SidebarView::default(), archive_view: None, _subscriptions: Vec::new(), } } fn subscribe_to_workspace( &self, workspace: &Entity, window: &mut Window, cx: &mut Context, ) { let project = workspace.read(cx).project().clone(); cx.subscribe_in( &project, window, |this, _project, event, _window, cx| match event { ProjectEvent::WorktreeAdded(_) | ProjectEvent::WorktreeRemoved(_) | ProjectEvent::WorktreeOrderChanged => { this.update_entries(false, cx); } _ => {} }, ) .detach(); let git_store = workspace.read(cx).project().read(cx).git_store().clone(); cx.subscribe_in( &git_store, window, |this, _, event: &project::git_store::GitStoreEvent, window, cx| { if matches!( event, project::git_store::GitStoreEvent::RepositoryUpdated( _, project::git_store::RepositoryEvent::GitWorktreeListChanged, _, ) ) { this.prune_stale_worktree_workspaces(window, cx); this.update_entries(false, cx); } }, ) .detach(); cx.subscribe_in( workspace, window, |this, _workspace, event: &workspace::Event, window, cx| { if let workspace::Event::PanelAdded(view) = event { if let Ok(agent_panel) = view.clone().downcast::() { this.subscribe_to_agent_panel(&agent_panel, window, cx); } } }, ) .detach(); if let Some(agent_panel) = workspace.read(cx).panel::(cx) { self.subscribe_to_agent_panel(&agent_panel, window, cx); } } fn subscribe_to_agent_panel( &self, agent_panel: &Entity, window: &mut Window, cx: &mut Context, ) { cx.subscribe_in( agent_panel, window, |this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event { AgentPanelEvent::ActiveViewChanged | AgentPanelEvent::ThreadFocused | AgentPanelEvent::BackgroundThreadChanged => { this.update_entries(false, cx); } }, ) .detach(); } fn all_thread_infos_for_workspace( workspace: &Entity, cx: &App, ) -> Vec { let Some(agent_panel) = workspace.read(cx).panel::(cx) else { return Vec::new(); }; let agent_panel_ref = agent_panel.read(cx); agent_panel_ref .parent_threads(cx) .into_iter() .map(|thread_view| { let thread_view_ref = thread_view.read(cx); let thread = thread_view_ref.thread.read(cx); let icon = thread_view_ref.agent_icon; let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone(); let title = thread.title(); let is_native = thread_view_ref.as_native_thread(cx).is_some(); let is_title_generating = is_native && thread.has_provisional_title(); let session_id = thread.session_id().clone(); let is_background = agent_panel_ref.is_background_thread(&session_id); let status = if thread.is_waiting_for_confirmation() { AgentThreadStatus::WaitingForConfirmation } else if thread.had_error() { AgentThreadStatus::Error } else { match thread.status() { ThreadStatus::Generating => AgentThreadStatus::Running, ThreadStatus::Idle => AgentThreadStatus::Completed, } }; let diff_stats = thread.action_log().read(cx).diff_stats(cx); ActiveThreadInfo { session_id, title, status, icon, icon_from_external_svg, is_background, is_title_generating, diff_stats, } }) .collect() } fn rebuild_contents(&mut self, thread_entries: Vec, cx: &App) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; let mw = multi_workspace.read(cx); let workspaces = mw.workspaces().to_vec(); let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned(); self.focused_thread = active_workspace .as_ref() .and_then(|ws| ws.read(cx).panel::(cx)) .and_then(|panel| panel.read(cx).active_conversation().cloned()) .and_then(|cv| cv.read(cx).parent_id(cx)); let mut threads_by_paths: HashMap> = HashMap::new(); for row in thread_entries { threads_by_paths .entry(row.folder_paths.clone()) .or_default() .push(row); } // Build a lookup for agent icons from the first workspace's AgentServerStore. let agent_server_store = workspaces .first() .map(|ws| ws.read(cx).project().read(cx).agent_server_store().clone()); let query = self.filter_editor.read(cx).text(cx); let previous = mem::take(&mut self.contents); let old_statuses: HashMap = previous .entries .iter() .filter_map(|entry| match entry { ListEntry::Thread(thread) if thread.is_live => { Some((thread.session_info.session_id.clone(), thread.status)) } _ => None, }) .collect(); let mut entries = Vec::new(); let mut notified_threads = previous.notified_threads; // Track all session IDs we add to entries so we can prune stale // notifications without a separate pass at the end. let mut current_session_ids: HashSet = HashSet::new(); // Compute active_entry_index inline during the build pass. let mut active_entry_index: Option = None; // Identify absorbed workspaces in a single pass. A workspace is // "absorbed" when it points at a git worktree checkout whose main // repo is open as another workspace — its threads appear under the // main repo's header instead of getting their own. let mut main_repo_workspace: HashMap, usize> = HashMap::new(); let mut absorbed: HashMap = HashMap::new(); let mut pending: HashMap, Vec<(usize, SharedString, Arc)>> = HashMap::new(); let mut absorbed_workspace_by_path: HashMap, usize> = HashMap::new(); for (i, workspace) in workspaces.iter().enumerate() { for snapshot in root_repository_snapshots(workspace, cx) { if snapshot.work_directory_abs_path == snapshot.original_repo_abs_path { main_repo_workspace .entry(snapshot.work_directory_abs_path.clone()) .or_insert(i); if let Some(waiting) = pending.remove(&snapshot.work_directory_abs_path) { for (ws_idx, name, ws_path) in waiting { absorbed.insert(ws_idx, (i, name)); absorbed_workspace_by_path.insert(ws_path, ws_idx); } } } else { let name: SharedString = snapshot .work_directory_abs_path .file_name() .unwrap_or_default() .to_string_lossy() .to_string() .into(); if let Some(&main_idx) = main_repo_workspace.get(&snapshot.original_repo_abs_path) { absorbed.insert(i, (main_idx, name)); absorbed_workspace_by_path .insert(snapshot.work_directory_abs_path.clone(), i); } else { pending .entry(snapshot.original_repo_abs_path.clone()) .or_default() .push((i, name, snapshot.work_directory_abs_path.clone())); } } } } for (ws_index, workspace) in workspaces.iter().enumerate() { if absorbed.contains_key(&ws_index) { continue; } let path_list = workspace_path_list(workspace, cx); let label = workspace_label_from_path_list(&path_list); let is_collapsed = self.collapsed_groups.contains(&path_list); let should_load_threads = !is_collapsed || !query.is_empty(); let mut threads: Vec = Vec::new(); if should_load_threads { let mut seen_session_ids: HashSet = HashSet::new(); // Read threads from SidebarDb for this workspace's path list. if let Some(rows) = threads_by_paths.get(&path_list) { for row in rows { seen_session_ids.insert(row.session_id.clone()); let (agent, icon, icon_from_external_svg) = match &row.agent_id { None => (Agent::NativeAgent, IconName::ZedAgent, None), Some(id) => { let custom_icon = agent_server_store .as_ref() .and_then(|store| store.read(cx).agent_icon(&id)); ( Agent::Custom { id: id.clone() }, IconName::Terminal, custom_icon, ) } }; threads.push(ThreadEntry { agent, session_info: acp_thread::AgentSessionInfo { session_id: row.session_id.clone(), work_dirs: None, title: Some(row.title.clone()), updated_at: Some(row.updated_at), created_at: row.created_at, meta: None, }, icon, icon_from_external_svg, status: AgentThreadStatus::default(), workspace: ThreadEntryWorkspace::Open(workspace.clone()), is_live: false, is_background: false, is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }); } } // Load threads from linked git worktrees of this workspace's repos. { let mut linked_worktree_queries: Vec<(PathList, SharedString, Arc)> = Vec::new(); for snapshot in root_repository_snapshots(workspace, cx) { if snapshot.work_directory_abs_path != snapshot.original_repo_abs_path { continue; } for git_worktree in snapshot.linked_worktrees() { let name = git_worktree .path .file_name() .unwrap_or_default() .to_string_lossy() .to_string(); linked_worktree_queries.push(( PathList::new(std::slice::from_ref(&git_worktree.path)), name.into(), Arc::from(git_worktree.path.as_path()), )); } } for (worktree_path_list, worktree_name, worktree_path) in &linked_worktree_queries { let target_workspace = match absorbed_workspace_by_path.get(worktree_path.as_ref()) { Some(&idx) => ThreadEntryWorkspace::Open(workspaces[idx].clone()), None => ThreadEntryWorkspace::Closed(worktree_path_list.clone()), }; if let Some(rows) = threads_by_paths.get(worktree_path_list) { for row in rows { if !seen_session_ids.insert(row.session_id.clone()) { continue; } let (agent, icon, icon_from_external_svg) = match &row.agent_id { None => (Agent::NativeAgent, IconName::ZedAgent, None), Some(name) => { let custom_icon = agent_server_store.as_ref().and_then(|store| { store .read(cx) .agent_icon(&AgentId(name.clone().into())) }); ( Agent::Custom { id: AgentId::new(name.clone()), }, IconName::Terminal, custom_icon, ) } }; threads.push(ThreadEntry { agent, session_info: acp_thread::AgentSessionInfo { session_id: row.session_id.clone(), work_dirs: None, title: Some(row.title.clone()), updated_at: Some(row.updated_at), created_at: row.created_at, meta: None, }, icon, icon_from_external_svg, status: AgentThreadStatus::default(), workspace: target_workspace.clone(), is_live: false, is_background: false, is_title_generating: false, highlight_positions: Vec::new(), worktree_name: Some(worktree_name.clone()), worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }); } } } } let live_infos = Self::all_thread_infos_for_workspace(workspace, cx); if !live_infos.is_empty() { let thread_index_by_session: HashMap = threads .iter() .enumerate() .map(|(i, t)| (t.session_info.session_id.clone(), i)) .collect(); for info in &live_infos { let Some(&idx) = thread_index_by_session.get(&info.session_id) else { continue; }; let thread = &mut threads[idx]; thread.session_info.title = Some(info.title.clone()); thread.status = info.status; thread.icon = info.icon; thread.icon_from_external_svg = info.icon_from_external_svg.clone(); thread.is_live = true; thread.is_background = info.is_background; thread.is_title_generating = info.is_title_generating; thread.diff_stats = info.diff_stats; } } // Update notification state for live threads in the same pass. let is_active_workspace = active_workspace .as_ref() .is_some_and(|active| active == workspace); for thread in &threads { let session_id = &thread.session_info.session_id; if thread.is_background && thread.status == AgentThreadStatus::Completed { notified_threads.insert(session_id.clone()); } else if thread.status == AgentThreadStatus::Completed && !is_active_workspace && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running) { notified_threads.insert(session_id.clone()); } if is_active_workspace && !thread.is_background { notified_threads.remove(session_id); } } // Sort by created_at (newest first), falling back to updated_at // for threads without a created_at (e.g., ACP sessions). threads.sort_by(|a, b| { let a_time = a.session_info.created_at.or(a.session_info.updated_at); let b_time = b.session_info.created_at.or(b.session_info.updated_at); b_time.cmp(&a_time) }); } if !query.is_empty() { let has_threads = !threads.is_empty(); let workspace_highlight_positions = fuzzy_match_positions(&query, &label).unwrap_or_default(); let workspace_matched = !workspace_highlight_positions.is_empty(); let mut matched_threads: Vec = Vec::new(); for mut thread in threads { let title = thread .session_info .title .as_ref() .map(|s| s.as_ref()) .unwrap_or(""); if let Some(positions) = fuzzy_match_positions(&query, title) { thread.highlight_positions = positions; } if let Some(worktree_name) = &thread.worktree_name { if let Some(positions) = fuzzy_match_positions(&query, worktree_name) { thread.worktree_highlight_positions = positions; } } let worktree_matched = !thread.worktree_highlight_positions.is_empty(); if workspace_matched || !thread.highlight_positions.is_empty() || worktree_matched { matched_threads.push(thread); } } if matched_threads.is_empty() && !workspace_matched { continue; } if active_entry_index.is_none() && self.focused_thread.is_none() && active_workspace .as_ref() .is_some_and(|active| active == workspace) { active_entry_index = Some(entries.len()); } entries.push(ListEntry::ProjectHeader { path_list: path_list.clone(), label, workspace: workspace.clone(), highlight_positions: workspace_highlight_positions, has_threads, }); // Track session IDs and compute active_entry_index as we add // thread entries. for thread in matched_threads { current_session_ids.insert(thread.session_info.session_id.clone()); if active_entry_index.is_none() { if let Some(focused) = &self.focused_thread { if &thread.session_info.session_id == focused { active_entry_index = Some(entries.len()); } } } entries.push(thread.into()); } } else { let has_threads = !threads.is_empty(); // Check if this header is the active entry before pushing it. if active_entry_index.is_none() && self.focused_thread.is_none() && active_workspace .as_ref() .is_some_and(|active| active == workspace) { active_entry_index = Some(entries.len()); } entries.push(ListEntry::ProjectHeader { path_list: path_list.clone(), label, workspace: workspace.clone(), highlight_positions: Vec::new(), has_threads, }); if is_collapsed { continue; } let total = threads.len(); let extra_batches = self.expanded_groups.get(&path_list).copied().unwrap_or(0); let threads_to_show = DEFAULT_THREADS_SHOWN + (extra_batches * DEFAULT_THREADS_SHOWN); let count = threads_to_show.min(total); let is_fully_expanded = count >= total; // Track session IDs and compute active_entry_index as we add // thread entries. for thread in threads.into_iter().take(count) { current_session_ids.insert(thread.session_info.session_id.clone()); if active_entry_index.is_none() { if let Some(focused) = &self.focused_thread { if &thread.session_info.session_id == focused { active_entry_index = Some(entries.len()); } } } entries.push(thread.into()); } if total > DEFAULT_THREADS_SHOWN { entries.push(ListEntry::ViewMore { path_list: path_list.clone(), remaining_count: total.saturating_sub(count), is_fully_expanded, }); } if total == 0 { entries.push(ListEntry::NewThread { path_list: path_list.clone(), workspace: workspace.clone(), }); } } } // Prune stale notifications using the session IDs we collected during // the build pass (no extra scan needed). notified_threads.retain(|id| current_session_ids.contains(id)); let project_header_indices = entries .iter() .enumerate() .filter_map(|(i, e)| matches!(e, ListEntry::ProjectHeader { .. }).then_some(i)) .collect(); self.active_entry_index = active_entry_index; self.contents = SidebarContents { entries, notified_threads, project_header_indices, }; } fn update_entries(&mut self, select_first_thread: bool, cx: &mut Context) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; if !multi_workspace_enabled(cx) { return; } let had_notifications = self.has_notifications(cx); let scroll_position = self.list_state.logical_scroll_top(); let list_thread_entries_task = ThreadMetadataStore::global(cx).read(cx).list(cx); self._update_entries_task.take(); self._update_entries_task = Some(cx.spawn(async move |this, cx| { let Some(thread_entries) = list_thread_entries_task.await.log_err() else { return; }; this.update(cx, |this, cx| { this.rebuild_contents(thread_entries, cx); if select_first_thread { this.selection = this .contents .entries .iter() .position(|entry| matches!(entry, ListEntry::Thread(_))) .or_else(|| { if this.contents.entries.is_empty() { None } else { Some(0) } }); } this.list_state.reset(this.contents.entries.len()); this.list_state.scroll_to(scroll_position); if had_notifications != this.has_notifications(cx) { multi_workspace.update(cx, |_, cx| { cx.notify(); }); } cx.notify(); }) .ok(); })); } fn render_list_entry( &mut self, ix: usize, window: &mut Window, cx: &mut Context, ) -> AnyElement { let Some(entry) = self.contents.entries.get(ix) else { return div().into_any_element(); }; let is_focused = self.focus_handle.is_focused(window) || self.filter_editor.focus_handle(cx).is_focused(window); // is_selected means the keyboard selector is here. let is_selected = is_focused && self.selection == Some(ix); let is_group_header_after_first = ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. }); let docked_right = AgentSettings::get_global(cx).dock == settings::DockPosition::Right; let rendered = match entry { ListEntry::ProjectHeader { path_list, label, workspace, highlight_positions, has_threads, } => self.render_project_header( ix, false, path_list, label, workspace, highlight_positions, *has_threads, is_selected, docked_right, cx, ), ListEntry::Thread(thread) => { self.render_thread(ix, thread, is_selected, docked_right, cx) } ListEntry::ViewMore { path_list, remaining_count, is_fully_expanded, } => self.render_view_more( ix, path_list, *remaining_count, *is_fully_expanded, is_selected, cx, ), ListEntry::NewThread { path_list, workspace, } => self.render_new_thread(ix, path_list, workspace, is_selected, cx), }; if is_group_header_after_first { v_flex() .w_full() .border_t_1() .border_color(cx.theme().colors().border_variant) .child(rendered) .into_any_element() } else { rendered } } fn render_project_header( &self, ix: usize, is_sticky: bool, path_list: &PathList, label: &SharedString, workspace: &Entity, highlight_positions: &[usize], has_threads: bool, is_selected: bool, docked_right: bool, cx: &mut Context, ) -> AnyElement { let id_prefix = if is_sticky { "sticky-" } else { "" }; let id = SharedString::from(format!("{id_prefix}project-header-{ix}")); let group_name = SharedString::from(format!("{id_prefix}header-group-{ix}")); let ib_id = SharedString::from(format!("{id_prefix}project-header-new-thread-{ix}")); let is_collapsed = self.collapsed_groups.contains(path_list); let disclosure_icon = if is_collapsed { IconName::ChevronRight } else { IconName::ChevronDown }; let workspace_for_new_thread = workspace.clone(); let workspace_for_remove = workspace.clone(); // let workspace_for_activate = workspace.clone(); let path_list_for_toggle = path_list.clone(); let path_list_for_collapse = path_list.clone(); let view_more_expanded = self.expanded_groups.contains_key(path_list); let multi_workspace = self.multi_workspace.upgrade(); let workspace_count = multi_workspace .as_ref() .map_or(0, |mw| mw.read(cx).workspaces().len()); let is_active_workspace = self.focused_thread.is_none() && multi_workspace .as_ref() .is_some_and(|mw| mw.read(cx).workspace() == workspace); let label = if highlight_positions.is_empty() { Label::new(label.clone()) .size(LabelSize::Small) .color(Color::Muted) .into_any_element() } else { HighlightedLabel::new(label.clone(), highlight_positions.to_vec()) .size(LabelSize::Small) .color(Color::Muted) .into_any_element() }; ListItem::new(id) .group_name(group_name) .toggle_state(is_active_workspace) .focused(is_selected) .docked_right(docked_right) .child( h_flex() .relative() .min_w_0() .w_full() .py_1() .gap_1p5() .child( Icon::new(disclosure_icon) .size(IconSize::Small) .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.6))), ) .child(label), ) .end_hover_gradient_overlay(true) .end_hover_slot( h_flex() .gap_1() .when(workspace_count > 1, |this| { this.child( IconButton::new( SharedString::from(format!( "{id_prefix}project-header-remove-{ix}", )), IconName::Close, ) .icon_size(IconSize::Small) .icon_color(Color::Muted) .tooltip(Tooltip::text("Remove Project")) .on_click(cx.listener( move |this, _, window, cx| { this.remove_workspace(&workspace_for_remove, window, cx); }, )), ) }) .when(view_more_expanded && !is_collapsed, |this| { this.child( IconButton::new( SharedString::from(format!( "{id_prefix}project-header-collapse-{ix}", )), IconName::ListCollapse, ) .icon_size(IconSize::Small) .icon_color(Color::Muted) .tooltip(Tooltip::text("Collapse Displayed Threads")) .on_click(cx.listener({ let path_list_for_collapse = path_list_for_collapse.clone(); move |this, _, _window, cx| { this.selection = None; this.expanded_groups.remove(&path_list_for_collapse); this.update_entries(false, cx); } })), ) }) .when(has_threads, |this| { this.child( IconButton::new(ib_id, IconName::NewThread) .icon_size(IconSize::Small) .icon_color(Color::Muted) .tooltip(Tooltip::text("New Thread")) .on_click(cx.listener(move |this, _, window, cx| { this.selection = None; this.create_new_thread(&workspace_for_new_thread, window, cx); })), ) }), ) .on_click(cx.listener(move |this, _, window, cx| { this.selection = None; this.toggle_collapse(&path_list_for_toggle, window, cx); })) // TODO: Decide if we really want the header to be activating different workspaces // .on_click(cx.listener(move |this, _, window, cx| { // this.selection = None; // this.activate_workspace(&workspace_for_activate, window, cx); // })) .into_any_element() } fn render_sticky_header( &self, docked_right: bool, window: &mut Window, cx: &mut Context, ) -> Option { let scroll_top = self.list_state.logical_scroll_top(); let &header_idx = self .contents .project_header_indices .iter() .rev() .find(|&&idx| idx <= scroll_top.item_ix)?; let needs_sticky = header_idx < scroll_top.item_ix || (header_idx == scroll_top.item_ix && scroll_top.offset_in_item > px(0.)); if !needs_sticky { return None; } let ListEntry::ProjectHeader { path_list, label, workspace, highlight_positions, has_threads, } = self.contents.entries.get(header_idx)? else { return None; }; let is_focused = self.focus_handle.is_focused(window) || self.filter_editor.focus_handle(cx).is_focused(window); let is_selected = is_focused && self.selection == Some(header_idx); let header_element = self.render_project_header( header_idx, true, &path_list, &label, &workspace, &highlight_positions, *has_threads, is_selected, docked_right, cx, ); let top_offset = self .contents .project_header_indices .iter() .find(|&&idx| idx > header_idx) .and_then(|&next_idx| { let bounds = self.list_state.bounds_for_item(next_idx)?; let viewport = self.list_state.viewport_bounds(); let y_in_viewport = bounds.origin.y - viewport.origin.y; let header_height = bounds.size.height; (y_in_viewport < header_height).then_some(y_in_viewport - header_height) }) .unwrap_or(px(0.)); let element = v_flex() .absolute() .top(top_offset) .left_0() .w_full() .bg(cx.theme().colors().surface_background) .border_b_1() .border_color(cx.theme().colors().border_variant) .child(header_element) .into_any_element(); Some(element) } fn activate_workspace( &mut self, workspace: &Entity, window: &mut Window, cx: &mut Context, ) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; multi_workspace.update(cx, |multi_workspace, cx| { multi_workspace.activate(workspace.clone(), cx); }); multi_workspace.update(cx, |multi_workspace, cx| { multi_workspace.focus_active_workspace(window, cx); }); } fn prune_stale_worktree_workspaces(&mut self, window: &mut Window, cx: &mut Context) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; let workspaces = multi_workspace.read(cx).workspaces().to_vec(); // Collect all worktree paths that are currently listed by any main // repo open in any workspace. let mut known_worktree_paths: HashSet = HashSet::new(); for workspace in &workspaces { for snapshot in root_repository_snapshots(workspace, cx) { if snapshot.work_directory_abs_path != snapshot.original_repo_abs_path { continue; } for git_worktree in snapshot.linked_worktrees() { known_worktree_paths.insert(git_worktree.path.to_path_buf()); } } } // Find workspaces that consist of exactly one root folder which is a // stale worktree checkout. Multi-root workspaces are never pruned — // losing one worktree shouldn't destroy a workspace that also // contains other folders. let mut to_remove: Vec> = Vec::new(); for workspace in &workspaces { let path_list = workspace_path_list(workspace, cx); if path_list.paths().len() != 1 { continue; } let should_prune = root_repository_snapshots(workspace, cx) .iter() .any(|snapshot| { snapshot.work_directory_abs_path != snapshot.original_repo_abs_path && !known_worktree_paths.contains(snapshot.work_directory_abs_path.as_ref()) }); if should_prune { to_remove.push(workspace.clone()); } } for workspace in &to_remove { self.remove_workspace(workspace, window, cx); } } fn remove_workspace( &mut self, workspace: &Entity, window: &mut Window, cx: &mut Context, ) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; multi_workspace.update(cx, |multi_workspace, cx| { let Some(index) = multi_workspace .workspaces() .iter() .position(|w| w == workspace) else { return; }; multi_workspace.remove_workspace(index, window, cx); }); } fn toggle_collapse( &mut self, path_list: &PathList, _window: &mut Window, cx: &mut Context, ) { if self.collapsed_groups.contains(path_list) { self.collapsed_groups.remove(path_list); } else { self.collapsed_groups.insert(path_list.clone()); } self.update_entries(false, cx); } fn focus_in(&mut self, _window: &mut Window, _cx: &mut Context) {} fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { if self.reset_filter_editor_text(window, cx) { self.update_entries(false, cx); } else { self.focus_handle.focus(window, cx); } } fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context) -> bool { self.filter_editor.update(cx, |editor, cx| { if editor.buffer().read(cx).len(cx).0 > 0 { editor.set_text("", window, cx); true } else { false } }) } fn has_filter_query(&self, cx: &App) -> bool { !self.filter_editor.read(cx).text(cx).is_empty() } fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context) { self.select_next(&SelectNext, window, cx); } fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context) { self.select_previous(&SelectPrevious, window, cx); } fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context) { let next = match self.selection { Some(ix) if ix + 1 < self.contents.entries.len() => ix + 1, None if !self.contents.entries.is_empty() => 0, _ => return, }; self.selection = Some(next); self.list_state.scroll_to_reveal_item(next); cx.notify(); } fn select_previous( &mut self, _: &SelectPrevious, _window: &mut Window, cx: &mut Context, ) { let prev = match self.selection { Some(ix) if ix > 0 => ix - 1, None if !self.contents.entries.is_empty() => self.contents.entries.len() - 1, _ => return, }; self.selection = Some(prev); self.list_state.scroll_to_reveal_item(prev); cx.notify(); } fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context) { if !self.contents.entries.is_empty() { self.selection = Some(0); self.list_state.scroll_to_reveal_item(0); cx.notify(); } } fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context) { if let Some(last) = self.contents.entries.len().checked_sub(1) { self.selection = Some(last); self.list_state.scroll_to_reveal_item(last); cx.notify(); } } fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { let Some(ix) = self.selection else { return }; let Some(entry) = self.contents.entries.get(ix) else { return; }; match entry { ListEntry::ProjectHeader { workspace, .. } => { let workspace = workspace.clone(); self.activate_workspace(&workspace, window, cx); } ListEntry::Thread(thread) => { let session_info = thread.session_info.clone(); match &thread.workspace { ThreadEntryWorkspace::Open(workspace) => { let workspace = workspace.clone(); self.activate_thread( thread.agent.clone(), session_info, &workspace, window, cx, ); } ThreadEntryWorkspace::Closed(path_list) => { self.open_workspace_and_activate_thread( thread.agent.clone(), session_info, path_list.clone(), window, cx, ); } } } ListEntry::ViewMore { path_list, is_fully_expanded, .. } => { let path_list = path_list.clone(); if *is_fully_expanded { self.expanded_groups.remove(&path_list); } else { let current = self.expanded_groups.get(&path_list).copied().unwrap_or(0); self.expanded_groups.insert(path_list, current + 1); } self.update_entries(false, cx); } ListEntry::NewThread { workspace, .. } => { let workspace = workspace.clone(); self.create_new_thread(&workspace, window, cx); } } } fn activate_thread( &mut self, agent: Agent, session_info: acp_thread::AgentSessionInfo, workspace: &Entity, window: &mut Window, cx: &mut Context, ) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; multi_workspace.update(cx, |multi_workspace, cx| { multi_workspace.activate(workspace.clone(), cx); }); workspace.update(cx, |workspace, cx| { workspace.open_panel::(window, cx); }); if let Some(agent_panel) = workspace.read(cx).panel::(cx) { agent_panel.update(cx, |panel, cx| { panel.load_agent_thread( agent, session_info.session_id, session_info.work_dirs, session_info.title, true, window, cx, ); }); } self.update_entries(false, cx); } fn open_workspace_and_activate_thread( &mut self, agent: Agent, session_info: acp_thread::AgentSessionInfo, path_list: PathList, window: &mut Window, cx: &mut Context, ) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; let paths: Vec = path_list.paths().iter().map(|p| p.to_path_buf()).collect(); let open_task = multi_workspace.update(cx, |mw, cx| mw.open_project(paths, window, cx)); cx.spawn_in(window, async move |this, cx| { let workspace = open_task.await?; this.update_in(cx, |this, window, cx| { this.activate_thread(agent, session_info, &workspace, window, cx); })?; anyhow::Ok(()) }) .detach_and_log_err(cx); } fn find_open_workspace_for_path_list( &self, path_list: &PathList, cx: &App, ) -> Option> { let multi_workspace = self.multi_workspace.upgrade()?; multi_workspace .read(cx) .workspaces() .iter() .find(|workspace| workspace_path_list(workspace, cx).paths() == path_list.paths()) .cloned() } fn activate_archived_thread( &mut self, agent: Agent, session_info: acp_thread::AgentSessionInfo, window: &mut Window, cx: &mut Context, ) { if let Some(path_list) = &session_info.work_dirs { if let Some(workspace) = self.find_open_workspace_for_path_list(&path_list, cx) { self.activate_thread(agent, session_info, &workspace, window, cx); } else { let path_list = path_list.clone(); self.open_workspace_and_activate_thread(agent, session_info, path_list, window, cx); } return; } let active_workspace = self.multi_workspace.upgrade().and_then(|w| { w.read(cx) .workspaces() .get(w.read(cx).active_workspace_index()) .cloned() }); if let Some(workspace) = active_workspace { self.activate_thread(agent, session_info, &workspace, window, cx); } } fn expand_selected_entry( &mut self, _: &ExpandSelectedEntry, _window: &mut Window, cx: &mut Context, ) { let Some(ix) = self.selection else { return }; match self.contents.entries.get(ix) { Some(ListEntry::ProjectHeader { path_list, .. }) => { if self.collapsed_groups.contains(path_list) { let path_list = path_list.clone(); self.collapsed_groups.remove(&path_list); self.update_entries(false, cx); } else if ix + 1 < self.contents.entries.len() { self.selection = Some(ix + 1); self.list_state.scroll_to_reveal_item(ix + 1); cx.notify(); } } _ => {} } } fn collapse_selected_entry( &mut self, _: &CollapseSelectedEntry, _window: &mut Window, cx: &mut Context, ) { let Some(ix) = self.selection else { return }; match self.contents.entries.get(ix) { Some(ListEntry::ProjectHeader { path_list, .. }) => { if !self.collapsed_groups.contains(path_list) { let path_list = path_list.clone(); self.collapsed_groups.insert(path_list); self.update_entries(false, cx); } } Some( ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. }, ) => { for i in (0..ix).rev() { if let Some(ListEntry::ProjectHeader { path_list, .. }) = self.contents.entries.get(i) { let path_list = path_list.clone(); self.selection = Some(i); self.collapsed_groups.insert(path_list); self.update_entries(false, cx); break; } } } None => {} } } fn delete_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context) { let Some(thread_store) = ThreadStore::try_global(cx) else { return; }; thread_store.update(cx, |store, cx| { store .delete_thread(session_id.clone(), cx) .detach_and_log_err(cx); }); ThreadMetadataStore::global(cx) .update(cx, |store, cx| store.delete(session_id.clone(), cx)) .detach_and_log_err(cx); } fn remove_selected_thread( &mut self, _: &RemoveSelectedThread, _window: &mut Window, cx: &mut Context, ) { let Some(ix) = self.selection else { return; }; let Some(ListEntry::Thread(thread)) = self.contents.entries.get(ix) else { return; }; if thread.agent != Agent::NativeAgent { return; } let session_id = thread.session_info.session_id.clone(); self.delete_thread(&session_id, cx); } fn render_thread( &self, ix: usize, thread: &ThreadEntry, is_focused: bool, docked_right: bool, cx: &mut Context, ) -> AnyElement { let has_notification = self .contents .is_thread_notified(&thread.session_info.session_id); let title: SharedString = thread .session_info .title .clone() .unwrap_or_else(|| "Untitled".into()); let session_info = thread.session_info.clone(); let thread_workspace = thread.workspace.clone(); let is_hovered = self.hovered_thread_index == Some(ix); let is_selected = self.focused_thread.as_ref() == Some(&session_info.session_id); let can_delete = thread.agent == Agent::NativeAgent; let session_id_for_delete = thread.session_info.session_id.clone(); let focus_handle = self.focus_handle.clone(); let id = SharedString::from(format!("thread-entry-{}", ix)); let timestamp = thread .session_info .created_at .or(thread.session_info.updated_at) .map(|entry_time| { let now = Utc::now(); let duration = now.signed_duration_since(entry_time); let minutes = duration.num_minutes(); let hours = duration.num_hours(); let days = duration.num_days(); let weeks = days / 7; let months = days / 30; if minutes < 60 { format!("{}m", minutes.max(1)) } else if hours < 24 { format!("{}h", hours) } else if weeks < 4 { format!("{}w", weeks.max(1)) } else { format!("{}mo", months.max(1)) } }); ThreadItem::new(id, title) .icon(thread.icon) .when_some(thread.icon_from_external_svg.clone(), |this, svg| { this.custom_icon_from_external_svg(svg) }) .when_some(thread.worktree_name.clone(), |this, name| { this.worktree(name) }) .worktree_highlight_positions(thread.worktree_highlight_positions.clone()) .when_some(timestamp, |this, ts| this.timestamp(ts)) .highlight_positions(thread.highlight_positions.to_vec()) .status(thread.status) .generating_title(thread.is_title_generating) .notified(has_notification) .when(thread.diff_stats.lines_added > 0, |this| { this.added(thread.diff_stats.lines_added as usize) }) .when(thread.diff_stats.lines_removed > 0, |this| { this.removed(thread.diff_stats.lines_removed as usize) }) .selected(is_selected) .focused(is_focused) .docked_right(docked_right) .hovered(is_hovered) .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| { if *is_hovered { this.hovered_thread_index = Some(ix); } else if this.hovered_thread_index == Some(ix) { this.hovered_thread_index = None; } cx.notify(); })) .when(is_hovered && can_delete, |this| { this.action_slot( IconButton::new("delete-thread", IconName::Trash) .icon_size(IconSize::Small) .icon_color(Color::Muted) .tooltip({ let focus_handle = focus_handle.clone(); move |_window, cx| { Tooltip::for_action_in( "Delete Thread", &RemoveSelectedThread, &focus_handle, cx, ) } }) .on_click({ let session_id = session_id_for_delete.clone(); cx.listener(move |this, _, _window, cx| { this.delete_thread(&session_id, cx); cx.stop_propagation(); }) }), ) }) .on_click({ let agent = thread.agent.clone(); cx.listener(move |this, _, window, cx| { this.selection = None; match &thread_workspace { ThreadEntryWorkspace::Open(workspace) => { this.activate_thread( agent.clone(), session_info.clone(), workspace, window, cx, ); } ThreadEntryWorkspace::Closed(path_list) => { this.open_workspace_and_activate_thread( agent.clone(), session_info.clone(), path_list.clone(), window, cx, ); } } }) }) .into_any_element() } fn render_filter_input(&self) -> impl IntoElement { self.filter_editor.clone() } fn render_view_more( &self, ix: usize, path_list: &PathList, remaining_count: usize, is_fully_expanded: bool, is_selected: bool, cx: &mut Context, ) -> AnyElement { let path_list = path_list.clone(); let id = SharedString::from(format!("view-more-{}", ix)); let (icon, label) = if is_fully_expanded { (IconName::ListCollapse, "Collapse") } else { (IconName::Plus, "View More") }; ListItem::new(id) .focused(is_selected) .child( h_flex() .py_1() .gap_1p5() .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)) .child(Label::new(label).color(Color::Muted)) .when(!is_fully_expanded, |this| { this.child( Label::new(format!("({})", remaining_count)) .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.5))), ) }), ) .on_click(cx.listener(move |this, _, _window, cx| { this.selection = None; if is_fully_expanded { this.expanded_groups.remove(&path_list); } else { let current = this.expanded_groups.get(&path_list).copied().unwrap_or(0); this.expanded_groups.insert(path_list.clone(), current + 1); } this.update_entries(false, cx); })) .into_any_element() } fn create_new_thread( &mut self, workspace: &Entity, window: &mut Window, cx: &mut Context, ) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; multi_workspace.update(cx, |multi_workspace, cx| { multi_workspace.activate(workspace.clone(), cx); }); workspace.update(cx, |workspace, cx| { if let Some(agent_panel) = workspace.panel::(cx) { agent_panel.update(cx, |panel, cx| { panel.new_thread(&NewThread, window, cx); }); } workspace.focus_panel::(window, cx); }); } fn render_new_thread( &self, ix: usize, _path_list: &PathList, workspace: &Entity, is_selected: bool, cx: &mut Context, ) -> AnyElement { let workspace = workspace.clone(); div() .w_full() .p_2() .pt_1p5() .child( Button::new( SharedString::from(format!("new-thread-btn-{}", ix)), "New Thread", ) .full_width() .style(ButtonStyle::Outlined) .start_icon( Icon::new(IconName::Plus) .size(IconSize::Small) .color(Color::Muted), ) .toggle_state(is_selected) .on_click(cx.listener(move |this, _, window, cx| { this.selection = None; this.create_new_thread(&workspace, window, cx); })), ) .into_any_element() } fn render_thread_list_header( &self, docked_right: bool, cx: &mut Context, ) -> impl IntoElement { let has_query = self.has_filter_query(cx); h_flex() .h(Tab::container_height(cx)) .flex_none() .gap_1p5() .border_b_1() .border_color(cx.theme().colors().border) .when(!docked_right, |this| { this.child(self.render_sidebar_toggle_button(false, cx)) }) .child(self.render_filter_input()) .child( h_flex() .gap_0p5() .when(!docked_right, |this| this.pr_1p5()) .when(has_query, |this| { this.child( IconButton::new("clear_filter", IconName::Close) .shape(IconButtonShape::Square) .tooltip(Tooltip::text("Clear Search")) .on_click(cx.listener(|this, _, window, cx| { this.reset_filter_editor_text(window, cx); this.update_entries(false, cx); })), ) }) .child( IconButton::new("archive", IconName::Archive) .icon_size(IconSize::Small) .tooltip(Tooltip::text("Archive")) .on_click(cx.listener(|this, _, window, cx| { this.show_archive(window, cx); })), ), ) .when(docked_right, |this| { this.pl_2() .pr_0p5() .child(self.render_sidebar_toggle_button(true, cx)) }) } fn render_sidebar_toggle_button( &self, docked_right: bool, cx: &mut Context, ) -> impl IntoElement { let icon = if docked_right { IconName::ThreadsSidebarRightOpen } else { IconName::ThreadsSidebarLeftOpen }; h_flex() .h_full() .px_1() .map(|this| { if docked_right { this.pr_1p5().border_l_1() } else { this.border_r_1() } }) .border_color(cx.theme().colors().border_variant) .child( IconButton::new("sidebar-close-toggle", icon) .icon_size(IconSize::Small) .tooltip(move |_, cx| { Tooltip::for_action("Close Threads Sidebar", &ToggleWorkspaceSidebar, cx) }) .on_click(|_, window, cx| { window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx); }), ) } } impl Sidebar { pub fn is_open(&self) -> bool { self.is_open } fn show_archive(&mut self, window: &mut Window, cx: &mut Context) { let Some(active_workspace) = self.multi_workspace.upgrade().and_then(|w| { w.read(cx) .workspaces() .get(w.read(cx).active_workspace_index()) .cloned() }) else { return; }; let Some(agent_panel) = active_workspace.read(cx).panel::(cx) else { return; }; let thread_store = agent_panel.read(cx).thread_store().clone(); let fs = active_workspace.read(cx).project().read(cx).fs().clone(); let agent_connection_store = agent_panel.read(cx).connection_store().clone(); let agent_server_store = active_workspace .read(cx) .project() .read(cx) .agent_server_store() .clone(); let archive_view = cx.new(|cx| { ThreadsArchiveView::new( agent_connection_store, agent_server_store, thread_store, fs, window, cx, ) }); let subscription = cx.subscribe_in( &archive_view, window, |this, _, event: &ThreadsArchiveViewEvent, window, cx| match event { ThreadsArchiveViewEvent::Close => { this.show_thread_list(window, cx); } ThreadsArchiveViewEvent::OpenThread { agent, session_info, } => { this.show_thread_list(window, cx); this.activate_archived_thread(agent.clone(), session_info.clone(), window, cx); } }, ); self._subscriptions.push(subscription); self.archive_view = Some(archive_view); self.view = SidebarView::Archive; cx.notify(); } fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context) { self.view = SidebarView::ThreadList; self.archive_view = None; self._subscriptions.clear(); window.focus(&self.focus_handle, cx); cx.notify(); } pub fn set_open(&mut self, open: bool, cx: &mut Context) { if self.is_open == open { return; } self.is_open = open; cx.notify(); if let Some(key) = self.persistence_key { let is_open = self.is_open; cx.background_spawn(async move { save_sidebar_open_state(key, is_open).await; }) .detach(); } } pub fn toggle(&mut self, window: &mut Window, cx: &mut Context) { let new_state = !self.is_open; self.set_open(new_state, cx); if new_state { cx.focus_self(window); } } pub fn focus_or_unfocus( &mut self, workspace: &mut Workspace, window: &mut Window, cx: &mut Context, ) { if self.is_open { let sidebar_is_focused = self.focus_handle(cx).contains_focused(window, cx); if sidebar_is_focused { let active_pane = workspace.active_pane().clone(); let pane_focus = active_pane.read(cx).focus_handle(cx); window.focus(&pane_focus, cx); } else { cx.focus_self(window); } } else { self.set_open(true, cx); cx.focus_self(window); } } pub fn width(&self, _cx: &App) -> Pixels { self.width } pub fn set_width(&mut self, width: Option, cx: &mut Context) { self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH); cx.notify(); } pub fn has_notifications(&self, _cx: &App) -> bool { !self.contents.notified_threads.is_empty() } } impl Focusable for Sidebar { fn focus_handle(&self, cx: &App) -> FocusHandle { self.filter_editor.focus_handle(cx) } } impl Render for Sidebar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let ui_font = theme::setup_ui_font(window, cx); let docked_right = AgentSettings::get_global(cx).dock == settings::DockPosition::Right; let sticky_header = self.render_sticky_header(docked_right, window, cx); v_flex() .id("workspace-sidebar") .key_context("ThreadsSidebar") .track_focus(&self.focus_handle) .on_action(cx.listener(Self::select_next)) .on_action(cx.listener(Self::select_previous)) .on_action(cx.listener(Self::editor_move_down)) .on_action(cx.listener(Self::editor_move_up)) .on_action(cx.listener(Self::select_first)) .on_action(cx.listener(Self::select_last)) .on_action(cx.listener(Self::confirm)) .on_action(cx.listener(Self::expand_selected_entry)) .on_action(cx.listener(Self::collapse_selected_entry)) .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::remove_selected_thread)) .font(ui_font) .size_full() .bg(cx.theme().colors().surface_background) .map(|this| match self.view { SidebarView::ThreadList => this .child(self.render_thread_list_header(docked_right, cx)) .child( v_flex() .relative() .flex_1() .overflow_hidden() .child( list( self.list_state.clone(), cx.processor(Self::render_list_entry), ) .flex_1() .size_full(), ) .when_some(sticky_header, |this, header| this.child(header)) .vertical_scrollbar_for(&self.list_state, window, cx), ), SidebarView::Archive => { if let Some(archive_view) = &self.archive_view { this.child(archive_view.clone()) } else { this } } }) } } #[cfg(test)] mod tests { use super::*; use crate::test_support::{active_session_id, open_thread_with_connection, send_message}; use acp_thread::StubAgentConnection; use agent::ThreadStore; use assistant_text_thread::TextThreadStore; use chrono::DateTime; use feature_flags::FeatureFlagAppExt as _; use fs::FakeFs; use gpui::TestAppContext; use pretty_assertions::assert_eq; use std::{path::PathBuf, sync::Arc}; use util::path_list::PathList; fn init_test(cx: &mut TestAppContext) { crate::test_support::init_test(cx); cx.update(|cx| { cx.update_flags(false, vec!["agent-v2".into()]); ThreadStore::init_global(cx); ThreadMetadataStore::init_global(cx); language_model::LanguageModelRegistry::test(cx); prompt_store::init(cx); }); } async fn init_test_project( worktree_path: &str, cx: &mut TestAppContext, ) -> Entity { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree(worktree_path, serde_json::json!({ "src": {} })) .await; cx.update(|cx| ::set_global(fs.clone(), cx)); project::Project::test(fs, [worktree_path.as_ref()], cx).await } fn setup_sidebar( multi_workspace: &Entity, cx: &mut gpui::VisualTestContext, ) -> Entity { let (sidebar, _panel) = setup_sidebar_with_agent_panel(multi_workspace, cx); sidebar } fn setup_sidebar_with_agent_panel( multi_workspace: &Entity, cx: &mut gpui::VisualTestContext, ) -> (Entity, Entity) { let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); let project = workspace.read_with(cx, |ws, _cx| ws.project().clone()); let panel = add_agent_panel(&workspace, &project, cx); workspace.update_in(cx, |workspace, window, cx| { workspace.right_dock().update(cx, |dock, cx| { if let Some(panel_ix) = dock.panel_index_for_type::() { dock.activate_panel(panel_ix, window, cx); } dock.set_open(true, window, cx); }); }); cx.run_until_parked(); let sidebar = panel.read_with(cx, |panel, _cx| { panel .sidebar .clone() .expect("AgentPanel should have created a sidebar") }); (sidebar, panel) } async fn save_n_test_threads( count: u32, path_list: &PathList, cx: &mut gpui::VisualTestContext, ) { for i in 0..count { save_thread_metadata( acp::SessionId::new(Arc::from(format!("thread-{}", i))), format!("Thread {}", i + 1).into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), path_list.clone(), cx, ) .await; } cx.run_until_parked(); } async fn save_test_thread_metadata( session_id: &acp::SessionId, path_list: PathList, cx: &mut TestAppContext, ) { save_thread_metadata( session_id.clone(), "Test".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), path_list, cx, ) .await; } async fn save_named_thread_metadata( session_id: &str, title: &str, path_list: &PathList, cx: &mut gpui::VisualTestContext, ) { save_thread_metadata( acp::SessionId::new(Arc::from(session_id)), SharedString::from(title.to_string()), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), path_list.clone(), cx, ) .await; cx.run_until_parked(); } async fn save_thread_metadata( session_id: acp::SessionId, title: SharedString, updated_at: DateTime, path_list: PathList, cx: &mut TestAppContext, ) { let metadata = ThreadMetadata { session_id, agent_id: None, title, updated_at, created_at: None, folder_paths: path_list, }; let task = cx.update(|cx| { ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)) }); task.await.unwrap(); } fn open_and_focus_sidebar(sidebar: &Entity, cx: &mut gpui::VisualTestContext) { cx.run_until_parked(); sidebar.update_in(cx, |sidebar, window, cx| { sidebar.set_open(true, cx); cx.focus_self(window); }); cx.run_until_parked(); } fn visible_entries_as_strings( sidebar: &Entity, cx: &mut gpui::VisualTestContext, ) -> Vec { sidebar.read_with(cx, |sidebar, _cx| { sidebar .contents .entries .iter() .enumerate() .map(|(ix, entry)| { let selected = if sidebar.selection == Some(ix) { " <== selected" } else { "" }; match entry { ListEntry::ProjectHeader { label, path_list, highlight_positions: _, .. } => { let icon = if sidebar.collapsed_groups.contains(path_list) { ">" } else { "v" }; format!("{} [{}]{}", icon, label, selected) } ListEntry::Thread(thread) => { let title = thread .session_info .title .as_ref() .map(|s| s.as_ref()) .unwrap_or("Untitled"); let active = if thread.is_live { " *" } else { "" }; let status_str = match thread.status { AgentThreadStatus::Running => " (running)", AgentThreadStatus::Error => " (error)", AgentThreadStatus::WaitingForConfirmation => " (waiting)", _ => "", }; let notified = if sidebar .contents .is_thread_notified(&thread.session_info.session_id) { " (!)" } else { "" }; let worktree = thread .worktree_name .as_ref() .map(|name| format!(" {{{}}}", name)) .unwrap_or_default(); format!( " {}{}{}{}{}{}", title, worktree, active, status_str, notified, selected ) } ListEntry::ViewMore { remaining_count, is_fully_expanded, .. } => { if *is_fully_expanded { format!(" - Collapse{}", selected) } else { format!(" + View More ({}){}", remaining_count, selected) } } ListEntry::NewThread { .. } => { format!(" [+ New Thread]{}", selected) } } }) .collect() }) } #[gpui::test] async fn test_single_workspace_no_threads(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["v [my-project]", " [+ New Thread]"] ); } #[gpui::test] async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); save_thread_metadata( acp::SessionId::new(Arc::from("thread-1")), "Fix crash in project panel".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), path_list.clone(), cx, ) .await; save_thread_metadata( acp::SessionId::new(Arc::from("thread-2")), "Add inline diff view".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), path_list.clone(), cx, ) .await; cx.run_until_parked(); multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [my-project]", " Fix crash in project panel", " Add inline diff view", ] ); } #[gpui::test] async fn test_workspace_lifecycle(cx: &mut TestAppContext) { let project = init_test_project("/project-a", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); // Single workspace with a thread let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]); save_thread_metadata( acp::SessionId::new(Arc::from("thread-a1")), "Thread A1".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), path_list.clone(), cx, ) .await; cx.run_until_parked(); multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["v [project-a]", " Thread A1"] ); // Add a second workspace multi_workspace.update_in(cx, |mw, window, cx| { mw.create_workspace(window, cx); }); cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [project-a]", " Thread A1", "v [Empty Workspace]", " [+ New Thread]" ] ); // Remove the second workspace multi_workspace.update_in(cx, |mw, window, cx| { mw.remove_workspace(1, window, cx); }); cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["v [project-a]", " Thread A1"] ); } #[gpui::test] async fn test_view_more_pagination(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); save_n_test_threads(12, &path_list, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [my-project]", " Thread 12", " Thread 11", " Thread 10", " Thread 9", " Thread 8", " + View More (7)", ] ); } #[gpui::test] async fn test_view_more_batched_expansion(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse save_n_test_threads(17, &path_list, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); // Initially shows 5 threads + View More (12 remaining) let entries = visible_entries_as_strings(&sidebar, cx); assert_eq!(entries.len(), 7); // header + 5 threads + View More assert!(entries.iter().any(|e| e.contains("View More (12)"))); // Focus and navigate to View More, then confirm to expand by one batch open_and_focus_sidebar(&sidebar, cx); for _ in 0..7 { cx.dispatch_action(SelectNext); } cx.dispatch_action(Confirm); cx.run_until_parked(); // Now shows 10 threads + View More (7 remaining) let entries = visible_entries_as_strings(&sidebar, cx); assert_eq!(entries.len(), 12); // header + 10 threads + View More assert!(entries.iter().any(|e| e.contains("View More (7)"))); // Expand again by one batch sidebar.update_in(cx, |s, _window, cx| { let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0); s.expanded_groups.insert(path_list.clone(), current + 1); s.update_entries(false, cx); }); cx.run_until_parked(); // Now shows 15 threads + View More (2 remaining) let entries = visible_entries_as_strings(&sidebar, cx); assert_eq!(entries.len(), 17); // header + 15 threads + View More assert!(entries.iter().any(|e| e.contains("View More (2)"))); // Expand one more time - should show all 17 threads with Collapse button sidebar.update_in(cx, |s, _window, cx| { let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0); s.expanded_groups.insert(path_list.clone(), current + 1); s.update_entries(false, cx); }); cx.run_until_parked(); // All 17 threads shown with Collapse button let entries = visible_entries_as_strings(&sidebar, cx); assert_eq!(entries.len(), 19); // header + 17 threads + Collapse assert!(!entries.iter().any(|e| e.contains("View More"))); assert!(entries.iter().any(|e| e.contains("Collapse"))); // Click collapse - should go back to showing 5 threads sidebar.update_in(cx, |s, _window, cx| { s.expanded_groups.remove(&path_list); s.update_entries(false, cx); }); cx.run_until_parked(); // Back to initial state: 5 threads + View More (12 remaining) let entries = visible_entries_as_strings(&sidebar, cx); assert_eq!(entries.len(), 7); // header + 5 threads + View More assert!(entries.iter().any(|e| e.contains("View More (12)"))); } #[gpui::test] async fn test_collapse_and_expand_group(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); save_n_test_threads(1, &path_list, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["v [my-project]", " Thread 1"] ); // Collapse sidebar.update_in(cx, |s, window, cx| { s.toggle_collapse(&path_list, window, cx); }); cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["> [my-project]"] ); // Expand sidebar.update_in(cx, |s, window, cx| { s.toggle_collapse(&path_list, window, cx); }); cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["v [my-project]", " Thread 1"] ); } #[gpui::test] async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]); let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]); sidebar.update_in(cx, |s, _window, _cx| { s.collapsed_groups.insert(collapsed_path.clone()); s.contents .notified_threads .insert(acp::SessionId::new(Arc::from("t-5"))); s.contents.entries = vec![ // Expanded project header ListEntry::ProjectHeader { path_list: expanded_path.clone(), label: "expanded-project".into(), workspace: workspace.clone(), highlight_positions: Vec::new(), has_threads: true, }, // Thread with default (Completed) status, not active ListEntry::Thread(ThreadEntry { agent: Agent::NativeAgent, session_info: acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("t-1")), work_dirs: None, title: Some("Completed thread".into()), updated_at: Some(Utc::now()), created_at: Some(Utc::now()), meta: None, }, icon: IconName::ZedAgent, icon_from_external_svg: None, status: AgentThreadStatus::Completed, workspace: ThreadEntryWorkspace::Open(workspace.clone()), is_live: false, is_background: false, is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }), // Active thread with Running status ListEntry::Thread(ThreadEntry { agent: Agent::NativeAgent, session_info: acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("t-2")), work_dirs: None, title: Some("Running thread".into()), updated_at: Some(Utc::now()), created_at: Some(Utc::now()), meta: None, }, icon: IconName::ZedAgent, icon_from_external_svg: None, status: AgentThreadStatus::Running, workspace: ThreadEntryWorkspace::Open(workspace.clone()), is_live: true, is_background: false, is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }), // Active thread with Error status ListEntry::Thread(ThreadEntry { agent: Agent::NativeAgent, session_info: acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("t-3")), work_dirs: None, title: Some("Error thread".into()), updated_at: Some(Utc::now()), created_at: Some(Utc::now()), meta: None, }, icon: IconName::ZedAgent, icon_from_external_svg: None, status: AgentThreadStatus::Error, workspace: ThreadEntryWorkspace::Open(workspace.clone()), is_live: true, is_background: false, is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }), // Thread with WaitingForConfirmation status, not active ListEntry::Thread(ThreadEntry { agent: Agent::NativeAgent, session_info: acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("t-4")), work_dirs: None, title: Some("Waiting thread".into()), updated_at: Some(Utc::now()), created_at: Some(Utc::now()), meta: None, }, icon: IconName::ZedAgent, icon_from_external_svg: None, status: AgentThreadStatus::WaitingForConfirmation, workspace: ThreadEntryWorkspace::Open(workspace.clone()), is_live: false, is_background: false, is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }), // Background thread that completed (should show notification) ListEntry::Thread(ThreadEntry { agent: Agent::NativeAgent, session_info: acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("t-5")), work_dirs: None, title: Some("Notified thread".into()), updated_at: Some(Utc::now()), created_at: Some(Utc::now()), meta: None, }, icon: IconName::ZedAgent, icon_from_external_svg: None, status: AgentThreadStatus::Completed, workspace: ThreadEntryWorkspace::Open(workspace.clone()), is_live: true, is_background: true, is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }), // View More entry ListEntry::ViewMore { path_list: expanded_path.clone(), remaining_count: 42, is_fully_expanded: false, }, // Collapsed project header ListEntry::ProjectHeader { path_list: collapsed_path.clone(), label: "collapsed-project".into(), workspace: workspace.clone(), highlight_positions: Vec::new(), has_threads: true, }, ]; // Select the Running thread (index 2) s.selection = Some(2); }); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [expanded-project]", " Completed thread", " Running thread * (running) <== selected", " Error thread * (error)", " Waiting thread (waiting)", " Notified thread * (!)", " + View More (42)", "> [collapsed-project]", ] ); // Move selection to the collapsed header sidebar.update_in(cx, |s, _window, _cx| { s.selection = Some(7); }); assert_eq!( visible_entries_as_strings(&sidebar, cx).last().cloned(), Some("> [collapsed-project] <== selected".to_string()), ); // Clear selection sidebar.update_in(cx, |s, _window, _cx| { s.selection = None; }); // No entry should have the selected marker let entries = visible_entries_as_strings(&sidebar, cx); for entry in &entries { assert!( !entry.contains("<== selected"), "unexpected selection marker in: {}", entry ); } } #[gpui::test] async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); save_n_test_threads(3, &path_list, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); // Entries: [header, thread3, thread2, thread1] // Focusing the sidebar does not set a selection; select_next/select_previous // handle None gracefully by starting from the first or last entry. open_and_focus_sidebar(&sidebar, cx); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); // First SelectNext from None starts at index 0 cx.dispatch_action(SelectNext); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); // Move down through remaining entries cx.dispatch_action(SelectNext); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); cx.dispatch_action(SelectNext); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); cx.dispatch_action(SelectNext); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); // At the end, selection stays on the last entry cx.dispatch_action(SelectNext); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); // Move back up cx.dispatch_action(SelectPrevious); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); cx.dispatch_action(SelectPrevious); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); cx.dispatch_action(SelectPrevious); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); // At the top, selection stays on the first entry cx.dispatch_action(SelectPrevious); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); } #[gpui::test] async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); save_n_test_threads(3, &path_list, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); open_and_focus_sidebar(&sidebar, cx); // SelectLast jumps to the end cx.dispatch_action(SelectLast); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); // SelectFirst jumps to the beginning cx.dispatch_action(SelectFirst); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); } #[gpui::test] async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); // Initially no selection assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); // Open the sidebar so it's rendered, then focus it to trigger focus_in. // focus_in no longer sets a default selection. open_and_focus_sidebar(&sidebar, cx); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); // Manually set a selection, blur, then refocus — selection should be preserved sidebar.update_in(cx, |sidebar, _window, _cx| { sidebar.selection = Some(0); }); cx.update(|window, _cx| { window.blur(); }); cx.run_until_parked(); sidebar.update_in(cx, |_, window, cx| { cx.focus_self(window); }); cx.run_until_parked(); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); } #[gpui::test] async fn test_keyboard_confirm_on_project_header_activates_workspace(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); multi_workspace.update_in(cx, |mw, window, cx| { mw.create_workspace(window, cx); }); cx.run_until_parked(); // Add an agent panel to workspace 1 so the sidebar renders when it's active. setup_sidebar_with_agent_panel(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); save_n_test_threads(1, &path_list, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [my-project]", " Thread 1", "v [Empty Workspace]", " [+ New Thread]", ] ); // Switch to workspace 1 so we can verify confirm switches back. multi_workspace.update_in(cx, |mw, window, cx| { mw.activate_index(1, window, cx); }); cx.run_until_parked(); assert_eq!( multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), 1 ); // Focus the sidebar and manually select the header (index 0) open_and_focus_sidebar(&sidebar, cx); sidebar.update_in(cx, |sidebar, _window, _cx| { sidebar.selection = Some(0); }); // Press confirm on project header (workspace 0) to activate it. cx.dispatch_action(Confirm); cx.run_until_parked(); assert_eq!( multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), 0 ); // Focus should have moved out of the sidebar to the workspace center. let workspace_0 = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone()); workspace_0.update_in(cx, |workspace, window, cx| { let pane_focus = workspace.active_pane().read(cx).focus_handle(cx); assert!( pane_focus.contains_focused(window, cx), "Confirming a project header should focus the workspace center pane" ); }); } #[gpui::test] async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); save_n_test_threads(8, &path_list, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); // Should show header + 5 threads + "View More (3)" let entries = visible_entries_as_strings(&sidebar, cx); assert_eq!(entries.len(), 7); assert!(entries.iter().any(|e| e.contains("View More (3)"))); // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6) open_and_focus_sidebar(&sidebar, cx); for _ in 0..7 { cx.dispatch_action(SelectNext); } assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6)); // Confirm on "View More" to expand cx.dispatch_action(Confirm); cx.run_until_parked(); // All 8 threads should now be visible with a "Collapse" button let entries = visible_entries_as_strings(&sidebar, cx); assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button assert!(!entries.iter().any(|e| e.contains("View More"))); assert!(entries.iter().any(|e| e.contains("Collapse"))); } #[gpui::test] async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); save_n_test_threads(1, &path_list, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["v [my-project]", " Thread 1"] ); // Focus sidebar and manually select the header (index 0). Press left to collapse. open_and_focus_sidebar(&sidebar, cx); sidebar.update_in(cx, |sidebar, _window, _cx| { sidebar.selection = Some(0); }); cx.dispatch_action(CollapseSelectedEntry); cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["> [my-project] <== selected"] ); // Press right to expand cx.dispatch_action(ExpandSelectedEntry); cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["v [my-project] <== selected", " Thread 1",] ); // Press right again on already-expanded header moves selection down cx.dispatch_action(ExpandSelectedEntry); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); } #[gpui::test] async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); save_n_test_threads(1, &path_list, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); // Focus sidebar (selection starts at None), then navigate down to the thread (child) open_and_focus_sidebar(&sidebar, cx); cx.dispatch_action(SelectNext); cx.dispatch_action(SelectNext); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["v [my-project]", " Thread 1 <== selected",] ); // Pressing left on a child collapses the parent group and selects it cx.dispatch_action(CollapseSelectedEntry); cx.run_until_parked(); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["> [my-project] <== selected"] ); } #[gpui::test] async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) { let project = init_test_project("/empty-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); // Even an empty project has the header and a new thread button assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["v [empty-project]", " [+ New Thread]"] ); // Focus sidebar — focus_in does not set a selection open_and_focus_sidebar(&sidebar, cx); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); // First SelectNext from None starts at index 0 (header) cx.dispatch_action(SelectNext); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); // SelectNext moves to the new thread button cx.dispatch_action(SelectNext); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); // At the end, selection stays on the last entry cx.dispatch_action(SelectNext); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); // SelectPrevious goes back to the header cx.dispatch_action(SelectPrevious); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); } #[gpui::test] async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); save_n_test_threads(1, &path_list, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); // Focus sidebar (selection starts at None), navigate down to the thread (index 1) open_and_focus_sidebar(&sidebar, cx); cx.dispatch_action(SelectNext); cx.dispatch_action(SelectNext); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); // Collapse the group, which removes the thread from the list cx.dispatch_action(CollapseSelectedEntry); cx.run_until_parked(); // Selection should be clamped to the last valid index (0 = header) let selection = sidebar.read_with(cx, |s, _| s.selection); let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len()); assert!( selection.unwrap_or(0) < entry_count, "selection {} should be within bounds (entries: {})", selection.unwrap_or(0), entry_count, ); } fn add_agent_panel( workspace: &Entity, project: &Entity, cx: &mut gpui::VisualTestContext, ) -> Entity { workspace.update_in(cx, |workspace, window, cx| { let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); let panel = cx.new(|cx| AgentPanel::test_new(workspace, text_thread_store, window, cx)); workspace.add_panel(panel.clone(), window, cx); panel }) } #[gpui::test] async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); // Open thread A and keep it generating. let connection = StubAgentConnection::new(); open_thread_with_connection(&panel, connection.clone(), cx); send_message(&panel, cx); let session_id_a = active_session_id(&panel, cx); save_test_thread_metadata(&session_id_a, path_list.clone(), cx).await; cx.update(|_, cx| { connection.send_update( session_id_a.clone(), acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), cx, ); }); cx.run_until_parked(); // Open thread B (idle, default response) — thread A goes to background. connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( acp::ContentChunk::new("Done".into()), )]); open_thread_with_connection(&panel, connection, cx); send_message(&panel, cx); let session_id_b = active_session_id(&panel, cx); save_test_thread_metadata(&session_id_b, path_list.clone(), cx).await; cx.run_until_parked(); let mut entries = visible_entries_as_strings(&sidebar, cx); entries[1..].sort(); assert_eq!( entries, vec!["v [my-project]", " Hello *", " Hello * (running)",] ); } #[gpui::test] async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) { let project_a = init_test_project("/project-a", cx).await; let (multi_workspace, cx) = cx .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx); let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); // Open thread on workspace A and keep it generating. let connection_a = StubAgentConnection::new(); open_thread_with_connection(&panel_a, connection_a.clone(), cx); send_message(&panel_a, cx); let session_id_a = active_session_id(&panel_a, cx); save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await; cx.update(|_, cx| { connection_a.send_update( session_id_a.clone(), acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())), cx, ); }); cx.run_until_parked(); // Add a second workspace and activate it (making workspace A the background). let fs = cx.update(|_, cx| ::global(cx)); let project_b = project::Project::test(fs, [], cx).await; multi_workspace.update_in(cx, |mw, window, cx| { mw.test_add_workspace(project_b, window, cx); }); cx.run_until_parked(); // Thread A is still running; no notification yet. assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [project-a]", " Hello * (running)", "v [Empty Workspace]", " [+ New Thread]", ] ); // Complete thread A's turn (transition Running → Completed). connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn); cx.run_until_parked(); // The completed background thread shows a notification indicator. assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [project-a]", " Hello * (!)", "v [Empty Workspace]", " [+ New Thread]", ] ); } fn type_in_search(sidebar: &Entity, query: &str, cx: &mut gpui::VisualTestContext) { sidebar.update_in(cx, |sidebar, window, cx| { window.focus(&sidebar.filter_editor.focus_handle(cx), cx); sidebar.filter_editor.update(cx, |editor, cx| { editor.set_text(query, window, cx); }); }); cx.run_until_parked(); } #[gpui::test] async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); for (id, title, hour) in [ ("t-1", "Fix crash in project panel", 3), ("t-2", "Add inline diff view", 2), ("t-3", "Refactor settings module", 1), ] { save_thread_metadata( acp::SessionId::new(Arc::from(id)), title.into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), path_list.clone(), cx, ) .await; } cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [my-project]", " Fix crash in project panel", " Add inline diff view", " Refactor settings module", ] ); // User types "diff" in the search box — only the matching thread remains, // with its workspace header preserved for context. type_in_search(&sidebar, "diff", cx); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["v [my-project]", " Add inline diff view <== selected",] ); // User changes query to something with no matches — list is empty. type_in_search(&sidebar, "nonexistent", cx); assert_eq!( visible_entries_as_strings(&sidebar, cx), Vec::::new() ); } #[gpui::test] async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) { // Scenario: A user remembers a thread title but not the exact casing. // Search should match case-insensitively so they can still find it. let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); save_thread_metadata( acp::SessionId::new(Arc::from("thread-1")), "Fix Crash In Project Panel".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), path_list.clone(), cx, ) .await; cx.run_until_parked(); // Lowercase query matches mixed-case title. type_in_search(&sidebar, "fix crash", cx); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [my-project]", " Fix Crash In Project Panel <== selected", ] ); // Uppercase query also matches the same title. type_in_search(&sidebar, "FIX CRASH", cx); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [my-project]", " Fix Crash In Project Panel <== selected", ] ); } #[gpui::test] async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) { // Scenario: A user searches, finds what they need, then presses Escape // to dismiss the filter and see the full list again. let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] { save_thread_metadata( acp::SessionId::new(Arc::from(id)), title.into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), path_list.clone(), cx, ) .await; } cx.run_until_parked(); // Confirm the full list is showing. assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["v [my-project]", " Alpha thread", " Beta thread",] ); // User types a search query to filter down. open_and_focus_sidebar(&sidebar, cx); type_in_search(&sidebar, "alpha", cx); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["v [my-project]", " Alpha thread <== selected",] ); // User presses Escape — filter clears, full list is restored. cx.dispatch_action(Cancel); cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [my-project]", " Alpha thread <== selected", " Beta thread", ] ); } #[gpui::test] async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) { let project_a = init_test_project("/project-a", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); for (id, title, hour) in [ ("a1", "Fix bug in sidebar", 2), ("a2", "Add tests for editor", 1), ] { save_thread_metadata( acp::SessionId::new(Arc::from(id)), title.into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), path_list_a.clone(), cx, ) .await; } // Add a second workspace. multi_workspace.update_in(cx, |mw, window, cx| { mw.create_workspace(window, cx); }); cx.run_until_parked(); let path_list_b = PathList::new::(&[]); for (id, title, hour) in [ ("b1", "Refactor sidebar layout", 3), ("b2", "Fix typo in README", 1), ] { save_thread_metadata( acp::SessionId::new(Arc::from(id)), title.into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), path_list_b.clone(), cx, ) .await; } cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [project-a]", " Fix bug in sidebar", " Add tests for editor", "v [Empty Workspace]", " Refactor sidebar layout", " Fix typo in README", ] ); // "sidebar" matches a thread in each workspace — both headers stay visible. type_in_search(&sidebar, "sidebar", cx); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [project-a]", " Fix bug in sidebar <== selected", "v [Empty Workspace]", " Refactor sidebar layout", ] ); // "typo" only matches in the second workspace — the first header disappears. type_in_search(&sidebar, "typo", cx); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["v [Empty Workspace]", " Fix typo in README <== selected",] ); // "project-a" matches the first workspace name — the header appears // with all child threads included. type_in_search(&sidebar, "project-a", cx); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [project-a]", " Fix bug in sidebar <== selected", " Add tests for editor", ] ); } #[gpui::test] async fn test_search_matches_workspace_name(cx: &mut TestAppContext) { let project_a = init_test_project("/alpha-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]); for (id, title, hour) in [ ("a1", "Fix bug in sidebar", 2), ("a2", "Add tests for editor", 1), ] { save_thread_metadata( acp::SessionId::new(Arc::from(id)), title.into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), path_list_a.clone(), cx, ) .await; } // Add a second workspace. multi_workspace.update_in(cx, |mw, window, cx| { mw.create_workspace(window, cx); }); cx.run_until_parked(); let path_list_b = PathList::new::(&[]); for (id, title, hour) in [ ("b1", "Refactor sidebar layout", 3), ("b2", "Fix typo in README", 1), ] { save_thread_metadata( acp::SessionId::new(Arc::from(id)), title.into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), path_list_b.clone(), cx, ) .await; } cx.run_until_parked(); // "alpha" matches the workspace name "alpha-project" but no thread titles. // The workspace header should appear with all child threads included. type_in_search(&sidebar, "alpha", cx); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [alpha-project]", " Fix bug in sidebar <== selected", " Add tests for editor", ] ); // "sidebar" matches thread titles in both workspaces but not workspace names. // Both headers appear with their matching threads. type_in_search(&sidebar, "sidebar", cx); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [alpha-project]", " Fix bug in sidebar <== selected", "v [Empty Workspace]", " Refactor sidebar layout", ] ); // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r // doesn't match) — but does not match either workspace name or any thread. // Actually let's test something simpler: a query that matches both a workspace // name AND some threads in that workspace. Matching threads should still appear. type_in_search(&sidebar, "fix", cx); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [alpha-project]", " Fix bug in sidebar <== selected", "v [Empty Workspace]", " Fix typo in README", ] ); // A query that matches a workspace name AND a thread in that same workspace. // Both the header (highlighted) and all child threads should appear. type_in_search(&sidebar, "alpha", cx); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [alpha-project]", " Fix bug in sidebar <== selected", " Add tests for editor", ] ); // Now search for something that matches only a workspace name when there // are also threads with matching titles — the non-matching workspace's // threads should still appear if their titles match. type_in_search(&sidebar, "alp", cx); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [alpha-project]", " Fix bug in sidebar <== selected", " Add tests for editor", ] ); } #[gpui::test] async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); // Create 8 threads. The oldest one has a unique name and will be // behind View More (only 5 shown by default). for i in 0..8u32 { let title = if i == 0 { "Hidden gem thread".to_string() } else { format!("Thread {}", i + 1) }; save_thread_metadata( acp::SessionId::new(Arc::from(format!("thread-{}", i))), title.into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), path_list.clone(), cx, ) .await; } cx.run_until_parked(); // Confirm the thread is not visible and View More is shown. let entries = visible_entries_as_strings(&sidebar, cx); assert!( entries.iter().any(|e| e.contains("View More")), "should have View More button" ); assert!( !entries.iter().any(|e| e.contains("Hidden gem")), "Hidden gem should be behind View More" ); // User searches for the hidden thread — it appears, and View More is gone. type_in_search(&sidebar, "hidden gem", cx); let filtered = visible_entries_as_strings(&sidebar, cx); assert_eq!( filtered, vec!["v [my-project]", " Hidden gem thread <== selected",] ); assert!( !filtered.iter().any(|e| e.contains("View More")), "View More should not appear when filtering" ); } #[gpui::test] async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); save_thread_metadata( acp::SessionId::new(Arc::from("thread-1")), "Important thread".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), path_list.clone(), cx, ) .await; cx.run_until_parked(); // User focuses the sidebar and collapses the group using keyboard: // manually select the header, then press CollapseSelectedEntry to collapse. open_and_focus_sidebar(&sidebar, cx); sidebar.update_in(cx, |sidebar, _window, _cx| { sidebar.selection = Some(0); }); cx.dispatch_action(CollapseSelectedEntry); cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["> [my-project] <== selected"] ); // User types a search — the thread appears even though its group is collapsed. type_in_search(&sidebar, "important", cx); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["> [my-project]", " Important thread <== selected",] ); } #[gpui::test] async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); for (id, title, hour) in [ ("t-1", "Fix crash in panel", 3), ("t-2", "Fix lint warnings", 2), ("t-3", "Add new feature", 1), ] { save_thread_metadata( acp::SessionId::new(Arc::from(id)), title.into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), path_list.clone(), cx, ) .await; } cx.run_until_parked(); open_and_focus_sidebar(&sidebar, cx); // User types "fix" — two threads match. type_in_search(&sidebar, "fix", cx); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [my-project]", " Fix crash in panel <== selected", " Fix lint warnings", ] ); // Selection starts on the first matching thread. User presses // SelectNext to move to the second match. cx.dispatch_action(SelectNext); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [my-project]", " Fix crash in panel", " Fix lint warnings <== selected", ] ); // User can also jump back with SelectPrevious. cx.dispatch_action(SelectPrevious); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [my-project]", " Fix crash in panel <== selected", " Fix lint warnings", ] ); } #[gpui::test] async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); multi_workspace.update_in(cx, |mw, window, cx| { mw.create_workspace(window, cx); }); cx.run_until_parked(); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); save_thread_metadata( acp::SessionId::new(Arc::from("hist-1")), "Historical Thread".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(), path_list.clone(), cx, ) .await; cx.run_until_parked(); multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [my-project]", " Historical Thread", "v [Empty Workspace]", " [+ New Thread]", ] ); // Switch to workspace 1 so we can verify the confirm switches back. multi_workspace.update_in(cx, |mw, window, cx| { mw.activate_index(1, window, cx); }); cx.run_until_parked(); assert_eq!( multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), 1 ); // Confirm on the historical (non-live) thread at index 1. // Before a previous fix, the workspace field was Option and // historical threads had None, so activate_thread early-returned // without switching the workspace. sidebar.update_in(cx, |sidebar, window, cx| { sidebar.selection = Some(1); sidebar.confirm(&Confirm, window, cx); }); cx.run_until_parked(); assert_eq!( multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), 0 ); } #[gpui::test] async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); save_thread_metadata( acp::SessionId::new(Arc::from("t-1")), "Thread A".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), path_list.clone(), cx, ) .await; save_thread_metadata( acp::SessionId::new(Arc::from("t-2")), "Thread B".into(), chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), path_list.clone(), cx, ) .await; cx.run_until_parked(); multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["v [my-project]", " Thread A", " Thread B",] ); // Keyboard confirm preserves selection. sidebar.update_in(cx, |sidebar, window, cx| { sidebar.selection = Some(1); sidebar.confirm(&Confirm, window, cx); }); assert_eq!( sidebar.read_with(cx, |sidebar, _| sidebar.selection), Some(1) ); // Click handlers clear selection to None so no highlight lingers // after a click regardless of focus state. The hover style provides // visual feedback during mouse interaction instead. sidebar.update_in(cx, |sidebar, window, cx| { sidebar.selection = None; let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); sidebar.toggle_collapse(&path_list, window, cx); }); assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None); // When the user tabs back into the sidebar, focus_in no longer // restores selection — it stays None. sidebar.update_in(cx, |sidebar, window, cx| { sidebar.focus_in(window, cx); }); assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None); } #[gpui::test] async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); let connection = StubAgentConnection::new(); connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( acp::ContentChunk::new("Hi there!".into()), )]); open_thread_with_connection(&panel, connection, cx); send_message(&panel, cx); let session_id = active_session_id(&panel, cx); save_test_thread_metadata(&session_id, path_list.clone(), cx).await; cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["v [my-project]", " Hello *"] ); // Simulate the agent generating a title. The notification chain is: // AcpThread::set_title emits TitleUpdated → // ConnectionView::handle_thread_event calls cx.notify() → // AgentPanel observer fires and emits AgentPanelEvent → // Sidebar subscription calls update_entries / rebuild_contents. // // Before the fix, handle_thread_event did NOT call cx.notify() for // TitleUpdated, so the AgentPanel observer never fired and the // sidebar kept showing the old title. let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap()); thread.update(cx, |thread, cx| { thread .set_title("Friendly Greeting with AI".into(), cx) .detach(); }); cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["v [my-project]", " Friendly Greeting with AI *"] ); } #[gpui::test] async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { let project_a = init_test_project("/project-a", cx).await; let (multi_workspace, cx) = cx .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx); let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); // Save a thread so it appears in the list. let connection_a = StubAgentConnection::new(); connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( acp::ContentChunk::new("Done".into()), )]); open_thread_with_connection(&panel_a, connection_a, cx); send_message(&panel_a, cx); let session_id_a = active_session_id(&panel_a, cx); save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await; // Add a second workspace with its own agent panel. let fs = cx.update(|_, cx| ::global(cx)); fs.as_fake() .insert_tree("/project-b", serde_json::json!({ "src": {} })) .await; let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await; let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| { mw.test_add_workspace(project_b.clone(), window, cx) }); let panel_b = add_agent_panel(&workspace_b, &project_b, cx); cx.run_until_parked(); let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone()); // ── 1. Initial state: no focused thread ────────────────────────────── // Workspace B is active (just added) and has no thread, so its header // is the active entry. sidebar.read_with(cx, |sidebar, _cx| { assert_eq!( sidebar.focused_thread, None, "Initially no thread should be focused" ); let active_entry = sidebar .active_entry_index .and_then(|ix| sidebar.contents.entries.get(ix)); assert!( matches!(active_entry, Some(ListEntry::ProjectHeader { .. })), "Active entry should be the active workspace header" ); }); // ── 2. Click thread in workspace A via sidebar ─────────────────────── sidebar.update_in(cx, |sidebar, window, cx| { sidebar.activate_thread( Agent::NativeAgent, acp_thread::AgentSessionInfo { session_id: session_id_a.clone(), work_dirs: None, title: Some("Test".into()), updated_at: None, created_at: None, meta: None, }, &workspace_a, window, cx, ); }); cx.run_until_parked(); sidebar.read_with(cx, |sidebar, _cx| { assert_eq!( sidebar.focused_thread.as_ref(), Some(&session_id_a), "After clicking a thread, it should be the focused thread" ); let active_entry = sidebar.active_entry_index .and_then(|ix| sidebar.contents.entries.get(ix)); assert!( matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_a), "Active entry should be the clicked thread" ); }); workspace_a.read_with(cx, |workspace, cx| { assert!( workspace.panel::(cx).is_some(), "Agent panel should exist" ); let dock = workspace.right_dock().read(cx); assert!( dock.is_open(), "Clicking a thread should open the agent panel dock" ); }); // ── 3. Open thread in workspace B, then click it via sidebar ───────── let connection_b = StubAgentConnection::new(); connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( acp::ContentChunk::new("Thread B".into()), )]); open_thread_with_connection(&panel_b, connection_b, cx); send_message(&panel_b, cx); let session_id_b = active_session_id(&panel_b, cx); let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); save_test_thread_metadata(&session_id_b, path_list_b.clone(), cx).await; cx.run_until_parked(); // Opening a thread in a non-active workspace should NOT change // focused_thread — it's derived from the active workspace. sidebar.read_with(cx, |sidebar, _cx| { assert_eq!( sidebar.focused_thread.as_ref(), Some(&session_id_a), "Opening a thread in a non-active workspace should not affect focused_thread" ); }); // Workspace A is currently active. Click a thread in workspace B, // which also triggers a workspace switch. sidebar.update_in(cx, |sidebar, window, cx| { sidebar.activate_thread( Agent::NativeAgent, acp_thread::AgentSessionInfo { session_id: session_id_b.clone(), work_dirs: None, title: Some("Thread B".into()), updated_at: None, created_at: None, meta: None, }, &workspace_b, window, cx, ); }); cx.run_until_parked(); sidebar.read_with(cx, |sidebar, _cx| { assert_eq!( sidebar.focused_thread.as_ref(), Some(&session_id_b), "Clicking a thread in another workspace should focus that thread" ); let active_entry = sidebar .active_entry_index .and_then(|ix| sidebar.contents.entries.get(ix)); assert!( matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b), "Active entry should be the cross-workspace thread" ); }); // ── 4. Switch workspace → focused_thread reflects new workspace ────── multi_workspace.update_in(cx, |mw, window, cx| { mw.activate_next_workspace(window, cx); }); cx.run_until_parked(); // Workspace A is now active. Its agent panel still has session_id_a // loaded, so focused_thread should reflect that. sidebar.read_with(cx, |sidebar, _cx| { assert_eq!( sidebar.focused_thread.as_ref(), Some(&session_id_a), "Switching workspaces should derive focused_thread from the new active workspace" ); let active_entry = sidebar .active_entry_index .and_then(|ix| sidebar.contents.entries.get(ix)); assert!( matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_a), "Active entry should be workspace_a's active thread" ); }); // ── 5. Opening a thread in a non-active workspace is ignored ────────── let connection_b2 = StubAgentConnection::new(); connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( acp::ContentChunk::new("New thread".into()), )]); open_thread_with_connection(&panel_b, connection_b2, cx); send_message(&panel_b, cx); let session_id_b2 = active_session_id(&panel_b, cx); save_test_thread_metadata(&session_id_b2, path_list_b.clone(), cx).await; cx.run_until_parked(); // Workspace A is still active, so focused_thread stays on session_id_a. sidebar.read_with(cx, |sidebar, _cx| { assert_eq!( sidebar.focused_thread.as_ref(), Some(&session_id_a), "Opening a thread in a non-active workspace should not affect focused_thread" ); }); // ── 6. Activating workspace B shows its active thread ──────────────── sidebar.update_in(cx, |sidebar, window, cx| { sidebar.activate_workspace(&workspace_b, window, cx); }); cx.run_until_parked(); // Workspace B is now active with session_id_b2 loaded. sidebar.read_with(cx, |sidebar, _cx| { assert_eq!( sidebar.focused_thread.as_ref(), Some(&session_id_b2), "Activating workspace_b should show workspace_b's active thread" ); let active_entry = sidebar .active_entry_index .and_then(|ix| sidebar.contents.entries.get(ix)); assert!( matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b2), "Active entry should be workspace_b's active thread" ); }); // ── 7. Switching back to workspace A reflects its thread ───────────── multi_workspace.update_in(cx, |mw, window, cx| { mw.activate_next_workspace(window, cx); }); cx.run_until_parked(); sidebar.read_with(cx, |sidebar, _cx| { assert_eq!( sidebar.focused_thread.as_ref(), Some(&session_id_a), "Switching back to workspace_a should show its active thread" ); }); } async fn init_test_project_with_git( worktree_path: &str, cx: &mut TestAppContext, ) -> (Entity, Arc) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( worktree_path, serde_json::json!({ ".git": {}, "src": {}, }), ) .await; cx.update(|cx| ::set_global(fs.clone(), cx)); let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await; (project, fs) } #[gpui::test] async fn test_search_matches_worktree_name(cx: &mut TestAppContext) { let (project, fs) = init_test_project_with_git("/project", cx).await; fs.as_fake() .with_git_state(std::path::Path::new("/project/.git"), false, |state| { state.worktrees.push(git::repository::Worktree { path: std::path::PathBuf::from("/wt/rosewood"), ref_name: "refs/heads/rosewood".into(), sha: "abc".into(), }); }) .unwrap(); project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]); let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); save_named_thread_metadata("main-t", "Unrelated Thread", &main_paths, cx).await; save_named_thread_metadata("wt-t", "Fix Bug", &wt_paths, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); // Search for "rosewood" — should match the worktree name, not the title. type_in_search(&sidebar, "rosewood", cx); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["v [project]", " Fix Bug {rosewood} <== selected"], ); } #[gpui::test] async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) { let (project, fs) = init_test_project_with_git("/project", cx).await; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); // Save a thread against a worktree path that doesn't exist yet. let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); // Thread is not visible yet — no worktree knows about this path. assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["v [project]", " [+ New Thread]"] ); // Now add the worktree to the git state and trigger a rescan. fs.as_fake() .with_git_state(std::path::Path::new("/project/.git"), true, |state| { state.worktrees.push(git::repository::Worktree { path: std::path::PathBuf::from("/wt/rosewood"), ref_name: "refs/heads/rosewood".into(), sha: "abc".into(), }); }) .unwrap(); cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["v [project]", " Worktree Thread {rosewood}",] ); } #[gpui::test] async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); // Create the main repo directory (not opened as a workspace yet). fs.insert_tree( "/project", serde_json::json!({ ".git": { "worktrees": { "feature-a": { "commondir": "../../", "HEAD": "ref: refs/heads/feature-a", }, "feature-b": { "commondir": "../../", "HEAD": "ref: refs/heads/feature-b", }, }, }, "src": {}, }), ) .await; // Two worktree checkouts whose .git files point back to the main repo. fs.insert_tree( "/wt-feature-a", serde_json::json!({ ".git": "gitdir: /project/.git/worktrees/feature-a", "src": {}, }), ) .await; fs.insert_tree( "/wt-feature-b", serde_json::json!({ ".git": "gitdir: /project/.git/worktrees/feature-b", "src": {}, }), ) .await; cx.update(|cx| ::set_global(fs.clone(), cx)); let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await; project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await; project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await; // Open both worktrees as workspaces — no main repo yet. let (multi_workspace, cx) = cx .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); multi_workspace.update_in(cx, |mw, window, cx| { mw.test_add_workspace(project_b.clone(), window, cx); }); let sidebar = setup_sidebar(&multi_workspace, cx); let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]); save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await; save_named_thread_metadata("thread-b", "Thread B", &paths_b, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); // Without the main repo, each worktree has its own header. assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [wt-feature-a]", " Thread A", "v [wt-feature-b]", " Thread B", ] ); // Configure the main repo to list both worktrees before opening // it so the initial git scan picks them up. fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { state.worktrees.push(git::repository::Worktree { path: std::path::PathBuf::from("/wt-feature-a"), ref_name: "refs/heads/feature-a".into(), sha: "aaa".into(), }); state.worktrees.push(git::repository::Worktree { path: std::path::PathBuf::from("/wt-feature-b"), ref_name: "refs/heads/feature-b".into(), sha: "bbb".into(), }); }) .unwrap(); let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; main_project .update(cx, |p, cx| p.git_scans_complete(cx)) .await; multi_workspace.update_in(cx, |mw, window, cx| { mw.test_add_workspace(main_project.clone(), window, cx); }); cx.run_until_parked(); // Both worktree workspaces should now be absorbed under the main // repo header, with worktree chips. assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [project]", " Thread A {wt-feature-a}", " Thread B {wt-feature-b}", ] ); // Remove feature-b from the main repo's linked worktrees. // The feature-b workspace should be pruned automatically. fs.with_git_state(std::path::Path::new("/project/.git"), true, |state| { state .worktrees .retain(|wt| wt.path != std::path::Path::new("/wt-feature-b")); }) .unwrap(); cx.run_until_parked(); // feature-b's workspace is pruned; feature-a remains absorbed // under the main repo. assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["v [project]", " Thread A {wt-feature-a}",] ); } #[gpui::test] async fn test_clicking_worktree_thread_opens_workspace_when_none_exists( cx: &mut TestAppContext, ) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( "/project", serde_json::json!({ ".git": { "worktrees": { "feature-a": { "commondir": "../../", "HEAD": "ref: refs/heads/feature-a", }, }, }, "src": {}, }), ) .await; fs.insert_tree( "/wt-feature-a", serde_json::json!({ ".git": "gitdir: /project/.git/worktrees/feature-a", "src": {}, }), ) .await; fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { state.worktrees.push(git::repository::Worktree { path: std::path::PathBuf::from("/wt-feature-a"), ref_name: "refs/heads/feature-a".into(), sha: "aaa".into(), }); }) .unwrap(); cx.update(|cx| ::set_global(fs.clone(), cx)); // Only open the main repo — no workspace for the worktree. let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; main_project .update(cx, |p, cx| p.git_scans_complete(cx)) .await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| { MultiWorkspace::test_new(main_project.clone(), window, cx) }); let sidebar = setup_sidebar(&multi_workspace, cx); // Save a thread for the worktree path (no workspace for it). let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); // Thread should appear under the main repo with a worktree chip. assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["v [project]", " WT Thread {wt-feature-a}"], ); // Only 1 workspace should exist. assert_eq!( multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), 1, ); // Focus the sidebar and select the worktree thread. open_and_focus_sidebar(&sidebar, cx); sidebar.update_in(cx, |sidebar, _window, _cx| { sidebar.selection = Some(1); // index 0 is header, 1 is the thread }); // Confirm to open the worktree thread. cx.dispatch_action(Confirm); cx.run_until_parked(); // A new workspace should have been created for the worktree path. let new_workspace = multi_workspace.read_with(cx, |mw, _| { assert_eq!( mw.workspaces().len(), 2, "confirming a worktree thread without a workspace should open one", ); mw.workspaces()[1].clone() }); let new_path_list = new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx)); assert_eq!( new_path_list, PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]), "the new workspace should have been opened for the worktree path", ); } #[gpui::test] async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace( cx: &mut TestAppContext, ) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( "/project", serde_json::json!({ ".git": { "worktrees": { "feature-a": { "commondir": "../../", "HEAD": "ref: refs/heads/feature-a", }, }, }, "src": {}, }), ) .await; fs.insert_tree( "/wt-feature-a", serde_json::json!({ ".git": "gitdir: /project/.git/worktrees/feature-a", "src": {}, }), ) .await; fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { state.worktrees.push(git::repository::Worktree { path: std::path::PathBuf::from("/wt-feature-a"), ref_name: "refs/heads/feature-a".into(), sha: "aaa".into(), }); }) .unwrap(); cx.update(|cx| ::set_global(fs.clone(), cx)); let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; main_project .update(cx, |p, cx| p.git_scans_complete(cx)) .await; worktree_project .update(cx, |p, cx| p.git_scans_complete(cx)) .await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| { MultiWorkspace::test_new(main_project.clone(), window, cx) }); let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { mw.test_add_workspace(worktree_project.clone(), window, cx) }); // Activate the main workspace before setting up the sidebar. multi_workspace.update_in(cx, |mw, window, cx| { mw.activate_index(0, window, cx); }); let sidebar = setup_sidebar(&multi_workspace, cx); let paths_main = PathList::new(&[std::path::PathBuf::from("/project")]); let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); save_named_thread_metadata("thread-main", "Main Thread", &paths_main, cx).await; save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); // The worktree workspace should be absorbed under the main repo. let entries = visible_entries_as_strings(&sidebar, cx); assert_eq!(entries.len(), 3); assert_eq!(entries[0], "v [project]"); assert!(entries.contains(&" Main Thread".to_string())); assert!(entries.contains(&" WT Thread {wt-feature-a}".to_string())); let wt_thread_index = entries .iter() .position(|e| e.contains("WT Thread")) .expect("should find the worktree thread entry"); assert_eq!( multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), 0, "main workspace should be active initially" ); // Focus the sidebar and select the absorbed worktree thread. open_and_focus_sidebar(&sidebar, cx); sidebar.update_in(cx, |sidebar, _window, _cx| { sidebar.selection = Some(wt_thread_index); }); // Confirm to activate the worktree thread. cx.dispatch_action(Confirm); cx.run_until_parked(); // The worktree workspace should now be active, not the main one. let active_workspace = multi_workspace.read_with(cx, |mw, _| { mw.workspaces()[mw.active_workspace_index()].clone() }); assert_eq!( active_workspace, worktree_workspace, "clicking an absorbed worktree thread should activate the worktree workspace" ); } #[gpui::test] async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace( cx: &mut TestAppContext, ) { // Thread has saved metadata in ThreadStore. A matching workspace is // already open. Expected: activates the matching workspace. init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) .await; fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) .await; cx.update(|cx| ::set_global(fs.clone(), cx)); let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); multi_workspace.update_in(cx, |mw, window, cx| { mw.test_add_workspace(project_b, window, cx); }); let sidebar = setup_sidebar(&multi_workspace, cx); // Save a thread with path_list pointing to project-b. let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); let session_id = acp::SessionId::new(Arc::from("archived-1")); save_test_thread_metadata(&session_id, path_list_b.clone(), cx).await; // Ensure workspace A is active. multi_workspace.update_in(cx, |mw, window, cx| { mw.activate_index(0, window, cx); }); cx.run_until_parked(); assert_eq!( multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), 0 ); // Call activate_archived_thread – should resolve saved paths and // switch to the workspace for project-b. sidebar.update_in(cx, |sidebar, window, cx| { sidebar.activate_archived_thread( Agent::NativeAgent, acp_thread::AgentSessionInfo { session_id: session_id.clone(), work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])), title: Some("Archived Thread".into()), updated_at: None, created_at: None, meta: None, }, window, cx, ); }); cx.run_until_parked(); assert_eq!( multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), 1, "should have activated the workspace matching the saved path_list" ); } #[gpui::test] async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace( cx: &mut TestAppContext, ) { // Thread has no saved metadata but session_info has cwd. A matching // workspace is open. Expected: uses cwd to find and activate it. init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) .await; fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) .await; cx.update(|cx| ::set_global(fs.clone(), cx)); let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); multi_workspace.update_in(cx, |mw, window, cx| { mw.test_add_workspace(project_b, window, cx); }); let sidebar = setup_sidebar(&multi_workspace, cx); // Start with workspace A active. multi_workspace.update_in(cx, |mw, window, cx| { mw.activate_index(0, window, cx); }); cx.run_until_parked(); assert_eq!( multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), 0 ); // No thread saved to the store – cwd is the only path hint. sidebar.update_in(cx, |sidebar, window, cx| { sidebar.activate_archived_thread( Agent::NativeAgent, acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("unknown-session")), work_dirs: Some(PathList::new(&[std::path::PathBuf::from("/project-b")])), title: Some("CWD Thread".into()), updated_at: None, created_at: None, meta: None, }, window, cx, ); }); cx.run_until_parked(); assert_eq!( multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), 1, "should have activated the workspace matching the cwd" ); } #[gpui::test] async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace( cx: &mut TestAppContext, ) { // Thread has no saved metadata and no cwd. Expected: falls back to // the currently active workspace. init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) .await; fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) .await; cx.update(|cx| ::set_global(fs.clone(), cx)); let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); multi_workspace.update_in(cx, |mw, window, cx| { mw.test_add_workspace(project_b, window, cx); }); let sidebar = setup_sidebar(&multi_workspace, cx); // Activate workspace B (index 1) to make it the active one. multi_workspace.update_in(cx, |mw, window, cx| { mw.activate_index(1, window, cx); }); cx.run_until_parked(); assert_eq!( multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), 1 ); // No saved thread, no cwd – should fall back to the active workspace. sidebar.update_in(cx, |sidebar, window, cx| { sidebar.activate_archived_thread( Agent::NativeAgent, acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("no-context-session")), work_dirs: None, title: Some("Contextless Thread".into()), updated_at: None, created_at: None, meta: None, }, window, cx, ); }); cx.run_until_parked(); assert_eq!( multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), 1, "should have stayed on the active workspace when no path info is available" ); } #[gpui::test] async fn test_activate_archived_thread_saved_paths_opens_new_workspace( cx: &mut TestAppContext, ) { // Thread has saved metadata pointing to a path with no open workspace. // Expected: opens a new workspace for that path. init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) .await; fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) .await; cx.update(|cx| ::set_global(fs.clone(), cx)); let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); // Save a thread with path_list pointing to project-b – which has no // open workspace. let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); let session_id = acp::SessionId::new(Arc::from("archived-new-ws")); assert_eq!( multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), 1, "should start with one workspace" ); sidebar.update_in(cx, |sidebar, window, cx| { sidebar.activate_archived_thread( Agent::NativeAgent, acp_thread::AgentSessionInfo { session_id: session_id.clone(), work_dirs: Some(path_list_b), title: Some("New WS Thread".into()), updated_at: None, created_at: None, meta: None, }, window, cx, ); }); cx.run_until_parked(); assert_eq!( multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), 2, "should have opened a second workspace for the archived thread's saved paths" ); } }