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