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