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