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