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