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