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