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