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