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