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