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