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