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