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