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