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