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