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