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