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