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