1mod thread_switcher;
2
3use acp_thread::ThreadStatus;
4use action_log::DiffStats;
5use agent_client_protocol::{self as acp};
6use agent_settings::AgentSettings;
7use agent_ui::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore, WorktreePaths};
8use agent_ui::thread_worktree_archive;
9use agent_ui::threads_archive_view::{
10 ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp,
11};
12use agent_ui::{
13 AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, DEFAULT_THREAD_TITLE, NewThread,
14 RemoveSelectedThread, ThreadId, ThreadImportModal,
15};
16use chrono::{DateTime, Utc};
17use editor::Editor;
18use gpui::{
19 Action as _, AnyElement, App, Context, DismissEvent, Entity, EntityId, FocusHandle, Focusable,
20 KeyContext, ListState, Pixels, Render, SharedString, Task, WeakEntity, Window, WindowHandle,
21 linear_color_stop, linear_gradient, list, prelude::*, px,
22};
23use menu::{
24 Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious,
25};
26use project::{
27 AgentId, AgentRegistryStore, Event as ProjectEvent, WorktreeId, linked_worktree_short_name,
28};
29use recent_projects::sidebar_recent_projects::SidebarRecentProjects;
30use remote::RemoteConnectionOptions;
31use ui::utils::platform_title_bar_height;
32
33use serde::{Deserialize, Serialize};
34use settings::Settings as _;
35use std::collections::{HashMap, HashSet};
36use std::mem;
37use std::path::{Path, PathBuf};
38use std::rc::Rc;
39use theme::ActiveTheme;
40use ui::{
41 AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, GradientFade, HighlightedLabel,
42 KeyBinding, PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, ThreadItemWorktreeInfo, TintColor,
43 Tooltip, WithScrollbar, prelude::*,
44};
45use util::ResultExt as _;
46use util::path_list::PathList;
47use workspace::{
48 AddFolderToProject, CloseWindow, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent,
49 NextProject, NextThread, Open, PreviousProject, PreviousThread, ProjectGroupKey, SaveIntent,
50 ShowFewerThreads, ShowMoreThreads, Sidebar as WorkspaceSidebar, SidebarSide, Toast,
51 ToggleWorkspaceSidebar, Workspace, notifications::NotificationId, sidebar_side_context_menu,
52};
53
54use zed_actions::OpenRecent;
55use zed_actions::editor::{MoveDown, MoveUp};
56
57use zed_actions::agents_sidebar::{FocusSidebarFilter, ToggleThreadSwitcher};
58
59use crate::thread_switcher::{ThreadSwitcher, ThreadSwitcherEntry, ThreadSwitcherEvent};
60
61#[cfg(test)]
62mod sidebar_tests;
63
64gpui::actions!(
65 agents_sidebar,
66 [
67 /// Creates a new thread in the currently selected or active project group.
68 NewThreadInGroup,
69 /// Toggles between the thread list and the archive view.
70 ToggleArchive,
71 ]
72);
73
74gpui::actions!(
75 dev,
76 [
77 /// Dumps multi-workspace state (projects, worktrees, active threads) into a new buffer.
78 DumpWorkspaceInfo,
79 ]
80);
81
82const DEFAULT_WIDTH: Pixels = px(300.0);
83const MIN_WIDTH: Pixels = px(200.0);
84const MAX_WIDTH: Pixels = px(800.0);
85const DEFAULT_THREADS_SHOWN: usize = 5;
86
87#[derive(Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88enum SerializedSidebarView {
89 #[default]
90 ThreadList,
91 Archive,
92}
93
94#[derive(Default, Serialize, Deserialize)]
95struct SerializedSidebar {
96 #[serde(default)]
97 width: Option<f32>,
98 #[serde(default)]
99 active_view: SerializedSidebarView,
100}
101
102#[derive(Debug, Default)]
103enum SidebarView {
104 #[default]
105 ThreadList,
106 Archive(Entity<ThreadsArchiveView>),
107}
108
109enum ArchiveWorktreeOutcome {
110 Success,
111 Cancelled,
112}
113
114#[derive(Clone, Debug)]
115struct ActiveEntry {
116 thread_id: agent_ui::ThreadId,
117 /// Stable remote identifier, used for matching when thread_id
118 /// differs (e.g. after cross-window activation creates a new
119 /// local ThreadId).
120 session_id: Option<acp::SessionId>,
121 workspace: Entity<Workspace>,
122}
123
124impl ActiveEntry {
125 fn workspace(&self) -> &Entity<Workspace> {
126 &self.workspace
127 }
128
129 fn is_active_thread(&self, thread_id: &agent_ui::ThreadId) -> bool {
130 self.thread_id == *thread_id
131 }
132
133 fn matches_entry(&self, entry: &ListEntry) -> bool {
134 match entry {
135 ListEntry::Thread(thread) => {
136 self.thread_id == thread.metadata.thread_id
137 || self
138 .session_id
139 .as_ref()
140 .zip(thread.metadata.session_id.as_ref())
141 .is_some_and(|(a, b)| a == b)
142 }
143 _ => false,
144 }
145 }
146}
147
148#[derive(Clone, Debug)]
149struct ActiveThreadInfo {
150 session_id: acp::SessionId,
151 title: SharedString,
152 status: AgentThreadStatus,
153 icon: IconName,
154 icon_from_external_svg: Option<SharedString>,
155 is_background: bool,
156 is_title_generating: bool,
157 diff_stats: DiffStats,
158}
159
160#[derive(Clone)]
161enum ThreadEntryWorkspace {
162 Open(Entity<Workspace>),
163 Closed {
164 /// The paths this thread uses (may point to linked worktrees).
165 folder_paths: PathList,
166 /// The project group this thread belongs to.
167 project_group_key: ProjectGroupKey,
168 },
169}
170
171impl ThreadEntryWorkspace {
172 fn is_remote(&self, cx: &App) -> bool {
173 match self {
174 ThreadEntryWorkspace::Open(workspace) => {
175 !workspace.read(cx).project().read(cx).is_local()
176 }
177 ThreadEntryWorkspace::Closed {
178 project_group_key, ..
179 } => project_group_key.host().is_some(),
180 }
181 }
182}
183
184#[derive(Clone)]
185struct WorktreeInfo {
186 name: SharedString,
187 full_path: SharedString,
188 highlight_positions: Vec<usize>,
189 kind: ui::WorktreeKind,
190}
191
192#[derive(Clone)]
193struct ThreadEntry {
194 metadata: ThreadMetadata,
195 icon: IconName,
196 icon_from_external_svg: Option<SharedString>,
197 status: AgentThreadStatus,
198 workspace: ThreadEntryWorkspace,
199 is_live: bool,
200 is_background: bool,
201 is_title_generating: bool,
202 is_draft: bool,
203 highlight_positions: Vec<usize>,
204 worktrees: Vec<WorktreeInfo>,
205 diff_stats: DiffStats,
206}
207
208impl ThreadEntry {
209 /// Updates this thread entry with active thread information.
210 ///
211 /// The existing [`ThreadEntry`] was likely deserialized from the database
212 /// but if we have a correspond thread already loaded we want to apply the
213 /// live information.
214 fn apply_active_info(&mut self, info: &ActiveThreadInfo) {
215 self.metadata.title = Some(info.title.clone());
216 self.status = info.status;
217 self.icon = info.icon;
218 self.icon_from_external_svg = info.icon_from_external_svg.clone();
219 self.is_live = true;
220 self.is_background = info.is_background;
221 self.is_title_generating = info.is_title_generating;
222 self.diff_stats = info.diff_stats;
223 }
224}
225
226#[derive(Clone)]
227enum ListEntry {
228 ProjectHeader {
229 key: ProjectGroupKey,
230 label: SharedString,
231 highlight_positions: Vec<usize>,
232 has_running_threads: bool,
233 waiting_thread_count: usize,
234 is_active: bool,
235 has_threads: bool,
236 },
237 Thread(ThreadEntry),
238 ViewMore {
239 key: ProjectGroupKey,
240 is_fully_expanded: bool,
241 },
242}
243
244#[cfg(test)]
245impl ListEntry {
246 fn session_id(&self) -> Option<&acp::SessionId> {
247 match self {
248 ListEntry::Thread(thread_entry) => thread_entry.metadata.session_id.as_ref(),
249 _ => None,
250 }
251 }
252
253 fn reachable_workspaces<'a>(
254 &'a self,
255 multi_workspace: &'a workspace::MultiWorkspace,
256 cx: &'a App,
257 ) -> Vec<Entity<Workspace>> {
258 match self {
259 ListEntry::Thread(thread) => match &thread.workspace {
260 ThreadEntryWorkspace::Open(ws) => vec![ws.clone()],
261 ThreadEntryWorkspace::Closed { .. } => Vec::new(),
262 },
263 ListEntry::ProjectHeader { key, .. } => multi_workspace
264 .workspaces_for_project_group(key, cx)
265 .unwrap_or_default(),
266 ListEntry::ViewMore { .. } => Vec::new(),
267 }
268 }
269}
270
271impl From<ThreadEntry> for ListEntry {
272 fn from(thread: ThreadEntry) -> Self {
273 ListEntry::Thread(thread)
274 }
275}
276
277#[derive(Default)]
278struct SidebarContents {
279 entries: Vec<ListEntry>,
280 notified_threads: HashSet<agent_ui::ThreadId>,
281 project_header_indices: Vec<usize>,
282 has_open_projects: bool,
283}
284
285impl SidebarContents {
286 fn is_thread_notified(&self, thread_id: &agent_ui::ThreadId) -> bool {
287 self.notified_threads.contains(thread_id)
288 }
289}
290
291fn fuzzy_match_positions(query: &str, candidate: &str) -> Option<Vec<usize>> {
292 let mut positions = Vec::new();
293 let mut query_chars = query.chars().peekable();
294
295 for (byte_idx, candidate_char) in candidate.char_indices() {
296 if let Some(&query_char) = query_chars.peek() {
297 if candidate_char.eq_ignore_ascii_case(&query_char) {
298 positions.push(byte_idx);
299 query_chars.next();
300 }
301 } else {
302 break;
303 }
304 }
305
306 if query_chars.peek().is_none() {
307 Some(positions)
308 } else {
309 None
310 }
311}
312
313// TODO: The mapping from workspace root paths to git repositories needs a
314// unified approach across the codebase: this function, `AgentPanel::classify_worktrees`,
315// thread persistence (which PathList is saved to the database), and thread
316// querying (which PathList is used to read threads back). All of these need
317// to agree on how repos are resolved for a given workspace, especially in
318// multi-root and nested-repo configurations.
319fn root_repository_snapshots(
320 workspace: &Entity<Workspace>,
321 cx: &App,
322) -> impl Iterator<Item = project::git_store::RepositorySnapshot> {
323 let path_list = workspace_path_list(workspace, cx);
324 let project = workspace.read(cx).project().read(cx);
325 project.repositories(cx).values().filter_map(move |repo| {
326 let snapshot = repo.read(cx).snapshot();
327 let is_root = path_list
328 .paths()
329 .iter()
330 .any(|p| p.as_path() == snapshot.work_directory_abs_path.as_ref());
331 is_root.then_some(snapshot)
332 })
333}
334
335fn workspace_path_list(workspace: &Entity<Workspace>, cx: &App) -> PathList {
336 PathList::new(&workspace.read(cx).root_paths(cx))
337}
338
339/// Derives worktree display info from a thread's stored path list.
340///
341/// For each path in the thread's `folder_paths`, produces a
342/// [`WorktreeInfo`] with a short display name, full path, and whether
343/// the worktree is the main checkout or a linked git worktree. When
344/// multiple main paths exist and a linked worktree's short name alone
345/// wouldn't identify which main project it belongs to, the main project
346/// name is prefixed for disambiguation (e.g. `project:feature`).
347///
348fn worktree_info_from_thread_paths(worktree_paths: &WorktreePaths) -> Vec<WorktreeInfo> {
349 let mut infos: Vec<WorktreeInfo> = Vec::new();
350 let mut linked_short_names: Vec<(SharedString, SharedString)> = Vec::new();
351 let mut unique_main_count = HashSet::new();
352
353 for (main_path, folder_path) in worktree_paths.ordered_pairs() {
354 unique_main_count.insert(main_path.clone());
355 let is_linked = main_path != folder_path;
356
357 if is_linked {
358 let short_name = linked_worktree_short_name(main_path, folder_path).unwrap_or_default();
359 let project_name = main_path
360 .file_name()
361 .map(|n| SharedString::from(n.to_string_lossy().to_string()))
362 .unwrap_or_default();
363 linked_short_names.push((short_name.clone(), project_name));
364 infos.push(WorktreeInfo {
365 name: short_name,
366 full_path: SharedString::from(folder_path.display().to_string()),
367 highlight_positions: Vec::new(),
368 kind: ui::WorktreeKind::Linked,
369 });
370 } else {
371 let Some(name) = folder_path.file_name() else {
372 continue;
373 };
374 infos.push(WorktreeInfo {
375 name: SharedString::from(name.to_string_lossy().to_string()),
376 full_path: SharedString::from(folder_path.display().to_string()),
377 highlight_positions: Vec::new(),
378 kind: ui::WorktreeKind::Main,
379 });
380 }
381 }
382
383 // When the group has multiple main worktree paths and the thread's
384 // folder paths don't all share the same short name, prefix each
385 // linked worktree chip with its main project name so the user knows
386 // which project it belongs to.
387 let all_same_name = infos.len() > 1 && infos.iter().all(|i| i.name == infos[0].name);
388
389 if unique_main_count.len() > 1 && !all_same_name {
390 for (info, (_short_name, project_name)) in infos
391 .iter_mut()
392 .filter(|i| i.kind == ui::WorktreeKind::Linked)
393 .zip(linked_short_names.iter())
394 {
395 info.name = SharedString::from(format!("{}:{}", project_name, info.name));
396 }
397 }
398
399 infos
400}
401
402/// Shows a [`RemoteConnectionModal`] on the given workspace and establishes
403/// an SSH connection. Suitable for passing to
404/// [`MultiWorkspace::find_or_create_workspace`] as the `connect_remote`
405/// argument.
406fn connect_remote(
407 modal_workspace: Entity<Workspace>,
408 connection_options: RemoteConnectionOptions,
409 window: &mut Window,
410 cx: &mut Context<MultiWorkspace>,
411) -> gpui::Task<anyhow::Result<Option<Entity<remote::RemoteClient>>>> {
412 remote_connection::connect_with_modal(&modal_workspace, connection_options, window, cx)
413}
414
415/// The sidebar re-derives its entire entry list from scratch on every
416/// change via `update_entries` → `rebuild_contents`. Avoid adding
417/// incremental or inter-event coordination state — if something can
418/// be computed from the current world state, compute it in the rebuild.
419pub struct Sidebar {
420 multi_workspace: WeakEntity<MultiWorkspace>,
421 width: Pixels,
422 focus_handle: FocusHandle,
423 filter_editor: Entity<Editor>,
424 list_state: ListState,
425 contents: SidebarContents,
426 /// The index of the list item that currently has the keyboard focus
427 ///
428 /// Note: This is NOT the same as the active item.
429 selection: Option<usize>,
430 /// Tracks which sidebar entry is currently active (highlighted).
431 active_entry: Option<ActiveEntry>,
432 hovered_thread_index: Option<usize>,
433
434 /// Updated only in response to explicit user actions (clicking a
435 /// thread, confirming in the thread switcher, etc.) — never from
436 /// background data changes. Used to sort the thread switcher popup.
437 thread_last_accessed: HashMap<acp::SessionId, DateTime<Utc>>,
438 /// Updated when the user presses a key to send or queue a message.
439 /// Used for sorting threads in the sidebar and as a secondary sort
440 /// key in the thread switcher.
441 thread_last_message_sent_or_queued: HashMap<agent_ui::ThreadId, DateTime<Utc>>,
442 thread_switcher: Option<Entity<ThreadSwitcher>>,
443 _thread_switcher_subscriptions: Vec<gpui::Subscription>,
444 pending_thread_activation: Option<agent_ui::ThreadId>,
445 view: SidebarView,
446 restoring_tasks: HashMap<agent_ui::ThreadId, Task<()>>,
447 recent_projects_popover_handle: PopoverMenuHandle<SidebarRecentProjects>,
448 project_header_menu_ix: Option<usize>,
449 _subscriptions: Vec<gpui::Subscription>,
450 _draft_observations: Vec<gpui::Subscription>,
451}
452
453impl Sidebar {
454 pub fn new(
455 multi_workspace: Entity<MultiWorkspace>,
456 window: &mut Window,
457 cx: &mut Context<Self>,
458 ) -> Self {
459 let focus_handle = cx.focus_handle();
460 cx.on_focus_in(&focus_handle, window, Self::focus_in)
461 .detach();
462
463 let filter_editor = cx.new(|cx| {
464 let mut editor = Editor::single_line(window, cx);
465 editor.set_use_modal_editing(true);
466 editor.set_placeholder_text("Search…", window, cx);
467 editor
468 });
469
470 cx.subscribe_in(
471 &multi_workspace,
472 window,
473 |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
474 MultiWorkspaceEvent::ActiveWorkspaceChanged => {
475 this.sync_active_entry_from_active_workspace(cx);
476 this.observe_draft_editors(cx);
477 this.update_entries(cx);
478 }
479 MultiWorkspaceEvent::WorkspaceAdded(workspace) => {
480 this.subscribe_to_workspace(workspace, window, cx);
481 this.update_entries(cx);
482 }
483 MultiWorkspaceEvent::WorkspaceRemoved(_) => {
484 this.update_entries(cx);
485 }
486 MultiWorkspaceEvent::ProjectGroupKeyUpdated { old_key, new_key } => {
487 this.move_threads_for_key_change(old_key, new_key, cx);
488 this.update_entries(cx);
489 }
490 },
491 )
492 .detach();
493
494 cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
495 if let editor::EditorEvent::BufferEdited = event {
496 let query = this.filter_editor.read(cx).text(cx);
497 if !query.is_empty() {
498 this.selection.take();
499 }
500 this.update_entries(cx);
501 if !query.is_empty() {
502 this.select_first_entry();
503 }
504 }
505 })
506 .detach();
507
508 cx.observe(&ThreadMetadataStore::global(cx), |this, _store, cx| {
509 this.update_entries(cx);
510 })
511 .detach();
512
513 let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect();
514 cx.defer_in(window, move |this, window, cx| {
515 for workspace in &workspaces {
516 this.subscribe_to_workspace(workspace, window, cx);
517 }
518 this.update_entries(cx);
519 });
520
521 Self {
522 multi_workspace: multi_workspace.downgrade(),
523 width: DEFAULT_WIDTH,
524 focus_handle,
525 filter_editor,
526 list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
527 contents: SidebarContents::default(),
528 selection: None,
529 active_entry: None,
530 hovered_thread_index: None,
531
532 thread_last_accessed: HashMap::new(),
533 thread_last_message_sent_or_queued: HashMap::new(),
534 thread_switcher: None,
535 _thread_switcher_subscriptions: Vec::new(),
536 pending_thread_activation: None,
537 view: SidebarView::default(),
538 restoring_tasks: HashMap::new(),
539 recent_projects_popover_handle: PopoverMenuHandle::default(),
540 project_header_menu_ix: None,
541 _subscriptions: Vec::new(),
542 _draft_observations: Vec::new(),
543 }
544 }
545
546 fn serialize(&mut self, cx: &mut Context<Self>) {
547 cx.emit(workspace::SidebarEvent::SerializeNeeded);
548 }
549
550 fn is_group_collapsed(&self, key: &ProjectGroupKey, cx: &App) -> bool {
551 self.multi_workspace
552 .upgrade()
553 .and_then(|mw| {
554 mw.read(cx)
555 .group_state_by_key(key)
556 .map(|state| !state.expanded)
557 })
558 .unwrap_or(false)
559 }
560
561 fn group_extra_batches(&self, key: &ProjectGroupKey, cx: &App) -> usize {
562 self.multi_workspace
563 .upgrade()
564 .and_then(|mw| {
565 mw.read(cx)
566 .group_state_by_key(key)
567 .and_then(|state| state.visible_thread_count)
568 })
569 .unwrap_or(0)
570 }
571
572 fn set_group_expanded(&self, key: &ProjectGroupKey, expanded: bool, cx: &mut Context<Self>) {
573 if let Some(mw) = self.multi_workspace.upgrade() {
574 mw.update(cx, |mw, cx| {
575 if let Some(state) = mw.group_state_by_key_mut(key) {
576 state.expanded = expanded;
577 }
578 mw.serialize(cx);
579 });
580 }
581 }
582
583 fn set_group_visible_thread_count(
584 &self,
585 key: &ProjectGroupKey,
586 count: Option<usize>,
587 cx: &mut Context<Self>,
588 ) {
589 if let Some(mw) = self.multi_workspace.upgrade() {
590 mw.update(cx, |mw, cx| {
591 if let Some(state) = mw.group_state_by_key_mut(key) {
592 state.visible_thread_count = count;
593 }
594 mw.serialize(cx);
595 });
596 }
597 }
598
599 fn is_active_workspace(&self, workspace: &Entity<Workspace>, cx: &App) -> bool {
600 self.multi_workspace
601 .upgrade()
602 .map_or(false, |mw| mw.read(cx).workspace() == workspace)
603 }
604
605 fn subscribe_to_workspace(
606 &mut self,
607 workspace: &Entity<Workspace>,
608 window: &mut Window,
609 cx: &mut Context<Self>,
610 ) {
611 let project = workspace.read(cx).project().clone();
612
613 cx.subscribe_in(
614 &project,
615 window,
616 |this, project, event, _window, cx| match event {
617 ProjectEvent::WorktreeAdded(_)
618 | ProjectEvent::WorktreeRemoved(_)
619 | ProjectEvent::WorktreeOrderChanged => {
620 this.observe_draft_editors(cx);
621 this.update_entries(cx);
622 }
623 ProjectEvent::WorktreePathsChanged { old_worktree_paths } => {
624 this.move_thread_paths(project, old_worktree_paths, cx);
625 this.observe_draft_editors(cx);
626 this.update_entries(cx);
627 }
628 _ => {}
629 },
630 )
631 .detach();
632
633 let git_store = workspace.read(cx).project().read(cx).git_store().clone();
634 cx.subscribe_in(
635 &git_store,
636 window,
637 |this, _, event: &project::git_store::GitStoreEvent, _window, cx| {
638 if matches!(
639 event,
640 project::git_store::GitStoreEvent::RepositoryUpdated(
641 _,
642 project::git_store::RepositoryEvent::GitWorktreeListChanged,
643 _,
644 )
645 ) {
646 this.update_entries(cx);
647 }
648 },
649 )
650 .detach();
651
652 cx.subscribe_in(
653 workspace,
654 window,
655 |this, _workspace, event: &workspace::Event, window, cx| {
656 if let workspace::Event::PanelAdded(view) = event {
657 if let Ok(agent_panel) = view.clone().downcast::<AgentPanel>() {
658 this.subscribe_to_agent_panel(&agent_panel, window, cx);
659 this.update_entries(cx);
660 }
661 }
662 },
663 )
664 .detach();
665
666 self.observe_docks(workspace, cx);
667
668 if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
669 self.subscribe_to_agent_panel(&agent_panel, window, cx);
670 self.observe_draft_editors(cx);
671 }
672 }
673
674 fn move_threads_for_key_change(
675 &mut self,
676 old_key: &ProjectGroupKey,
677 new_key: &ProjectGroupKey,
678 cx: &mut Context<Self>,
679 ) {
680 let old_main_paths = old_key.path_list();
681 let new_main_paths = new_key.path_list();
682
683 let added_paths: Vec<PathBuf> = new_main_paths
684 .paths()
685 .iter()
686 .filter(|p| !old_main_paths.paths().contains(p))
687 .cloned()
688 .collect();
689
690 let removed_paths: Vec<PathBuf> = old_main_paths
691 .paths()
692 .iter()
693 .filter(|p| !new_main_paths.paths().contains(p))
694 .cloned()
695 .collect();
696
697 if added_paths.is_empty() && removed_paths.is_empty() {
698 return;
699 }
700
701 let remote_connection = old_key.host();
702 ThreadMetadataStore::global(cx).update(cx, |store, store_cx| {
703 store.change_worktree_paths_by_main(
704 old_main_paths,
705 remote_connection.as_ref(),
706 |paths| {
707 for path in &added_paths {
708 paths.add_path(path, path);
709 }
710 for path in &removed_paths {
711 paths.remove_main_path(path);
712 }
713 },
714 store_cx,
715 );
716 });
717 }
718
719 fn move_thread_paths(
720 &mut self,
721 project: &Entity<project::Project>,
722 old_paths: &WorktreePaths,
723 cx: &mut Context<Self>,
724 ) {
725 let new_paths = project.read(cx).worktree_paths(cx);
726 let old_folder_paths = old_paths.folder_path_list().clone();
727
728 let added_pairs: Vec<_> = new_paths
729 .ordered_pairs()
730 .filter(|(main, folder)| {
731 !old_paths
732 .ordered_pairs()
733 .any(|(old_main, old_folder)| old_main == *main && old_folder == *folder)
734 })
735 .map(|(m, f)| (m.clone(), f.clone()))
736 .collect();
737
738 let new_folder_paths = new_paths.folder_path_list();
739 let removed_folder_paths: Vec<PathBuf> = old_folder_paths
740 .paths()
741 .iter()
742 .filter(|p| !new_folder_paths.paths().contains(p))
743 .cloned()
744 .collect();
745
746 if added_pairs.is_empty() && removed_folder_paths.is_empty() {
747 return;
748 }
749
750 let remote_connection = project.read(cx).remote_connection_options(cx);
751 ThreadMetadataStore::global(cx).update(cx, |store, store_cx| {
752 store.change_worktree_paths(
753 &old_folder_paths,
754 remote_connection.as_ref(),
755 |paths| {
756 for (main_path, folder_path) in &added_pairs {
757 paths.add_path(main_path, folder_path);
758 }
759 for path in &removed_folder_paths {
760 paths.remove_folder_path(path);
761 }
762 },
763 store_cx,
764 );
765 });
766 }
767
768 fn subscribe_to_agent_panel(
769 &mut self,
770 agent_panel: &Entity<AgentPanel>,
771 window: &mut Window,
772 cx: &mut Context<Self>,
773 ) {
774 cx.subscribe_in(
775 agent_panel,
776 window,
777 |this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event {
778 AgentPanelEvent::ActiveViewChanged => {
779 this.sync_active_entry_from_panel(_agent_panel, cx);
780 this.observe_draft_editors(cx);
781 this.update_entries(cx);
782 }
783 AgentPanelEvent::ThreadFocused | AgentPanelEvent::RetainedThreadChanged => {
784 this.sync_active_entry_from_panel(_agent_panel, cx);
785 this.update_entries(cx);
786 }
787 AgentPanelEvent::MessageSentOrQueued { thread_id } => {
788 this.record_thread_message_sent(thread_id);
789 this.update_entries(cx);
790 }
791 },
792 )
793 .detach();
794 }
795
796 fn sync_active_entry_from_active_workspace(&mut self, cx: &App) {
797 let panel = self
798 .active_workspace(cx)
799 .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx));
800 if let Some(panel) = panel {
801 self.sync_active_entry_from_panel(&panel, cx);
802 }
803 }
804
805 /// Syncs `active_entry` from the agent panel's current state.
806 /// Called from `ActiveViewChanged` — the panel has settled into its
807 /// new view, so we can safely read it without race conditions.
808 ///
809 /// Also resolves `pending_thread_activation` when the panel's
810 /// active thread matches the pending activation.
811 fn sync_active_entry_from_panel(&mut self, agent_panel: &Entity<AgentPanel>, cx: &App) -> bool {
812 let Some(active_workspace) = self.active_workspace(cx) else {
813 return false;
814 };
815
816 // Only sync when the event comes from the active workspace's panel.
817 let is_active_panel = active_workspace
818 .read(cx)
819 .panel::<AgentPanel>(cx)
820 .is_some_and(|p| p == *agent_panel);
821 if !is_active_panel {
822 return false;
823 }
824
825 let panel = agent_panel.read(cx);
826
827 if let Some(pending_thread_id) = self.pending_thread_activation {
828 let panel_thread_id = panel
829 .active_conversation_view()
830 .map(|cv| cv.read(cx).parent_id());
831
832 if panel_thread_id == Some(pending_thread_id) {
833 let session_id = panel
834 .active_agent_thread(cx)
835 .map(|thread| thread.read(cx).session_id().clone());
836 self.active_entry = Some(ActiveEntry {
837 thread_id: pending_thread_id,
838 session_id,
839 workspace: active_workspace,
840 });
841 self.pending_thread_activation = None;
842 return true;
843 }
844 // Pending activation not yet resolved — keep current active_entry.
845 return false;
846 }
847
848 if let Some(thread_id) = panel.active_thread_id(cx) {
849 let session_id = panel
850 .active_agent_thread(cx)
851 .map(|thread| thread.read(cx).session_id().clone());
852 self.active_entry = Some(ActiveEntry {
853 thread_id,
854 session_id,
855 workspace: active_workspace,
856 });
857 }
858
859 false
860 }
861
862 fn observe_docks(&mut self, workspace: &Entity<Workspace>, cx: &mut Context<Self>) {
863 let docks: Vec<_> = workspace
864 .read(cx)
865 .all_docks()
866 .into_iter()
867 .cloned()
868 .collect();
869 let workspace = workspace.downgrade();
870 for dock in docks {
871 let workspace = workspace.clone();
872 cx.observe(&dock, move |this, _dock, cx| {
873 let Some(workspace) = workspace.upgrade() else {
874 return;
875 };
876 if !this.is_active_workspace(&workspace, cx) {
877 return;
878 }
879
880 cx.notify();
881 })
882 .detach();
883 }
884 }
885
886 fn observe_draft_editors(&mut self, cx: &mut Context<Self>) {
887 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
888 self._draft_observations.clear();
889 return;
890 };
891
892 // Collect conversation views up front to avoid holding a
893 // borrow on `cx` across `cx.observe` calls.
894 let conversation_views: Vec<_> = multi_workspace
895 .read(cx)
896 .workspaces()
897 .filter_map(|ws| ws.read(cx).panel::<AgentPanel>(cx))
898 .flat_map(|panel| panel.read(cx).conversation_views())
899 .collect();
900
901 let mut subscriptions = Vec::with_capacity(conversation_views.len());
902 for cv in conversation_views {
903 if let Some(thread_view) = cv.read(cx).active_thread() {
904 let editor = thread_view.read(cx).message_editor.clone();
905 subscriptions.push(cx.observe(&editor, |this, _editor, cx| {
906 this.update_entries(cx);
907 }));
908 } else {
909 subscriptions.push(cx.observe(&cv, |this, _cv, cx| {
910 this.observe_draft_editors(cx);
911 this.update_entries(cx);
912 }));
913 }
914 }
915
916 self._draft_observations = subscriptions;
917 }
918
919 fn clean_mention_links(input: &str) -> String {
920 let mut result = String::with_capacity(input.len());
921 let mut remaining = input;
922
923 while let Some(start) = remaining.find("[@") {
924 result.push_str(&remaining[..start]);
925 let after_bracket = &remaining[start + 1..]; // skip '['
926 if let Some(close_bracket) = after_bracket.find("](") {
927 let mention = &after_bracket[..close_bracket]; // "@something"
928 let after_link_start = &after_bracket[close_bracket + 2..]; // after "]("
929 if let Some(close_paren) = after_link_start.find(')') {
930 result.push_str(mention);
931 remaining = &after_link_start[close_paren + 1..];
932 continue;
933 }
934 }
935 // Couldn't parse full link syntax — emit the literal "[@" and move on.
936 result.push_str("[@");
937 remaining = &remaining[start + 2..];
938 }
939 result.push_str(remaining);
940 result
941 }
942
943 /// Opens a new workspace for a group that has no open workspaces.
944 fn open_workspace_for_group(
945 &mut self,
946 project_group_key: &ProjectGroupKey,
947 window: &mut Window,
948 cx: &mut Context<Self>,
949 ) {
950 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
951 return;
952 };
953 let path_list = project_group_key.path_list().clone();
954 let host = project_group_key.host();
955 let provisional_key = Some(project_group_key.clone());
956 let active_workspace = multi_workspace.read(cx).workspace().clone();
957 let modal_workspace = active_workspace.clone();
958
959 let task = multi_workspace.update(cx, |this, cx| {
960 this.find_or_create_workspace(
961 path_list,
962 host,
963 provisional_key,
964 |options, window, cx| connect_remote(active_workspace, options, window, cx),
965 window,
966 cx,
967 )
968 });
969
970 cx.spawn_in(window, async move |_this, cx| {
971 let result = task.await;
972 remote_connection::dismiss_connection_modal(&modal_workspace, cx);
973 result?;
974 anyhow::Ok(())
975 })
976 .detach_and_log_err(cx);
977 }
978
979 fn open_workspace_and_create_draft(
980 &mut self,
981 project_group_key: &ProjectGroupKey,
982 window: &mut Window,
983 cx: &mut Context<Self>,
984 ) {
985 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
986 return;
987 };
988
989 let path_list = project_group_key.path_list().clone();
990 let host = project_group_key.host();
991 let provisional_key = Some(project_group_key.clone());
992 let active_workspace = multi_workspace.read(cx).workspace().clone();
993
994 let task = multi_workspace.update(cx, |this, cx| {
995 this.find_or_create_workspace(
996 path_list,
997 host,
998 provisional_key,
999 |options, window, cx| connect_remote(active_workspace, options, window, cx),
1000 window,
1001 cx,
1002 )
1003 });
1004
1005 cx.spawn_in(window, async move |this, cx| {
1006 let workspace = task.await?;
1007 this.update_in(cx, |this, window, cx| {
1008 this.create_new_thread(&workspace, window, cx);
1009 })?;
1010 anyhow::Ok(())
1011 })
1012 .detach_and_log_err(cx);
1013 }
1014
1015 /// Rebuilds the sidebar contents from current workspace and thread state.
1016 ///
1017 /// Iterates [`MultiWorkspace::project_group_keys`] to determine project
1018 /// groups, then populates thread entries from the metadata store and
1019 /// merges live thread info from active agent panels.
1020 ///
1021 /// Aim for a single forward pass over workspaces and threads plus an
1022 /// O(T log T) sort. Avoid adding extra scans over the data.
1023 ///
1024 /// Properties:
1025 ///
1026 /// - Should always show every workspace in the multiworkspace
1027 /// - If you have no threads, and two workspaces for the worktree and the main workspace, make sure at least one is shown
1028 /// - Should always show every thread, associated with each workspace in the multiworkspace
1029 /// - After every build_contents, our "active" state should exactly match the current workspace's, current agent panel's current thread.
1030 fn rebuild_contents(&mut self, cx: &App) {
1031 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1032 return;
1033 };
1034 let mw = multi_workspace.read(cx);
1035 let workspaces: Vec<_> = mw.workspaces().cloned().collect();
1036 let active_workspace = Some(mw.workspace().clone());
1037
1038 let agent_server_store = workspaces
1039 .first()
1040 .map(|ws| ws.read(cx).project().read(cx).agent_server_store().clone());
1041
1042 let query = self.filter_editor.read(cx).text(cx);
1043
1044 let previous = mem::take(&mut self.contents);
1045
1046 let old_statuses: HashMap<acp::SessionId, AgentThreadStatus> = previous
1047 .entries
1048 .iter()
1049 .filter_map(|entry| match entry {
1050 ListEntry::Thread(thread) if thread.is_live => {
1051 let sid = thread.metadata.session_id.clone()?;
1052 Some((sid, thread.status))
1053 }
1054 _ => None,
1055 })
1056 .collect();
1057
1058 let mut entries = Vec::new();
1059 let mut notified_threads = previous.notified_threads;
1060 let mut current_session_ids: HashSet<acp::SessionId> = HashSet::new();
1061 let mut current_thread_ids: HashSet<agent_ui::ThreadId> = HashSet::new();
1062 let mut project_header_indices: Vec<usize> = Vec::new();
1063 let mut seen_thread_ids: HashSet<agent_ui::ThreadId> = HashSet::new();
1064
1065 let has_open_projects = workspaces
1066 .iter()
1067 .any(|ws| !workspace_path_list(ws, cx).paths().is_empty());
1068
1069 let resolve_agent_icon = |agent_id: &AgentId| -> (IconName, Option<SharedString>) {
1070 let agent = Agent::from(agent_id.clone());
1071 let icon = match agent {
1072 Agent::NativeAgent => IconName::ZedAgent,
1073 Agent::Custom { .. } => IconName::Terminal,
1074 };
1075 let icon_from_external_svg = agent_server_store
1076 .as_ref()
1077 .and_then(|store| store.read(cx).agent_icon(&agent_id));
1078 (icon, icon_from_external_svg)
1079 };
1080
1081 let groups = mw.project_groups(cx);
1082
1083 let mut all_paths: Vec<PathBuf> = groups
1084 .iter()
1085 .flat_map(|group| group.key.path_list().paths().iter().cloned())
1086 .collect();
1087 all_paths.sort();
1088 all_paths.dedup();
1089 let path_details =
1090 util::disambiguate::compute_disambiguation_details(&all_paths, |path, detail| {
1091 project::path_suffix(path, detail)
1092 });
1093 let path_detail_map: HashMap<PathBuf, usize> =
1094 all_paths.into_iter().zip(path_details).collect();
1095
1096 for group in &groups {
1097 let group_key = &group.key;
1098 let group_workspaces = &group.workspaces;
1099 if group_key.path_list().paths().is_empty() {
1100 continue;
1101 }
1102
1103 let label = group_key.display_name(&path_detail_map);
1104
1105 let is_collapsed = self.is_group_collapsed(group_key, cx);
1106 let should_load_threads = !is_collapsed || !query.is_empty();
1107
1108 let is_active = active_workspace
1109 .as_ref()
1110 .is_some_and(|active| group_workspaces.contains(active));
1111
1112 // Collect live thread infos from all workspaces in this group.
1113 let live_infos: Vec<_> = group_workspaces
1114 .iter()
1115 .flat_map(|ws| all_thread_infos_for_workspace(ws, cx))
1116 .collect();
1117
1118 let mut threads: Vec<ThreadEntry> = Vec::new();
1119 let mut has_running_threads = false;
1120 let mut waiting_thread_count: usize = 0;
1121
1122 if should_load_threads {
1123 let thread_store = ThreadMetadataStore::global(cx);
1124
1125 // Build a lookup from workspace root paths to their workspace
1126 // entity, used to assign ThreadEntryWorkspace::Open for threads
1127 // whose folder_paths match an open workspace.
1128 let workspace_by_path_list: HashMap<PathList, &Entity<Workspace>> =
1129 group_workspaces
1130 .iter()
1131 .map(|ws| (workspace_path_list(ws, cx), ws))
1132 .collect();
1133
1134 // Resolve a ThreadEntryWorkspace for a thread row. If any open
1135 // workspace's root paths match the thread's folder_paths, use
1136 // Open; otherwise use Closed.
1137 let resolve_workspace = |row: &ThreadMetadata| -> ThreadEntryWorkspace {
1138 workspace_by_path_list
1139 .get(row.folder_paths())
1140 .map(|ws| ThreadEntryWorkspace::Open((*ws).clone()))
1141 .unwrap_or_else(|| ThreadEntryWorkspace::Closed {
1142 folder_paths: row.folder_paths().clone(),
1143 project_group_key: group_key.clone(),
1144 })
1145 };
1146
1147 // Build a ThreadEntry from a metadata row.
1148 let make_thread_entry =
1149 |row: ThreadMetadata, workspace: ThreadEntryWorkspace| -> ThreadEntry {
1150 let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id);
1151 let worktrees = worktree_info_from_thread_paths(&row.worktree_paths);
1152 let is_draft = row.is_draft();
1153 ThreadEntry {
1154 metadata: row,
1155 icon,
1156 icon_from_external_svg,
1157 status: AgentThreadStatus::default(),
1158 workspace,
1159 is_live: false,
1160 is_background: false,
1161 is_title_generating: false,
1162 is_draft,
1163 highlight_positions: Vec::new(),
1164 worktrees,
1165 diff_stats: DiffStats::default(),
1166 }
1167 };
1168
1169 // Main code path: one query per group via main_worktree_paths.
1170 // The main_worktree_paths column is set on all new threads and
1171 // points to the group's canonical paths regardless of which
1172 // linked worktree the thread was opened in.
1173 for row in thread_store
1174 .read(cx)
1175 .entries_for_main_worktree_path(group_key.path_list())
1176 .cloned()
1177 {
1178 if !seen_thread_ids.insert(row.thread_id) {
1179 continue;
1180 }
1181 let workspace = resolve_workspace(&row);
1182 threads.push(make_thread_entry(row, workspace));
1183 }
1184
1185 // Legacy threads did not have `main_worktree_paths` populated, so they
1186 // must be queried by their `folder_paths`.
1187
1188 // Load any legacy threads for the main worktrees of this project group.
1189 for row in thread_store
1190 .read(cx)
1191 .entries_for_path(group_key.path_list())
1192 .cloned()
1193 {
1194 if !seen_thread_ids.insert(row.thread_id) {
1195 continue;
1196 }
1197 let workspace = resolve_workspace(&row);
1198 threads.push(make_thread_entry(row, workspace));
1199 }
1200
1201 // Load any legacy threads for any single linked wortree of this project group.
1202 let mut linked_worktree_paths = HashSet::new();
1203 for workspace in group_workspaces {
1204 if workspace.read(cx).visible_worktrees(cx).count() != 1 {
1205 continue;
1206 }
1207 for snapshot in root_repository_snapshots(workspace, cx) {
1208 for linked_worktree in snapshot.linked_worktrees() {
1209 linked_worktree_paths.insert(linked_worktree.path.clone());
1210 }
1211 }
1212 }
1213 for path in linked_worktree_paths {
1214 let worktree_path_list = PathList::new(std::slice::from_ref(&path));
1215 for row in thread_store
1216 .read(cx)
1217 .entries_for_path(&worktree_path_list)
1218 .cloned()
1219 {
1220 if !seen_thread_ids.insert(row.thread_id) {
1221 continue;
1222 }
1223 threads.push(make_thread_entry(
1224 row,
1225 ThreadEntryWorkspace::Closed {
1226 folder_paths: worktree_path_list.clone(),
1227 project_group_key: group_key.clone(),
1228 },
1229 ));
1230 }
1231 }
1232
1233 // Build a lookup from live_infos and compute running/waiting
1234 // counts in a single pass.
1235 let mut live_info_by_session: HashMap<&acp::SessionId, &ActiveThreadInfo> =
1236 HashMap::new();
1237 for info in &live_infos {
1238 live_info_by_session.insert(&info.session_id, info);
1239 if info.status == AgentThreadStatus::Running {
1240 has_running_threads = true;
1241 }
1242 if info.status == AgentThreadStatus::WaitingForConfirmation {
1243 waiting_thread_count += 1;
1244 }
1245 }
1246
1247 // Merge live info into threads and update notification state
1248 // in a single pass.
1249 for thread in &mut threads {
1250 if let Some(session_id) = &thread.metadata.session_id {
1251 if let Some(info) = live_info_by_session.get(session_id) {
1252 thread.apply_active_info(info);
1253 }
1254 }
1255
1256 let session_id = &thread.metadata.session_id;
1257 let is_active_thread = self.active_entry.as_ref().is_some_and(|entry| {
1258 entry.is_active_thread(&thread.metadata.thread_id)
1259 && active_workspace
1260 .as_ref()
1261 .is_some_and(|active| active == entry.workspace())
1262 });
1263
1264 if thread.status == AgentThreadStatus::Completed
1265 && !is_active_thread
1266 && session_id.as_ref().and_then(|sid| old_statuses.get(sid))
1267 == Some(&AgentThreadStatus::Running)
1268 {
1269 notified_threads.insert(thread.metadata.thread_id);
1270 }
1271
1272 if is_active_thread && !thread.is_background {
1273 notified_threads.remove(&thread.metadata.thread_id);
1274 }
1275 }
1276
1277 threads.sort_by(|a, b| {
1278 let a_time = a
1279 .metadata
1280 .session_id
1281 .as_ref()
1282 .and_then(|_sid| {
1283 self.thread_last_message_sent_or_queued
1284 .get(&a.metadata.thread_id)
1285 })
1286 .copied()
1287 .or(a.metadata.created_at)
1288 .or(Some(a.metadata.updated_at));
1289 let b_time = b
1290 .metadata
1291 .session_id
1292 .as_ref()
1293 .and_then(|_sid| {
1294 self.thread_last_message_sent_or_queued
1295 .get(&b.metadata.thread_id)
1296 })
1297 .copied()
1298 .or(b.metadata.created_at)
1299 .or(Some(b.metadata.updated_at));
1300 b_time.cmp(&a_time)
1301 });
1302 } else {
1303 for info in live_infos {
1304 if info.status == AgentThreadStatus::Running {
1305 has_running_threads = true;
1306 }
1307 if info.status == AgentThreadStatus::WaitingForConfirmation {
1308 waiting_thread_count += 1;
1309 }
1310 }
1311 }
1312
1313 let has_threads = if !threads.is_empty() {
1314 true
1315 } else {
1316 let store = ThreadMetadataStore::global(cx).read(cx);
1317 store
1318 .entries_for_main_worktree_path(group_key.path_list())
1319 .next()
1320 .is_some()
1321 || store
1322 .entries_for_path(group_key.path_list())
1323 .next()
1324 .is_some()
1325 };
1326
1327 if !query.is_empty() {
1328 let workspace_highlight_positions =
1329 fuzzy_match_positions(&query, &label).unwrap_or_default();
1330 let workspace_matched = !workspace_highlight_positions.is_empty();
1331
1332 let mut matched_threads: Vec<ThreadEntry> = Vec::new();
1333 for mut thread in threads {
1334 let title: &str = thread
1335 .metadata
1336 .title
1337 .as_ref()
1338 .map_or(DEFAULT_THREAD_TITLE, |t| t.as_ref());
1339 if let Some(positions) = fuzzy_match_positions(&query, title) {
1340 thread.highlight_positions = positions;
1341 }
1342 let mut worktree_matched = false;
1343 for worktree in &mut thread.worktrees {
1344 if let Some(positions) = fuzzy_match_positions(&query, &worktree.name) {
1345 worktree.highlight_positions = positions;
1346 worktree_matched = true;
1347 }
1348 }
1349 if workspace_matched
1350 || !thread.highlight_positions.is_empty()
1351 || worktree_matched
1352 {
1353 matched_threads.push(thread);
1354 }
1355 }
1356
1357 if matched_threads.is_empty() && !workspace_matched {
1358 continue;
1359 }
1360
1361 project_header_indices.push(entries.len());
1362 entries.push(ListEntry::ProjectHeader {
1363 key: group_key.clone(),
1364 label,
1365 highlight_positions: workspace_highlight_positions,
1366 has_running_threads,
1367 waiting_thread_count,
1368 is_active,
1369 has_threads,
1370 });
1371
1372 for thread in matched_threads {
1373 if let Some(sid) = thread.metadata.session_id.clone() {
1374 current_session_ids.insert(sid);
1375 }
1376 current_thread_ids.insert(thread.metadata.thread_id);
1377 entries.push(thread.into());
1378 }
1379 } else {
1380 project_header_indices.push(entries.len());
1381 entries.push(ListEntry::ProjectHeader {
1382 key: group_key.clone(),
1383 label,
1384 highlight_positions: Vec::new(),
1385 has_running_threads,
1386 waiting_thread_count,
1387 is_active,
1388 has_threads,
1389 });
1390
1391 if is_collapsed {
1392 continue;
1393 }
1394
1395 {
1396 // Override titles with editor text for drafts and
1397 // threads that still have the default placeholder
1398 // title (panel considers them drafts even if they
1399 // have a session_id).
1400 for thread in &mut threads {
1401 let needs_title_override =
1402 thread.is_draft || thread.metadata.title.is_none();
1403 if needs_title_override {
1404 if let ThreadEntryWorkspace::Open(workspace) = &thread.workspace {
1405 if let Some(text) =
1406 self.read_draft_text(thread.metadata.thread_id, workspace, cx)
1407 {
1408 thread.metadata.title = Some(text);
1409 }
1410 }
1411 }
1412 }
1413 }
1414
1415 let total = threads.len();
1416
1417 let extra_batches = self.group_extra_batches(&group_key, cx);
1418 let threads_to_show =
1419 DEFAULT_THREADS_SHOWN + (extra_batches * DEFAULT_THREADS_SHOWN);
1420 let count = threads_to_show.min(total);
1421
1422 let mut promoted_threads: HashSet<agent_ui::ThreadId> = HashSet::new();
1423
1424 // Build visible entries in a single pass. Threads within
1425 // the cutoff are always shown. Threads beyond it are shown
1426 // only if they should be promoted (running, waiting, or
1427 // focused)
1428 for (index, thread) in threads.into_iter().enumerate() {
1429 let is_hidden = index >= count;
1430
1431 if is_hidden {
1432 let is_notified = notified_threads.contains(&thread.metadata.thread_id);
1433 let is_promoted = thread.status == AgentThreadStatus::Running
1434 || thread.status == AgentThreadStatus::WaitingForConfirmation
1435 || is_notified
1436 || self.active_entry.as_ref().is_some_and(|active| {
1437 active.matches_entry(&ListEntry::Thread(thread.clone()))
1438 });
1439 if is_promoted {
1440 promoted_threads.insert(thread.metadata.thread_id);
1441 }
1442 let is_in_promoted = promoted_threads.contains(&thread.metadata.thread_id);
1443 if !is_in_promoted {
1444 continue;
1445 }
1446 }
1447
1448 if let Some(sid) = &thread.metadata.session_id {
1449 current_session_ids.insert(sid.clone());
1450 }
1451 current_thread_ids.insert(thread.metadata.thread_id);
1452 entries.push(thread.into());
1453 }
1454
1455 let visible = count + promoted_threads.len();
1456 let is_fully_expanded = visible >= total;
1457
1458 if total > DEFAULT_THREADS_SHOWN {
1459 entries.push(ListEntry::ViewMore {
1460 key: group_key.clone(),
1461 is_fully_expanded,
1462 });
1463 }
1464 }
1465 }
1466
1467 notified_threads.retain(|id| current_thread_ids.contains(id));
1468
1469 self.thread_last_accessed
1470 .retain(|id, _| current_session_ids.contains(id));
1471 self.thread_last_message_sent_or_queued
1472 .retain(|id, _| current_thread_ids.contains(id));
1473
1474 self.contents = SidebarContents {
1475 entries,
1476 notified_threads,
1477 project_header_indices,
1478 has_open_projects,
1479 };
1480 }
1481
1482 /// Rebuilds the sidebar's visible entries from already-cached state.
1483 fn update_entries(&mut self, cx: &mut Context<Self>) {
1484 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1485 return;
1486 };
1487 if !multi_workspace.read(cx).multi_workspace_enabled(cx) {
1488 return;
1489 }
1490
1491 let had_notifications = self.has_notifications(cx);
1492 let scroll_position = self.list_state.logical_scroll_top();
1493
1494 self.rebuild_contents(cx);
1495
1496 self.list_state.reset(self.contents.entries.len());
1497 self.list_state.scroll_to(scroll_position);
1498
1499 if had_notifications != self.has_notifications(cx) {
1500 multi_workspace.update(cx, |_, cx| {
1501 cx.notify();
1502 });
1503 }
1504
1505 cx.notify();
1506 }
1507
1508 fn select_first_entry(&mut self) {
1509 self.selection = self
1510 .contents
1511 .entries
1512 .iter()
1513 .position(|entry| matches!(entry, ListEntry::Thread(_)))
1514 .or_else(|| {
1515 if self.contents.entries.is_empty() {
1516 None
1517 } else {
1518 Some(0)
1519 }
1520 });
1521 }
1522
1523 fn render_list_entry(
1524 &mut self,
1525 ix: usize,
1526 window: &mut Window,
1527 cx: &mut Context<Self>,
1528 ) -> AnyElement {
1529 let Some(entry) = self.contents.entries.get(ix) else {
1530 return div().into_any_element();
1531 };
1532 let is_focused = self.focus_handle.is_focused(window);
1533 // is_selected means the keyboard selector is here.
1534 let is_selected = is_focused && self.selection == Some(ix);
1535
1536 let is_group_header_after_first =
1537 ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. });
1538
1539 let is_active = self
1540 .active_entry
1541 .as_ref()
1542 .is_some_and(|active| active.matches_entry(entry));
1543
1544 let rendered = match entry {
1545 ListEntry::ProjectHeader {
1546 key,
1547 label,
1548 highlight_positions,
1549 has_running_threads,
1550 waiting_thread_count,
1551 is_active: is_active_group,
1552 has_threads,
1553 } => self.render_project_header(
1554 ix,
1555 false,
1556 key,
1557 label,
1558 highlight_positions,
1559 *has_running_threads,
1560 *waiting_thread_count,
1561 *is_active_group,
1562 is_selected,
1563 *has_threads,
1564 cx,
1565 ),
1566 ListEntry::Thread(thread) => self.render_thread(ix, thread, is_active, is_selected, cx),
1567 ListEntry::ViewMore {
1568 key,
1569 is_fully_expanded,
1570 } => self.render_view_more(ix, key, *is_fully_expanded, is_selected, cx),
1571 };
1572
1573 if is_group_header_after_first {
1574 v_flex()
1575 .w_full()
1576 .border_t_1()
1577 .border_color(cx.theme().colors().border)
1578 .child(rendered)
1579 .into_any_element()
1580 } else {
1581 rendered
1582 }
1583 }
1584
1585 fn render_remote_project_icon(
1586 &self,
1587 ix: usize,
1588 host: Option<&RemoteConnectionOptions>,
1589 ) -> Option<AnyElement> {
1590 let remote_icon_per_type = match host? {
1591 RemoteConnectionOptions::Wsl(_) => IconName::Linux,
1592 RemoteConnectionOptions::Docker(_) => IconName::Box,
1593 _ => IconName::Server,
1594 };
1595
1596 Some(
1597 div()
1598 .id(format!("remote-project-icon-{}", ix))
1599 .child(
1600 Icon::new(remote_icon_per_type)
1601 .size(IconSize::XSmall)
1602 .color(Color::Muted),
1603 )
1604 .tooltip(Tooltip::text("Remote Project"))
1605 .into_any_element(),
1606 )
1607 }
1608
1609 fn render_project_header(
1610 &self,
1611 ix: usize,
1612 is_sticky: bool,
1613 key: &ProjectGroupKey,
1614 label: &SharedString,
1615 highlight_positions: &[usize],
1616 has_running_threads: bool,
1617 waiting_thread_count: usize,
1618 is_active: bool,
1619 is_focused: bool,
1620 has_threads: bool,
1621 cx: &mut Context<Self>,
1622 ) -> AnyElement {
1623 let host = key.host();
1624
1625 let id_prefix = if is_sticky { "sticky-" } else { "" };
1626 let id = SharedString::from(format!("{id_prefix}project-header-{ix}"));
1627 let disclosure_id = SharedString::from(format!("disclosure-{ix}"));
1628 let group_name = SharedString::from(format!("{id_prefix}header-group-{ix}"));
1629
1630 let is_collapsed = !has_threads || self.is_group_collapsed(key, cx);
1631 let (disclosure_icon, disclosure_tooltip) = if is_collapsed {
1632 (IconName::ChevronRight, "Expand Project")
1633 } else {
1634 (IconName::ChevronDown, "Collapse Project")
1635 };
1636
1637 let key_for_toggle = key.clone();
1638 let key_for_collapse = key.clone();
1639 let view_more_expanded = self.group_extra_batches(key, cx) > 0;
1640
1641 let label = if highlight_positions.is_empty() {
1642 Label::new(label.clone())
1643 .when(!is_active, |this| this.color(Color::Muted))
1644 .into_any_element()
1645 } else {
1646 HighlightedLabel::new(label.clone(), highlight_positions.to_vec())
1647 .when(!is_active, |this| this.color(Color::Muted))
1648 .into_any_element()
1649 };
1650
1651 let color = cx.theme().colors();
1652 let sidebar_base_bg = color
1653 .title_bar_background
1654 .blend(color.panel_background.opacity(0.25));
1655
1656 let base_bg = color.background.blend(sidebar_base_bg);
1657
1658 let hover_base = color
1659 .element_active
1660 .blend(color.element_background.opacity(0.2));
1661 let hover_solid = base_bg.blend(hover_base);
1662 let real_hover_color = if is_active { base_bg } else { hover_solid };
1663
1664 let group_name_for_gradient = group_name.clone();
1665 let gradient_overlay = move || {
1666 GradientFade::new(base_bg, real_hover_color, real_hover_color)
1667 .width(px(64.0))
1668 .right(px(-2.0))
1669 .gradient_stop(0.75)
1670 .group_name(group_name_for_gradient.clone())
1671 };
1672
1673 let is_ellipsis_menu_open = self.project_header_menu_ix == Some(ix);
1674
1675 h_flex()
1676 .id(id)
1677 .group(&group_name)
1678 .h(Tab::content_height(cx))
1679 .relative()
1680 .w_full()
1681 .pl(px(5.))
1682 .pr_1p5()
1683 .justify_between()
1684 .border_1()
1685 .map(|this| {
1686 if is_focused {
1687 this.border_color(color.border_focused)
1688 } else {
1689 this.border_color(gpui::transparent_black())
1690 }
1691 })
1692 .child(
1693 h_flex()
1694 .relative()
1695 .min_w_0()
1696 .w_full()
1697 .gap(px(5.))
1698 .child(
1699 IconButton::new(disclosure_id, disclosure_icon)
1700 .shape(ui::IconButtonShape::Square)
1701 .icon_size(IconSize::Small)
1702 .icon_color(if has_threads {
1703 Color::Custom(cx.theme().colors().icon_muted.opacity(0.5))
1704 } else {
1705 Color::Custom(cx.theme().colors().icon_disabled)
1706 })
1707 .when(has_threads, |this| {
1708 this.tooltip(Tooltip::text(disclosure_tooltip)).on_click(
1709 cx.listener(move |this, _, window, cx| {
1710 this.selection = None;
1711 this.toggle_collapse(&key_for_toggle, window, cx);
1712 }),
1713 )
1714 }),
1715 )
1716 .child(label)
1717 .when_some(
1718 self.render_remote_project_icon(ix, host.as_ref()),
1719 |this, icon| this.child(icon),
1720 )
1721 .when(is_collapsed, |this| {
1722 this.when(has_running_threads, |this| {
1723 this.child(
1724 Icon::new(IconName::LoadCircle)
1725 .size(IconSize::XSmall)
1726 .color(Color::Muted)
1727 .with_rotate_animation(2),
1728 )
1729 })
1730 .when(waiting_thread_count > 0, |this| {
1731 let tooltip_text = if waiting_thread_count == 1 {
1732 "1 thread is waiting for confirmation".to_string()
1733 } else {
1734 format!(
1735 "{waiting_thread_count} threads are waiting for confirmation",
1736 )
1737 };
1738 this.child(
1739 div()
1740 .id(format!("{id_prefix}waiting-indicator-{ix}"))
1741 .child(
1742 Icon::new(IconName::Warning)
1743 .size(IconSize::XSmall)
1744 .color(Color::Warning),
1745 )
1746 .tooltip(Tooltip::text(tooltip_text)),
1747 )
1748 })
1749 }),
1750 )
1751 .child(gradient_overlay())
1752 .child(
1753 h_flex()
1754 .when(!is_ellipsis_menu_open, |this| {
1755 this.visible_on_hover(&group_name)
1756 })
1757 .child(gradient_overlay())
1758 .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
1759 cx.stop_propagation();
1760 })
1761 .child(self.render_project_header_ellipsis_menu(ix, id_prefix, key, cx))
1762 .when(has_threads && view_more_expanded && !is_collapsed, |this| {
1763 this.child(
1764 IconButton::new(
1765 SharedString::from(format!(
1766 "{id_prefix}project-header-collapse-{ix}",
1767 )),
1768 IconName::ListCollapse,
1769 )
1770 .icon_size(IconSize::Small)
1771 .tooltip(Tooltip::text("Collapse Displayed Threads"))
1772 .on_click(cx.listener({
1773 let key_for_collapse = key_for_collapse.clone();
1774 move |this, _, _window, cx| {
1775 this.selection = None;
1776 this.set_group_visible_thread_count(
1777 &key_for_collapse,
1778 None,
1779 cx,
1780 );
1781 this.update_entries(cx);
1782 }
1783 })),
1784 )
1785 })
1786 .child({
1787 let key = key.clone();
1788 let focus_handle = self.focus_handle.clone();
1789
1790 IconButton::new(
1791 SharedString::from(format!(
1792 "{id_prefix}project-header-new-thread-{ix}",
1793 )),
1794 IconName::Plus,
1795 )
1796 .icon_size(IconSize::Small)
1797 .tooltip(move |_, cx| {
1798 Tooltip::for_action_in(
1799 "Start New Agent Thread",
1800 &NewThread,
1801 &focus_handle,
1802 cx,
1803 )
1804 })
1805 .on_click(cx.listener(
1806 move |this, _, window, cx| {
1807 this.set_group_expanded(&key, true, cx);
1808 this.selection = None;
1809 let workspace = this.multi_workspace.upgrade().and_then(|mw| {
1810 let mw = mw.read(cx);
1811 let active = mw.workspace().clone();
1812 let active_key = active.read(cx).project_group_key(cx);
1813 if active_key == key {
1814 Some(active)
1815 } else {
1816 mw.workspace_for_paths(
1817 key.path_list(),
1818 key.host().as_ref(),
1819 cx,
1820 )
1821 }
1822 });
1823 if let Some(workspace) = workspace {
1824 this.create_new_thread(&workspace, window, cx);
1825 } else {
1826 this.open_workspace_and_create_draft(&key, window, cx);
1827 }
1828 },
1829 ))
1830 }),
1831 )
1832 .cursor_pointer()
1833 .when(!is_active, |this| this.hover(|s| s.bg(hover_solid)))
1834 .tooltip(Tooltip::text(disclosure_tooltip))
1835 .on_click(cx.listener(move |this, _, window, cx| {
1836 this.selection = None;
1837 this.toggle_collapse(&key_for_collapse, window, cx);
1838 }))
1839 .into_any_element()
1840 }
1841
1842 fn render_project_header_ellipsis_menu(
1843 &self,
1844 ix: usize,
1845 id_prefix: &str,
1846 project_group_key: &ProjectGroupKey,
1847 cx: &mut Context<Self>,
1848 ) -> impl IntoElement {
1849 let multi_workspace = self.multi_workspace.clone();
1850 let this = cx.weak_entity();
1851 let project_group_key = project_group_key.clone();
1852
1853 PopoverMenu::new(format!("{id_prefix}project-header-menu-{ix}"))
1854 .on_open(Rc::new({
1855 let this = this.clone();
1856 move |_window, cx| {
1857 this.update(cx, |sidebar, cx| {
1858 sidebar.project_header_menu_ix = Some(ix);
1859 cx.notify();
1860 })
1861 .ok();
1862 }
1863 }))
1864 .menu(move |window, cx| {
1865 let multi_workspace = multi_workspace.clone();
1866 let project_group_key = project_group_key.clone();
1867
1868 let has_multiple_projects = multi_workspace
1869 .read_with(cx, |mw, _| mw.project_group_keys().len() >= 2)
1870 .unwrap_or(false);
1871
1872 let menu =
1873 ContextMenu::build_persistent(window, cx, move |menu, _window, menu_cx| {
1874 let weak_menu = menu_cx.weak_entity();
1875 let mut menu = menu
1876 .header("Project Folders")
1877 .end_slot_action(Box::new(menu::EndSlot));
1878
1879 for path in project_group_key.path_list().paths() {
1880 let Some(name) = path.file_name() else {
1881 continue;
1882 };
1883 let name: SharedString = name.to_string_lossy().into_owned().into();
1884 let path = path.clone();
1885 let project_group_key = project_group_key.clone();
1886 let multi_workspace = multi_workspace.clone();
1887 let weak_menu = weak_menu.clone();
1888 menu = menu.entry_with_end_slot_on_hover(
1889 name.clone(),
1890 None,
1891 |_, _| {},
1892 IconName::Close,
1893 "Remove Folder".into(),
1894 move |_window, cx| {
1895 multi_workspace
1896 .update(cx, |multi_workspace, cx| {
1897 multi_workspace.remove_folder_from_project_group(
1898 &project_group_key,
1899 &path,
1900 cx,
1901 );
1902 })
1903 .ok();
1904 weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
1905 },
1906 );
1907 }
1908
1909 let menu = menu.separator().entry(
1910 "Add Folder to Project",
1911 Some(Box::new(AddFolderToProject)),
1912 {
1913 let project_group_key = project_group_key.clone();
1914 let multi_workspace = multi_workspace.clone();
1915 let weak_menu = weak_menu.clone();
1916 move |window, cx| {
1917 multi_workspace
1918 .update(cx, |multi_workspace, cx| {
1919 multi_workspace.prompt_to_add_folders_to_project_group(
1920 project_group_key.clone(),
1921 window,
1922 cx,
1923 );
1924 })
1925 .ok();
1926 weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
1927 }
1928 },
1929 );
1930
1931 let menu = if project_group_key.host().is_none() && has_multiple_projects {
1932 menu.entry(
1933 "Open Project in New Window",
1934 Some(Box::new(workspace::MoveProjectToNewWindow)),
1935 {
1936 let project_group_key = project_group_key.clone();
1937 let multi_workspace = multi_workspace.clone();
1938 move |window, cx| {
1939 multi_workspace
1940 .update(cx, |multi_workspace, cx| {
1941 multi_workspace
1942 .open_project_group_in_new_window(
1943 &project_group_key,
1944 window,
1945 cx,
1946 )
1947 .detach_and_log_err(cx);
1948 })
1949 .ok();
1950 }
1951 },
1952 )
1953 } else {
1954 menu
1955 };
1956
1957 let project_group_key = project_group_key.clone();
1958 let multi_workspace = multi_workspace.clone();
1959 menu.separator()
1960 .entry("Remove Project", None, move |window, cx| {
1961 multi_workspace
1962 .update(cx, |multi_workspace, cx| {
1963 multi_workspace
1964 .remove_project_group(&project_group_key, window, cx)
1965 .detach_and_log_err(cx);
1966 })
1967 .ok();
1968 weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
1969 })
1970 });
1971
1972 let this = this.clone();
1973 window
1974 .subscribe(&menu, cx, move |_, _: &gpui::DismissEvent, _window, cx| {
1975 this.update(cx, |sidebar, cx| {
1976 sidebar.project_header_menu_ix = None;
1977 cx.notify();
1978 })
1979 .ok();
1980 })
1981 .detach();
1982
1983 Some(menu)
1984 })
1985 .trigger(
1986 IconButton::new(
1987 SharedString::from(format!("{id_prefix}-ellipsis-menu-{ix}")),
1988 IconName::Ellipsis,
1989 )
1990 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1991 .icon_size(IconSize::Small),
1992 )
1993 .anchor(gpui::Corner::TopRight)
1994 .offset(gpui::Point {
1995 x: px(0.),
1996 y: px(1.),
1997 })
1998 }
1999
2000 fn render_sticky_header(
2001 &self,
2002 window: &mut Window,
2003 cx: &mut Context<Self>,
2004 ) -> Option<AnyElement> {
2005 let scroll_top = self.list_state.logical_scroll_top();
2006
2007 let &header_idx = self
2008 .contents
2009 .project_header_indices
2010 .iter()
2011 .rev()
2012 .find(|&&idx| idx <= scroll_top.item_ix)?;
2013
2014 let needs_sticky = header_idx < scroll_top.item_ix
2015 || (header_idx == scroll_top.item_ix && scroll_top.offset_in_item > px(0.));
2016
2017 if !needs_sticky {
2018 return None;
2019 }
2020
2021 let ListEntry::ProjectHeader {
2022 key,
2023 label,
2024 highlight_positions,
2025 has_running_threads,
2026 waiting_thread_count,
2027 is_active,
2028 has_threads,
2029 } = self.contents.entries.get(header_idx)?
2030 else {
2031 return None;
2032 };
2033
2034 let is_focused = self.focus_handle.is_focused(window);
2035 let is_selected = is_focused && self.selection == Some(header_idx);
2036
2037 let header_element = self.render_project_header(
2038 header_idx,
2039 true,
2040 key,
2041 &label,
2042 &highlight_positions,
2043 *has_running_threads,
2044 *waiting_thread_count,
2045 *is_active,
2046 *has_threads,
2047 is_selected,
2048 cx,
2049 );
2050
2051 let top_offset = self
2052 .contents
2053 .project_header_indices
2054 .iter()
2055 .find(|&&idx| idx > header_idx)
2056 .and_then(|&next_idx| {
2057 let bounds = self.list_state.bounds_for_item(next_idx)?;
2058 let viewport = self.list_state.viewport_bounds();
2059 let y_in_viewport = bounds.origin.y - viewport.origin.y;
2060 let header_height = bounds.size.height;
2061 (y_in_viewport < header_height).then_some(y_in_viewport - header_height)
2062 })
2063 .unwrap_or(px(0.));
2064
2065 let color = cx.theme().colors();
2066 let background = color
2067 .title_bar_background
2068 .blend(color.panel_background.opacity(0.2));
2069
2070 let element = v_flex()
2071 .absolute()
2072 .top(top_offset)
2073 .left_0()
2074 .w_full()
2075 .bg(background)
2076 .border_b_1()
2077 .border_color(color.border.opacity(0.5))
2078 .child(header_element)
2079 .shadow_xs()
2080 .into_any_element();
2081
2082 Some(element)
2083 }
2084
2085 fn toggle_collapse(
2086 &mut self,
2087 project_group_key: &ProjectGroupKey,
2088 _window: &mut Window,
2089 cx: &mut Context<Self>,
2090 ) {
2091 let is_collapsed = self.is_group_collapsed(project_group_key, cx);
2092 self.set_group_expanded(project_group_key, is_collapsed, cx);
2093 self.update_entries(cx);
2094 }
2095
2096 fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
2097 let mut dispatch_context = KeyContext::new_with_defaults();
2098 dispatch_context.add("ThreadsSidebar");
2099 dispatch_context.add("menu");
2100
2101 let is_archived_search_focused = matches!(&self.view, SidebarView::Archive(archive) if archive.read(cx).is_filter_editor_focused(window, cx));
2102
2103 let identifier = if self.filter_editor.focus_handle(cx).is_focused(window)
2104 || is_archived_search_focused
2105 {
2106 "searching"
2107 } else {
2108 "not_searching"
2109 };
2110
2111 dispatch_context.add(identifier);
2112 dispatch_context
2113 }
2114
2115 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2116 if !self.focus_handle.is_focused(window) {
2117 return;
2118 }
2119
2120 if let SidebarView::Archive(archive) = &self.view {
2121 let has_selection = archive.read(cx).has_selection();
2122 if !has_selection {
2123 archive.update(cx, |view, cx| view.focus_filter_editor(window, cx));
2124 }
2125 } else if self.selection.is_none() {
2126 self.filter_editor.focus_handle(cx).focus(window, cx);
2127 }
2128 }
2129
2130 fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
2131 if self.reset_filter_editor_text(window, cx) {
2132 self.update_entries(cx);
2133 } else {
2134 self.selection = None;
2135 self.filter_editor.focus_handle(cx).focus(window, cx);
2136 cx.notify();
2137 }
2138 }
2139
2140 fn focus_sidebar_filter(
2141 &mut self,
2142 _: &FocusSidebarFilter,
2143 window: &mut Window,
2144 cx: &mut Context<Self>,
2145 ) {
2146 self.selection = None;
2147 if let SidebarView::Archive(archive) = &self.view {
2148 archive.update(cx, |view, cx| {
2149 view.clear_selection();
2150 view.focus_filter_editor(window, cx);
2151 });
2152 } else {
2153 self.filter_editor.focus_handle(cx).focus(window, cx);
2154 }
2155
2156 // When vim mode is active, the editor defaults to normal mode which
2157 // blocks text input. Switch to insert mode so the user can type
2158 // immediately.
2159 if vim_mode_setting::VimModeSetting::get_global(cx).0 {
2160 if let Ok(action) = cx.build_action("vim::SwitchToInsertMode", None) {
2161 window.dispatch_action(action, cx);
2162 }
2163 }
2164
2165 cx.notify();
2166 }
2167
2168 fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
2169 self.filter_editor.update(cx, |editor, cx| {
2170 if editor.buffer().read(cx).len(cx).0 > 0 {
2171 editor.set_text("", window, cx);
2172 true
2173 } else {
2174 false
2175 }
2176 })
2177 }
2178
2179 fn has_filter_query(&self, cx: &App) -> bool {
2180 !self.filter_editor.read(cx).text(cx).is_empty()
2181 }
2182
2183 fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
2184 self.select_next(&SelectNext, window, cx);
2185 if self.selection.is_some() {
2186 self.focus_handle.focus(window, cx);
2187 }
2188 }
2189
2190 fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
2191 self.select_previous(&SelectPrevious, window, cx);
2192 if self.selection.is_some() {
2193 self.focus_handle.focus(window, cx);
2194 }
2195 }
2196
2197 fn editor_confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2198 if self.selection.is_none() {
2199 self.select_next(&SelectNext, window, cx);
2200 }
2201 if self.selection.is_some() {
2202 self.focus_handle.focus(window, cx);
2203 }
2204 }
2205
2206 fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
2207 let next = match self.selection {
2208 Some(ix) if ix + 1 < self.contents.entries.len() => ix + 1,
2209 Some(_) if !self.contents.entries.is_empty() => 0,
2210 None if !self.contents.entries.is_empty() => 0,
2211 _ => return,
2212 };
2213 self.selection = Some(next);
2214 self.list_state.scroll_to_reveal_item(next);
2215 cx.notify();
2216 }
2217
2218 fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
2219 match self.selection {
2220 Some(0) => {
2221 self.selection = None;
2222 self.filter_editor.focus_handle(cx).focus(window, cx);
2223 cx.notify();
2224 }
2225 Some(ix) => {
2226 self.selection = Some(ix - 1);
2227 self.list_state.scroll_to_reveal_item(ix - 1);
2228 cx.notify();
2229 }
2230 None if !self.contents.entries.is_empty() => {
2231 let last = self.contents.entries.len() - 1;
2232 self.selection = Some(last);
2233 self.list_state.scroll_to_reveal_item(last);
2234 cx.notify();
2235 }
2236 None => {}
2237 }
2238 }
2239
2240 fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
2241 if !self.contents.entries.is_empty() {
2242 self.selection = Some(0);
2243 self.list_state.scroll_to_reveal_item(0);
2244 cx.notify();
2245 }
2246 }
2247
2248 fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
2249 if let Some(last) = self.contents.entries.len().checked_sub(1) {
2250 self.selection = Some(last);
2251 self.list_state.scroll_to_reveal_item(last);
2252 cx.notify();
2253 }
2254 }
2255
2256 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
2257 let Some(ix) = self.selection else { return };
2258 let Some(entry) = self.contents.entries.get(ix) else {
2259 return;
2260 };
2261
2262 match entry {
2263 ListEntry::ProjectHeader {
2264 key, has_threads, ..
2265 } => {
2266 if *has_threads {
2267 let key = key.clone();
2268 self.toggle_collapse(&key, window, cx);
2269 }
2270 }
2271 ListEntry::Thread(thread) => {
2272 let metadata = thread.metadata.clone();
2273 match &thread.workspace {
2274 ThreadEntryWorkspace::Open(workspace) => {
2275 let workspace = workspace.clone();
2276 self.activate_thread(metadata, &workspace, false, window, cx);
2277 }
2278 ThreadEntryWorkspace::Closed {
2279 folder_paths,
2280 project_group_key,
2281 } => {
2282 let folder_paths = folder_paths.clone();
2283 let project_group_key = project_group_key.clone();
2284 self.open_workspace_and_activate_thread(
2285 metadata,
2286 folder_paths,
2287 &project_group_key,
2288 window,
2289 cx,
2290 );
2291 }
2292 }
2293 }
2294 ListEntry::ViewMore {
2295 key,
2296 is_fully_expanded,
2297 ..
2298 } => {
2299 let key = key.clone();
2300 if *is_fully_expanded {
2301 self.reset_thread_group_expansion(&key, cx);
2302 } else {
2303 self.expand_thread_group(&key, cx);
2304 }
2305 }
2306 }
2307 }
2308
2309 fn find_workspace_across_windows(
2310 &self,
2311 cx: &App,
2312 predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
2313 ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
2314 cx.windows()
2315 .into_iter()
2316 .filter_map(|window| window.downcast::<MultiWorkspace>())
2317 .find_map(|window| {
2318 let workspace = window.read(cx).ok().and_then(|multi_workspace| {
2319 multi_workspace
2320 .workspaces()
2321 .find(|workspace| predicate(workspace, cx))
2322 .cloned()
2323 })?;
2324 Some((window, workspace))
2325 })
2326 }
2327
2328 fn find_workspace_in_current_window(
2329 &self,
2330 cx: &App,
2331 predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
2332 ) -> Option<Entity<Workspace>> {
2333 self.multi_workspace.upgrade().and_then(|multi_workspace| {
2334 multi_workspace
2335 .read(cx)
2336 .workspaces()
2337 .find(|workspace| predicate(workspace, cx))
2338 .cloned()
2339 })
2340 }
2341
2342 fn load_agent_thread_in_workspace(
2343 workspace: &Entity<Workspace>,
2344 metadata: &ThreadMetadata,
2345 focus: bool,
2346 window: &mut Window,
2347 cx: &mut App,
2348 ) {
2349 let load_thread = |agent_panel: Entity<AgentPanel>,
2350 metadata: &ThreadMetadata,
2351 focus: bool,
2352 window: &mut Window,
2353 cx: &mut App| {
2354 let Some(session_id) = metadata.session_id.clone() else {
2355 return;
2356 };
2357 agent_panel.update(cx, |panel, cx| {
2358 panel.load_agent_thread(
2359 Agent::from(metadata.agent_id.clone()),
2360 session_id,
2361 Some(metadata.folder_paths().clone()),
2362 metadata.title.clone(),
2363 focus,
2364 window,
2365 cx,
2366 );
2367 });
2368 };
2369
2370 let mut existing_panel = None;
2371 workspace.update(cx, |workspace, cx| {
2372 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
2373 panel.update(cx, |panel, _cx| panel.begin_loading_thread());
2374 existing_panel = Some(panel);
2375 }
2376 });
2377
2378 if let Some(agent_panel) = existing_panel {
2379 load_thread(agent_panel, metadata, focus, window, cx);
2380 workspace.update(cx, |workspace, cx| {
2381 workspace.reveal_panel::<AgentPanel>(window, cx);
2382 });
2383 return;
2384 }
2385
2386 let workspace = workspace.downgrade();
2387 let metadata = metadata.clone();
2388 let mut async_window_cx = window.to_async(cx);
2389 cx.spawn(async move |_cx| {
2390 let panel = AgentPanel::load(workspace.clone(), async_window_cx.clone()).await?;
2391
2392 workspace.update_in(&mut async_window_cx, |workspace, window, cx| {
2393 let panel = workspace.panel::<AgentPanel>(cx).unwrap_or_else(|| {
2394 workspace.add_panel(panel.clone(), window, cx);
2395 panel.clone()
2396 });
2397 panel.update(cx, |panel, _cx| panel.begin_loading_thread());
2398 load_thread(panel, &metadata, focus, window, cx);
2399 workspace.reveal_panel::<AgentPanel>(window, cx);
2400 })?;
2401
2402 anyhow::Ok(())
2403 })
2404 .detach_and_log_err(cx);
2405 }
2406
2407 fn activate_thread_locally(
2408 &mut self,
2409 metadata: &ThreadMetadata,
2410 workspace: &Entity<Workspace>,
2411 retain: bool,
2412 window: &mut Window,
2413 cx: &mut Context<Self>,
2414 ) {
2415 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2416 return;
2417 };
2418
2419 // Set active_entry eagerly so the sidebar highlight updates
2420 // immediately, rather than waiting for a deferred AgentPanel
2421 // event which can race with ActiveWorkspaceChanged clearing it.
2422 self.active_entry = Some(ActiveEntry {
2423 thread_id: metadata.thread_id,
2424 session_id: metadata.session_id.clone(),
2425 workspace: workspace.clone(),
2426 });
2427 self.record_thread_access(&metadata.session_id);
2428
2429 if metadata.session_id.is_some() {
2430 self.pending_thread_activation = Some(metadata.thread_id);
2431 }
2432
2433 multi_workspace.update(cx, |multi_workspace, cx| {
2434 multi_workspace.activate(workspace.clone(), window, cx);
2435 if retain {
2436 multi_workspace.retain_active_workspace(cx);
2437 }
2438 });
2439
2440 // Drafts (and other retained threads without a session_id) are
2441 // already in memory — activate them directly instead of loading.
2442 let thread_id = metadata.thread_id;
2443 if metadata.session_id.is_none() {
2444 workspace.update(cx, |ws, cx| {
2445 if let Some(panel) = ws.panel::<AgentPanel>(cx) {
2446 panel.update(cx, |panel, cx| {
2447 panel.activate_retained_thread(thread_id, true, window, cx);
2448 });
2449 }
2450 ws.focus_panel::<AgentPanel>(window, cx);
2451 });
2452 self.pending_thread_activation = None;
2453 self.observe_draft_editors(cx);
2454 } else {
2455 Self::load_agent_thread_in_workspace(workspace, metadata, true, window, cx);
2456 }
2457
2458 self.update_entries(cx);
2459 }
2460
2461 fn activate_thread_in_other_window(
2462 &self,
2463 metadata: ThreadMetadata,
2464 workspace: Entity<Workspace>,
2465 target_window: WindowHandle<MultiWorkspace>,
2466 cx: &mut Context<Self>,
2467 ) {
2468 let target_session_id = metadata.session_id.clone();
2469 let metadata_thread_id = metadata.thread_id;
2470 let workspace_for_entry = workspace.clone();
2471
2472 let activated = target_window
2473 .update(cx, |multi_workspace, window, cx| {
2474 window.activate_window();
2475 multi_workspace.activate(workspace.clone(), window, cx);
2476 Self::load_agent_thread_in_workspace(&workspace, &metadata, true, window, cx);
2477 })
2478 .log_err()
2479 .is_some();
2480
2481 if activated {
2482 if let Some(target_sidebar) = target_window
2483 .read(cx)
2484 .ok()
2485 .and_then(|multi_workspace| {
2486 multi_workspace.sidebar().map(|sidebar| sidebar.to_any())
2487 })
2488 .and_then(|sidebar| sidebar.downcast::<Self>().ok())
2489 {
2490 target_sidebar.update(cx, |sidebar, cx| {
2491 sidebar.pending_thread_activation = Some(metadata_thread_id);
2492 sidebar.active_entry = Some(ActiveEntry {
2493 thread_id: metadata_thread_id,
2494 session_id: target_session_id.clone(),
2495 workspace: workspace_for_entry.clone(),
2496 });
2497 sidebar.record_thread_access(&target_session_id);
2498 sidebar.update_entries(cx);
2499 });
2500 }
2501 }
2502 }
2503
2504 fn activate_thread(
2505 &mut self,
2506 metadata: ThreadMetadata,
2507 workspace: &Entity<Workspace>,
2508 retain: bool,
2509 window: &mut Window,
2510 cx: &mut Context<Self>,
2511 ) {
2512 if self
2513 .find_workspace_in_current_window(cx, |candidate, _| candidate == workspace)
2514 .is_some()
2515 {
2516 self.activate_thread_locally(&metadata, &workspace, retain, window, cx);
2517 return;
2518 }
2519
2520 let Some((target_window, workspace)) =
2521 self.find_workspace_across_windows(cx, |candidate, _| candidate == workspace)
2522 else {
2523 return;
2524 };
2525
2526 self.activate_thread_in_other_window(metadata, workspace, target_window, cx);
2527 }
2528
2529 fn open_workspace_and_activate_thread(
2530 &mut self,
2531 metadata: ThreadMetadata,
2532 folder_paths: PathList,
2533 project_group_key: &ProjectGroupKey,
2534 window: &mut Window,
2535 cx: &mut Context<Self>,
2536 ) {
2537 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2538 return;
2539 };
2540
2541 let pending_thread_id = metadata.thread_id;
2542 // Mark the pending thread activation so rebuild_contents
2543 // preserves the Thread active_entry during loading and
2544 // reconciliation cannot synthesize an empty fallback draft.
2545 self.pending_thread_activation = Some(pending_thread_id);
2546
2547 let host = project_group_key.host();
2548 let provisional_key = Some(project_group_key.clone());
2549 let active_workspace = multi_workspace.read(cx).workspace().clone();
2550 let modal_workspace = active_workspace.clone();
2551
2552 let open_task = multi_workspace.update(cx, |this, cx| {
2553 this.find_or_create_workspace(
2554 folder_paths,
2555 host,
2556 provisional_key,
2557 |options, window, cx| connect_remote(active_workspace, options, window, cx),
2558 window,
2559 cx,
2560 )
2561 });
2562
2563 cx.spawn_in(window, async move |this, cx| {
2564 let result = open_task.await;
2565 // Dismiss the modal as soon as the open attempt completes so
2566 // failures or cancellations do not leave a stale connection modal behind.
2567 remote_connection::dismiss_connection_modal(&modal_workspace, cx);
2568
2569 if result.is_err() {
2570 this.update(cx, |this, _cx| {
2571 if this.pending_thread_activation == Some(pending_thread_id) {
2572 this.pending_thread_activation = None;
2573 }
2574 })
2575 .ok();
2576 }
2577
2578 let workspace = result?;
2579 this.update_in(cx, |this, window, cx| {
2580 this.activate_thread(metadata, &workspace, false, window, cx);
2581 })?;
2582 anyhow::Ok(())
2583 })
2584 .detach_and_log_err(cx);
2585 }
2586
2587 fn find_current_workspace_for_path_list(
2588 &self,
2589 path_list: &PathList,
2590 cx: &App,
2591 ) -> Option<Entity<Workspace>> {
2592 self.find_workspace_in_current_window(cx, |workspace, cx| {
2593 workspace_path_list(workspace, cx).paths() == path_list.paths()
2594 })
2595 }
2596
2597 fn find_open_workspace_for_path_list(
2598 &self,
2599 path_list: &PathList,
2600 cx: &App,
2601 ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
2602 self.find_workspace_across_windows(cx, |workspace, cx| {
2603 workspace_path_list(workspace, cx).paths() == path_list.paths()
2604 })
2605 }
2606
2607 fn activate_archived_thread(
2608 &mut self,
2609 metadata: ThreadMetadata,
2610 window: &mut Window,
2611 cx: &mut Context<Self>,
2612 ) {
2613 let thread_id = metadata.thread_id;
2614 let weak_archive_view = match &self.view {
2615 SidebarView::Archive(view) => Some(view.downgrade()),
2616 _ => None,
2617 };
2618
2619 if metadata.folder_paths().paths().is_empty() {
2620 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.unarchive(thread_id, cx));
2621
2622 let active_workspace = self
2623 .multi_workspace
2624 .upgrade()
2625 .map(|w| w.read(cx).workspace().clone());
2626
2627 if let Some(workspace) = active_workspace {
2628 self.activate_thread_locally(&metadata, &workspace, false, window, cx);
2629 } else {
2630 let path_list = metadata.folder_paths().clone();
2631 if let Some((target_window, workspace)) =
2632 self.find_open_workspace_for_path_list(&path_list, cx)
2633 {
2634 self.activate_thread_in_other_window(metadata, workspace, target_window, cx);
2635 } else {
2636 let key = ProjectGroupKey::new(None, path_list.clone());
2637 self.open_workspace_and_activate_thread(metadata, path_list, &key, window, cx);
2638 }
2639 }
2640 self.show_thread_list(window, cx);
2641 return;
2642 }
2643
2644 let store = ThreadMetadataStore::global(cx);
2645 let task = store
2646 .read(cx)
2647 .get_archived_worktrees_for_thread(thread_id, cx);
2648 let path_list = metadata.folder_paths().clone();
2649
2650 let restore_task = cx.spawn_in(window, async move |this, cx| {
2651 let result: anyhow::Result<()> = async {
2652 let archived_worktrees = task.await?;
2653
2654 if archived_worktrees.is_empty() {
2655 this.update_in(cx, |this, window, cx| {
2656 this.restoring_tasks.remove(&thread_id);
2657 ThreadMetadataStore::global(cx)
2658 .update(cx, |store, cx| store.unarchive(thread_id, cx));
2659
2660 if let Some(workspace) =
2661 this.find_current_workspace_for_path_list(&path_list, cx)
2662 {
2663 this.activate_thread_locally(&metadata, &workspace, false, window, cx);
2664 } else if let Some((target_window, workspace)) =
2665 this.find_open_workspace_for_path_list(&path_list, cx)
2666 {
2667 this.activate_thread_in_other_window(
2668 metadata,
2669 workspace,
2670 target_window,
2671 cx,
2672 );
2673 } else {
2674 let key = ProjectGroupKey::new(None, path_list.clone());
2675 this.open_workspace_and_activate_thread(
2676 metadata, path_list, &key, window, cx,
2677 );
2678 }
2679 this.show_thread_list(window, cx);
2680 })?;
2681 return anyhow::Ok(());
2682 }
2683
2684 let mut path_replacements: Vec<(PathBuf, PathBuf)> = Vec::new();
2685 for row in &archived_worktrees {
2686 match thread_worktree_archive::restore_worktree_via_git(row, &mut *cx).await {
2687 Ok(restored_path) => {
2688 thread_worktree_archive::cleanup_archived_worktree_record(
2689 row, &mut *cx,
2690 )
2691 .await;
2692 path_replacements.push((row.worktree_path.clone(), restored_path));
2693 }
2694 Err(error) => {
2695 log::error!("Failed to restore worktree: {error:#}");
2696 this.update_in(cx, |this, _window, cx| {
2697 this.restoring_tasks.remove(&thread_id);
2698 if let Some(weak_archive_view) = &weak_archive_view {
2699 weak_archive_view
2700 .update(cx, |view, cx| {
2701 view.clear_restoring(&thread_id, cx);
2702 })
2703 .ok();
2704 }
2705
2706 if let Some(multi_workspace) = this.multi_workspace.upgrade() {
2707 let workspace = multi_workspace.read(cx).workspace().clone();
2708 workspace.update(cx, |workspace, cx| {
2709 struct RestoreWorktreeErrorToast;
2710 workspace.show_toast(
2711 Toast::new(
2712 NotificationId::unique::<RestoreWorktreeErrorToast>(
2713 ),
2714 format!("Failed to restore worktree: {error:#}"),
2715 )
2716 .autohide(),
2717 cx,
2718 );
2719 });
2720 }
2721 })
2722 .ok();
2723 return anyhow::Ok(());
2724 }
2725 }
2726 }
2727
2728 if !path_replacements.is_empty() {
2729 cx.update(|_window, cx| {
2730 store.update(cx, |store, cx| {
2731 store.update_restored_worktree_paths(thread_id, &path_replacements, cx);
2732 });
2733 })?;
2734
2735 let updated_metadata =
2736 cx.update(|_window, cx| store.read(cx).entry(thread_id).cloned())?;
2737
2738 if let Some(updated_metadata) = updated_metadata {
2739 let new_paths = updated_metadata.folder_paths().clone();
2740
2741 cx.update(|_window, cx| {
2742 store.update(cx, |store, cx| {
2743 store.unarchive(updated_metadata.thread_id, cx);
2744 });
2745 })?;
2746
2747 this.update_in(cx, |this, window, cx| {
2748 this.restoring_tasks.remove(&thread_id);
2749 let key = ProjectGroupKey::new(None, new_paths.clone());
2750 this.open_workspace_and_activate_thread(
2751 updated_metadata,
2752 new_paths,
2753 &key,
2754 window,
2755 cx,
2756 );
2757 this.show_thread_list(window, cx);
2758 })?;
2759 }
2760 }
2761
2762 anyhow::Ok(())
2763 }
2764 .await;
2765 if let Err(error) = result {
2766 log::error!("{error:#}");
2767 }
2768 });
2769 self.restoring_tasks.insert(thread_id, restore_task);
2770 }
2771
2772 fn expand_selected_entry(
2773 &mut self,
2774 _: &SelectChild,
2775 _window: &mut Window,
2776 cx: &mut Context<Self>,
2777 ) {
2778 let Some(ix) = self.selection else { return };
2779
2780 match self.contents.entries.get(ix) {
2781 Some(ListEntry::ProjectHeader {
2782 key, has_threads, ..
2783 }) => {
2784 if *has_threads {
2785 let key = key.clone();
2786 if self.is_group_collapsed(&key, cx) {
2787 self.set_group_expanded(&key, true, cx);
2788 self.update_entries(cx);
2789 } else if ix + 1 < self.contents.entries.len() {
2790 self.selection = Some(ix + 1);
2791 self.list_state.scroll_to_reveal_item(ix + 1);
2792 cx.notify();
2793 }
2794 }
2795 }
2796 _ => {}
2797 }
2798 }
2799
2800 fn collapse_selected_entry(
2801 &mut self,
2802 _: &SelectParent,
2803 _window: &mut Window,
2804 cx: &mut Context<Self>,
2805 ) {
2806 let Some(ix) = self.selection else { return };
2807
2808 match self.contents.entries.get(ix) {
2809 Some(ListEntry::ProjectHeader {
2810 key, has_threads, ..
2811 }) => {
2812 if *has_threads {
2813 let key = key.clone();
2814 if !self.is_group_collapsed(&key, cx) {
2815 self.set_group_expanded(&key, false, cx);
2816 self.update_entries(cx);
2817 }
2818 }
2819 }
2820 Some(ListEntry::Thread(_) | ListEntry::ViewMore { .. }) => {
2821 for i in (0..ix).rev() {
2822 if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(i)
2823 {
2824 let key = key.clone();
2825 self.selection = Some(i);
2826 self.set_group_expanded(&key, false, cx);
2827 self.update_entries(cx);
2828 break;
2829 }
2830 }
2831 }
2832 None => {}
2833 }
2834 }
2835
2836 fn toggle_selected_fold(
2837 &mut self,
2838 _: &editor::actions::ToggleFold,
2839 _window: &mut Window,
2840 cx: &mut Context<Self>,
2841 ) {
2842 let Some(ix) = self.selection else { return };
2843
2844 // Find the group header for the current selection.
2845 let header_ix = match self.contents.entries.get(ix) {
2846 Some(ListEntry::ProjectHeader { .. }) => Some(ix),
2847 Some(ListEntry::Thread(_) | ListEntry::ViewMore { .. }) => (0..ix).rev().find(|&i| {
2848 matches!(
2849 self.contents.entries.get(i),
2850 Some(ListEntry::ProjectHeader { .. })
2851 )
2852 }),
2853 None => None,
2854 };
2855
2856 if let Some(header_ix) = header_ix {
2857 if let Some(ListEntry::ProjectHeader {
2858 key, has_threads, ..
2859 }) = self.contents.entries.get(header_ix)
2860 {
2861 if *has_threads {
2862 let key = key.clone();
2863 if self.is_group_collapsed(&key, cx) {
2864 self.set_group_expanded(&key, true, cx);
2865 } else {
2866 self.selection = Some(header_ix);
2867 self.set_group_expanded(&key, false, cx);
2868 }
2869 self.update_entries(cx);
2870 }
2871 }
2872 }
2873 }
2874
2875 fn fold_all(
2876 &mut self,
2877 _: &editor::actions::FoldAll,
2878 _window: &mut Window,
2879 cx: &mut Context<Self>,
2880 ) {
2881 if let Some(mw) = self.multi_workspace.upgrade() {
2882 mw.update(cx, |mw, _cx| {
2883 mw.set_all_groups_expanded(false);
2884 });
2885 }
2886 self.update_entries(cx);
2887 }
2888
2889 fn unfold_all(
2890 &mut self,
2891 _: &editor::actions::UnfoldAll,
2892 _window: &mut Window,
2893 cx: &mut Context<Self>,
2894 ) {
2895 if let Some(mw) = self.multi_workspace.upgrade() {
2896 mw.update(cx, |mw, _cx| {
2897 mw.set_all_groups_expanded(true);
2898 });
2899 }
2900 self.update_entries(cx);
2901 }
2902
2903 fn stop_thread(&mut self, thread_id: &agent_ui::ThreadId, cx: &mut Context<Self>) {
2904 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2905 return;
2906 };
2907
2908 let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect();
2909 for workspace in workspaces {
2910 if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2911 let cancelled =
2912 agent_panel.update(cx, |panel, cx| panel.cancel_thread(thread_id, cx));
2913 if cancelled {
2914 return;
2915 }
2916 }
2917 }
2918 }
2919
2920 fn archive_thread(
2921 &mut self,
2922 session_id: &acp::SessionId,
2923 window: &mut Window,
2924 cx: &mut Context<Self>,
2925 ) {
2926 let store = ThreadMetadataStore::global(cx);
2927 let metadata = store.read(cx).entry_by_session(session_id).cloned();
2928 let active_workspace = metadata.as_ref().and_then(|metadata| {
2929 self.active_entry.as_ref().and_then(|entry| {
2930 if entry.is_active_thread(&metadata.thread_id) {
2931 Some(entry.workspace.clone())
2932 } else {
2933 None
2934 }
2935 })
2936 });
2937 let thread_id = metadata.as_ref().map(|metadata| metadata.thread_id);
2938 let thread_folder_paths = metadata
2939 .as_ref()
2940 .map(|metadata| metadata.folder_paths().clone())
2941 .or_else(|| {
2942 active_workspace
2943 .as_ref()
2944 .map(|workspace| PathList::new(&workspace.read(cx).root_paths(cx)))
2945 });
2946
2947 // Compute which linked worktree roots should be archived from disk if
2948 // this thread is archived. This must happen before we remove any
2949 // workspace from the MultiWorkspace, because `build_root_plan` needs
2950 // the currently open workspaces in order to find the affected projects
2951 // and repository handles for each linked worktree.
2952 let roots_to_archive = metadata
2953 .as_ref()
2954 .map(|metadata| {
2955 let mut workspaces = self
2956 .multi_workspace
2957 .upgrade()
2958 .map(|multi_workspace| {
2959 multi_workspace
2960 .read(cx)
2961 .workspaces()
2962 .cloned()
2963 .collect::<Vec<_>>()
2964 })
2965 .unwrap_or_default();
2966 for workspace in thread_worktree_archive::all_open_workspaces(cx) {
2967 if !workspaces.contains(&workspace) {
2968 workspaces.push(workspace);
2969 }
2970 }
2971 metadata
2972 .folder_paths()
2973 .ordered_paths()
2974 .filter_map(|path| {
2975 thread_worktree_archive::build_root_plan(path, &workspaces, cx)
2976 })
2977 .filter(|plan| {
2978 thread_id.map_or(true, |tid| {
2979 !thread_worktree_archive::path_is_referenced_by_other_unarchived_threads(
2980 tid,
2981 &plan.root_path,
2982 cx,
2983 )
2984 })
2985 })
2986 .collect::<Vec<_>>()
2987 })
2988 .unwrap_or_default();
2989
2990 // Find the neighbor thread in the sidebar (by display position).
2991 // Look below first, then above, for the nearest thread that isn't
2992 // the one being archived. We capture both the neighbor's metadata
2993 // (for activation) and its workspace paths (for the workspace
2994 // removal fallback).
2995 let current_pos = self.contents.entries.iter().position(|entry| match entry {
2996 ListEntry::Thread(thread) => thread_id.map_or_else(
2997 || thread.metadata.session_id.as_ref() == Some(session_id),
2998 |tid| thread.metadata.thread_id == tid,
2999 ),
3000 _ => false,
3001 });
3002 let neighbor = current_pos.and_then(|pos| {
3003 self.contents.entries[pos + 1..]
3004 .iter()
3005 .chain(self.contents.entries[..pos].iter().rev())
3006 .find_map(|entry| match entry {
3007 ListEntry::Thread(t)
3008 if !t.is_draft && t.metadata.session_id.as_ref() != Some(session_id) =>
3009 {
3010 let workspace_paths = match &t.workspace {
3011 ThreadEntryWorkspace::Open(ws) => {
3012 PathList::new(&ws.read(cx).root_paths(cx))
3013 }
3014 ThreadEntryWorkspace::Closed { folder_paths, .. } => {
3015 folder_paths.clone()
3016 }
3017 };
3018 Some((t.metadata.clone(), workspace_paths))
3019 }
3020 _ => None,
3021 })
3022 });
3023
3024 // Check if archiving this thread would leave its worktree workspace
3025 // with no threads, requiring workspace removal.
3026 let workspace_to_remove = thread_folder_paths.as_ref().and_then(|folder_paths| {
3027 if folder_paths.is_empty() {
3028 return None;
3029 }
3030
3031 let remaining = ThreadMetadataStore::global(cx)
3032 .read(cx)
3033 .entries_for_path(folder_paths)
3034 .filter(|t| t.session_id.as_ref() != Some(session_id))
3035 .count();
3036
3037 if remaining > 0 {
3038 return None;
3039 }
3040
3041 let multi_workspace = self.multi_workspace.upgrade()?;
3042 let workspace = multi_workspace
3043 .read(cx)
3044 .workspace_for_paths(folder_paths, None, cx)?;
3045
3046 let group_key = workspace.read(cx).project_group_key(cx);
3047 let is_linked_worktree = group_key.path_list() != folder_paths;
3048
3049 is_linked_worktree.then_some(workspace)
3050 });
3051
3052 // Also find workspaces for root plans that aren't covered by
3053 // workspace_to_remove. For workspaces that exclusively contain
3054 // worktrees being archived, remove the whole workspace. For
3055 // "mixed" workspaces (containing both archived and non-archived
3056 // worktrees), close only the editor items referencing the
3057 // archived worktrees so their Entity<Worktree> handles are
3058 // dropped without destroying the user's workspace layout.
3059 let mut workspaces_to_remove: Vec<Entity<Workspace>> =
3060 workspace_to_remove.into_iter().collect();
3061 let mut close_item_tasks: Vec<Task<anyhow::Result<()>>> = Vec::new();
3062
3063 let archive_paths: HashSet<&Path> = roots_to_archive
3064 .iter()
3065 .map(|root| root.root_path.as_path())
3066 .collect();
3067
3068 // Classify workspaces into "exclusive" (all worktrees archived)
3069 // and "mixed" (some worktrees archived, some not).
3070 let mut mixed_workspaces: Vec<(Entity<Workspace>, Vec<WorktreeId>)> = Vec::new();
3071
3072 if let Some(multi_workspace) = self.multi_workspace.upgrade() {
3073 let all_workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect();
3074
3075 for workspace in all_workspaces {
3076 if workspaces_to_remove.contains(&workspace) {
3077 continue;
3078 }
3079
3080 let project = workspace.read(cx).project().read(cx);
3081 let visible_worktrees: Vec<_> = project
3082 .visible_worktrees(cx)
3083 .map(|wt| (wt.read(cx).id(), wt.read(cx).abs_path()))
3084 .collect();
3085
3086 let archived_worktree_ids: Vec<WorktreeId> = visible_worktrees
3087 .iter()
3088 .filter(|(_, path)| archive_paths.contains(path.as_ref()))
3089 .map(|(id, _)| *id)
3090 .collect();
3091
3092 if archived_worktree_ids.is_empty() {
3093 continue;
3094 }
3095
3096 if visible_worktrees.len() == archived_worktree_ids.len() {
3097 workspaces_to_remove.push(workspace);
3098 } else {
3099 mixed_workspaces.push((workspace, archived_worktree_ids));
3100 }
3101 }
3102 }
3103
3104 // For mixed workspaces, close only items belonging to the
3105 // worktrees being archived.
3106 for (workspace, archived_worktree_ids) in &mixed_workspaces {
3107 let panes: Vec<_> = workspace.read(cx).panes().to_vec();
3108 for pane in panes {
3109 let items_to_close: Vec<EntityId> = pane
3110 .read(cx)
3111 .items()
3112 .filter(|item| {
3113 item.project_path(cx)
3114 .is_some_and(|pp| archived_worktree_ids.contains(&pp.worktree_id))
3115 })
3116 .map(|item| item.item_id())
3117 .collect();
3118
3119 if !items_to_close.is_empty() {
3120 let task = pane.update(cx, |pane, cx| {
3121 pane.close_items(window, cx, SaveIntent::Close, &|item_id| {
3122 items_to_close.contains(&item_id)
3123 })
3124 });
3125 close_item_tasks.push(task);
3126 }
3127 }
3128 }
3129
3130 if !workspaces_to_remove.is_empty() {
3131 let multi_workspace = self.multi_workspace.upgrade().unwrap();
3132 let session_id = session_id.clone();
3133
3134 let fallback_paths = neighbor
3135 .as_ref()
3136 .map(|(_, paths)| paths.clone())
3137 .unwrap_or_else(|| {
3138 workspaces_to_remove
3139 .first()
3140 .map(|ws| ws.read(cx).project_group_key(cx).path_list().clone())
3141 .unwrap_or_default()
3142 });
3143
3144 let excluded = workspaces_to_remove.clone();
3145 let remove_task = multi_workspace.update(cx, |mw, cx| {
3146 mw.remove(
3147 workspaces_to_remove,
3148 move |this, window, cx| {
3149 this.find_or_create_local_workspace(fallback_paths, &excluded, window, cx)
3150 },
3151 window,
3152 cx,
3153 )
3154 });
3155
3156 let neighbor_metadata = neighbor.map(|(metadata, _)| metadata);
3157 let thread_folder_paths = thread_folder_paths.clone();
3158 cx.spawn_in(window, async move |this, cx| {
3159 if !remove_task.await? {
3160 return anyhow::Ok(());
3161 }
3162
3163 for task in close_item_tasks {
3164 let result: anyhow::Result<()> = task.await;
3165 result.log_err();
3166 }
3167
3168 this.update_in(cx, |this, window, cx| {
3169 let in_flight = thread_id.and_then(|tid| {
3170 this.start_archive_worktree_task(tid, roots_to_archive, cx)
3171 });
3172 this.archive_and_activate(
3173 &session_id,
3174 thread_id,
3175 neighbor_metadata.as_ref(),
3176 thread_folder_paths.as_ref(),
3177 in_flight,
3178 window,
3179 cx,
3180 );
3181 })?;
3182 anyhow::Ok(())
3183 })
3184 .detach_and_log_err(cx);
3185 } else if !close_item_tasks.is_empty() {
3186 let session_id = session_id.clone();
3187 let neighbor_metadata = neighbor.map(|(metadata, _)| metadata);
3188 let thread_folder_paths = thread_folder_paths.clone();
3189 cx.spawn_in(window, async move |this, cx| {
3190 for task in close_item_tasks {
3191 let result: anyhow::Result<()> = task.await;
3192 result.log_err();
3193 }
3194
3195 this.update_in(cx, |this, window, cx| {
3196 let in_flight = thread_id.and_then(|tid| {
3197 this.start_archive_worktree_task(tid, roots_to_archive, cx)
3198 });
3199 this.archive_and_activate(
3200 &session_id,
3201 thread_id,
3202 neighbor_metadata.as_ref(),
3203 thread_folder_paths.as_ref(),
3204 in_flight,
3205 window,
3206 cx,
3207 );
3208 })?;
3209 anyhow::Ok(())
3210 })
3211 .detach_and_log_err(cx);
3212 } else {
3213 let neighbor_metadata = neighbor.map(|(metadata, _)| metadata);
3214 let in_flight = thread_id
3215 .and_then(|tid| self.start_archive_worktree_task(tid, roots_to_archive, cx));
3216 self.archive_and_activate(
3217 session_id,
3218 thread_id,
3219 neighbor_metadata.as_ref(),
3220 thread_folder_paths.as_ref(),
3221 in_flight,
3222 window,
3223 cx,
3224 );
3225 }
3226 }
3227
3228 /// Archive a thread and activate the nearest neighbor or a draft.
3229 ///
3230 /// IMPORTANT: when activating a neighbor or creating a fallback draft,
3231 /// this method also activates the target workspace in the MultiWorkspace.
3232 /// This is critical because `rebuild_contents` derives the active
3233 /// workspace from `mw.workspace()`. If the linked worktree workspace is
3234 /// still active after archiving its last thread, `rebuild_contents` sees
3235 /// the threadless linked worktree as active and emits a spurious
3236 /// "+ New Thread" entry with the worktree chip — keeping the worktree
3237 /// alive and preventing disk cleanup.
3238 ///
3239 /// When `in_flight_archive` is present, it is the background task that
3240 /// persists the linked worktree's git state and deletes it from disk.
3241 /// We attach it to the metadata store at the same time we mark the thread
3242 /// archived so failures can automatically unarchive the thread and user-
3243 /// initiated unarchive can cancel the task.
3244 fn archive_and_activate(
3245 &mut self,
3246 _session_id: &acp::SessionId,
3247 thread_id: Option<agent_ui::ThreadId>,
3248 neighbor: Option<&ThreadMetadata>,
3249 thread_folder_paths: Option<&PathList>,
3250 in_flight_archive: Option<(Task<()>, smol::channel::Sender<()>)>,
3251 window: &mut Window,
3252 cx: &mut Context<Self>,
3253 ) {
3254 if let Some(thread_id) = thread_id {
3255 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
3256 store.archive(thread_id, in_flight_archive, cx);
3257 });
3258 }
3259
3260 let is_active = self
3261 .active_entry
3262 .as_ref()
3263 .is_some_and(|entry| thread_id.is_some_and(|tid| entry.is_active_thread(&tid)));
3264
3265 if is_active {
3266 self.active_entry = None;
3267 }
3268
3269 if !is_active {
3270 // The user is looking at a different thread/draft. Clear the
3271 // archived thread from its workspace's panel so that switching
3272 // to that workspace later doesn't show a stale thread.
3273 if let Some(folder_paths) = thread_folder_paths {
3274 if let Some(workspace) = self
3275 .multi_workspace
3276 .upgrade()
3277 .and_then(|mw| mw.read(cx).workspace_for_paths(folder_paths, None, cx))
3278 {
3279 if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
3280 let panel_shows_archived = panel
3281 .read(cx)
3282 .active_conversation_view()
3283 .map(|cv| cv.read(cx).parent_id())
3284 .is_some_and(|live_thread_id| {
3285 thread_id.is_some_and(|id| id == live_thread_id)
3286 });
3287 if panel_shows_archived {
3288 panel.update(cx, |panel, cx| {
3289 panel.clear_base_view(cx);
3290 });
3291 }
3292 }
3293 }
3294 }
3295 return;
3296 }
3297
3298 // Try to activate the neighbor thread. If its workspace is open,
3299 // tell the panel to load it and activate that workspace.
3300 // `rebuild_contents` will reconcile `active_entry` once the thread
3301 // finishes loading.
3302
3303 if let Some(metadata) = neighbor {
3304 if let Some(workspace) = self.multi_workspace.upgrade().and_then(|mw| {
3305 mw.read(cx)
3306 .workspace_for_paths(metadata.folder_paths(), None, cx)
3307 }) {
3308 self.active_entry = Some(ActiveEntry {
3309 thread_id: metadata.thread_id,
3310 session_id: metadata.session_id.clone(),
3311 workspace: workspace.clone(),
3312 });
3313 self.activate_workspace(&workspace, window, cx);
3314 Self::load_agent_thread_in_workspace(&workspace, metadata, true, window, cx);
3315 return;
3316 }
3317 }
3318
3319 // No neighbor or its workspace isn't open — just clear the
3320 // panel so the group is left empty.
3321 if let Some(folder_paths) = thread_folder_paths {
3322 let workspace = self
3323 .multi_workspace
3324 .upgrade()
3325 .and_then(|mw| mw.read(cx).workspace_for_paths(folder_paths, None, cx));
3326 if let Some(workspace) = workspace {
3327 if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
3328 panel.update(cx, |panel, cx| {
3329 panel.clear_base_view(cx);
3330 });
3331 }
3332 }
3333 }
3334 }
3335
3336 fn start_archive_worktree_task(
3337 &self,
3338 thread_id: ThreadId,
3339 roots: Vec<thread_worktree_archive::RootPlan>,
3340 cx: &mut Context<Self>,
3341 ) -> Option<(Task<()>, smol::channel::Sender<()>)> {
3342 if roots.is_empty() {
3343 return None;
3344 }
3345
3346 let (cancel_tx, cancel_rx) = smol::channel::bounded::<()>(1);
3347 let task = cx.spawn(async move |_this, cx| {
3348 match Self::archive_worktree_roots(roots, cancel_rx, cx).await {
3349 Ok(ArchiveWorktreeOutcome::Success) => {
3350 cx.update(|cx| {
3351 ThreadMetadataStore::global(cx).update(cx, |store, _cx| {
3352 store.cleanup_completed_archive(thread_id);
3353 });
3354 });
3355 }
3356 Ok(ArchiveWorktreeOutcome::Cancelled) => {}
3357 Err(error) => {
3358 log::error!("Failed to archive worktree: {error:#}");
3359 cx.update(|cx| {
3360 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
3361 store.unarchive(thread_id, cx);
3362 });
3363 });
3364 }
3365 }
3366 });
3367
3368 Some((task, cancel_tx))
3369 }
3370
3371 async fn archive_worktree_roots(
3372 roots: Vec<thread_worktree_archive::RootPlan>,
3373 cancel_rx: smol::channel::Receiver<()>,
3374 cx: &mut gpui::AsyncApp,
3375 ) -> anyhow::Result<ArchiveWorktreeOutcome> {
3376 let mut completed_persists: Vec<(i64, thread_worktree_archive::RootPlan)> = Vec::new();
3377
3378 for root in &roots {
3379 if cancel_rx.is_closed() {
3380 for &(id, ref completed_root) in completed_persists.iter().rev() {
3381 thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
3382 }
3383 return Ok(ArchiveWorktreeOutcome::Cancelled);
3384 }
3385
3386 match thread_worktree_archive::persist_worktree_state(root, cx).await {
3387 Ok(id) => {
3388 completed_persists.push((id, root.clone()));
3389 }
3390 Err(error) => {
3391 for &(id, ref completed_root) in completed_persists.iter().rev() {
3392 thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
3393 }
3394 return Err(error);
3395 }
3396 }
3397
3398 if cancel_rx.is_closed() {
3399 for &(id, ref completed_root) in completed_persists.iter().rev() {
3400 thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
3401 }
3402 return Ok(ArchiveWorktreeOutcome::Cancelled);
3403 }
3404
3405 if let Err(error) = thread_worktree_archive::remove_root(root.clone(), cx).await {
3406 if let Some(&(id, ref completed_root)) = completed_persists.last() {
3407 if completed_root.root_path == root.root_path {
3408 thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
3409 completed_persists.pop();
3410 }
3411 }
3412 for &(id, ref completed_root) in completed_persists.iter().rev() {
3413 thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
3414 }
3415 return Err(error);
3416 }
3417 }
3418
3419 Ok(ArchiveWorktreeOutcome::Success)
3420 }
3421
3422 fn activate_workspace(
3423 &self,
3424 workspace: &Entity<Workspace>,
3425 window: &mut Window,
3426 cx: &mut Context<Self>,
3427 ) {
3428 if let Some(multi_workspace) = self.multi_workspace.upgrade() {
3429 multi_workspace.update(cx, |mw, cx| {
3430 mw.activate(workspace.clone(), window, cx);
3431 });
3432 }
3433 }
3434
3435 fn remove_selected_thread(
3436 &mut self,
3437 _: &RemoveSelectedThread,
3438 window: &mut Window,
3439 cx: &mut Context<Self>,
3440 ) {
3441 let Some(ix) = self.selection else {
3442 return;
3443 };
3444 match self.contents.entries.get(ix) {
3445 Some(ListEntry::Thread(thread)) if thread.is_draft => {
3446 let draft_id = thread.metadata.thread_id;
3447 if let ThreadEntryWorkspace::Open(workspace) = &thread.workspace {
3448 let workspace = workspace.clone();
3449 self.remove_draft(draft_id, &workspace, window, cx);
3450 }
3451 }
3452 Some(ListEntry::Thread(thread)) => {
3453 match thread.status {
3454 AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation => {
3455 return;
3456 }
3457 AgentThreadStatus::Completed | AgentThreadStatus::Error => {}
3458 }
3459 if let Some(session_id) = thread.metadata.session_id.clone() {
3460 self.archive_thread(&session_id, window, cx);
3461 }
3462 }
3463 _ => {}
3464 }
3465 }
3466
3467 fn record_thread_access(&mut self, session_id: &Option<acp::SessionId>) {
3468 if let Some(sid) = session_id {
3469 self.thread_last_accessed.insert(sid.clone(), Utc::now());
3470 }
3471 }
3472
3473 fn record_thread_message_sent(&mut self, thread_id: &agent_ui::ThreadId) {
3474 self.thread_last_message_sent_or_queued
3475 .insert(*thread_id, Utc::now());
3476 }
3477
3478 fn mru_threads_for_switcher(&self, cx: &App) -> Vec<ThreadSwitcherEntry> {
3479 let mut current_header_label: Option<SharedString> = None;
3480 let mut current_header_key: Option<ProjectGroupKey> = None;
3481 let mut entries: Vec<ThreadSwitcherEntry> = self
3482 .contents
3483 .entries
3484 .iter()
3485 .filter_map(|entry| match entry {
3486 ListEntry::ProjectHeader { label, key, .. } => {
3487 current_header_label = Some(label.clone());
3488 current_header_key = Some(key.clone());
3489 None
3490 }
3491 ListEntry::Thread(thread) => {
3492 let session_id = thread.metadata.session_id.clone()?;
3493 let workspace = match &thread.workspace {
3494 ThreadEntryWorkspace::Open(workspace) => Some(workspace.clone()),
3495 ThreadEntryWorkspace::Closed { .. } => {
3496 current_header_key.as_ref().and_then(|key| {
3497 self.multi_workspace.upgrade().and_then(|mw| {
3498 mw.read(cx).workspace_for_paths(
3499 key.path_list(),
3500 key.host().as_ref(),
3501 cx,
3502 )
3503 })
3504 })
3505 }
3506 }?;
3507 let notified = self.contents.is_thread_notified(&thread.metadata.thread_id);
3508 let timestamp: SharedString = format_history_entry_timestamp(
3509 self.thread_last_message_sent_or_queued
3510 .get(&thread.metadata.thread_id)
3511 .copied()
3512 .or(thread.metadata.created_at)
3513 .unwrap_or(thread.metadata.updated_at),
3514 )
3515 .into();
3516 Some(ThreadSwitcherEntry {
3517 session_id,
3518 title: thread.metadata.display_title(),
3519 icon: thread.icon,
3520 icon_from_external_svg: thread.icon_from_external_svg.clone(),
3521 status: thread.status,
3522 metadata: thread.metadata.clone(),
3523 workspace,
3524 project_name: current_header_label.clone(),
3525 worktrees: thread
3526 .worktrees
3527 .iter()
3528 .map(|wt| ThreadItemWorktreeInfo {
3529 name: wt.name.clone(),
3530 full_path: wt.full_path.clone(),
3531 highlight_positions: Vec::new(),
3532 kind: wt.kind,
3533 })
3534 .collect(),
3535 diff_stats: thread.diff_stats,
3536 is_title_generating: thread.is_title_generating,
3537 notified,
3538 timestamp,
3539 })
3540 }
3541 _ => None,
3542 })
3543 .collect();
3544
3545 entries.sort_by(|a, b| {
3546 let a_accessed = self.thread_last_accessed.get(&a.session_id);
3547 let b_accessed = self.thread_last_accessed.get(&b.session_id);
3548
3549 match (a_accessed, b_accessed) {
3550 (Some(a_time), Some(b_time)) => b_time.cmp(a_time),
3551 (Some(_), None) => std::cmp::Ordering::Less,
3552 (None, Some(_)) => std::cmp::Ordering::Greater,
3553 (None, None) => {
3554 let a_sent = self
3555 .thread_last_message_sent_or_queued
3556 .get(&a.metadata.thread_id);
3557 let b_sent = self
3558 .thread_last_message_sent_or_queued
3559 .get(&b.metadata.thread_id);
3560
3561 match (a_sent, b_sent) {
3562 (Some(a_time), Some(b_time)) => b_time.cmp(a_time),
3563 (Some(_), None) => std::cmp::Ordering::Less,
3564 (None, Some(_)) => std::cmp::Ordering::Greater,
3565 (None, None) => {
3566 let a_time = a.metadata.created_at.or(Some(a.metadata.updated_at));
3567 let b_time = b.metadata.created_at.or(Some(b.metadata.updated_at));
3568 b_time.cmp(&a_time)
3569 }
3570 }
3571 }
3572 }
3573 });
3574
3575 entries
3576 }
3577
3578 fn dismiss_thread_switcher(&mut self, cx: &mut Context<Self>) {
3579 self.thread_switcher = None;
3580 self._thread_switcher_subscriptions.clear();
3581 if let Some(mw) = self.multi_workspace.upgrade() {
3582 mw.update(cx, |mw, cx| {
3583 mw.set_sidebar_overlay(None, cx);
3584 });
3585 }
3586 }
3587
3588 fn on_toggle_thread_switcher(
3589 &mut self,
3590 action: &ToggleThreadSwitcher,
3591 window: &mut Window,
3592 cx: &mut Context<Self>,
3593 ) {
3594 self.toggle_thread_switcher_impl(action.select_last, window, cx);
3595 }
3596
3597 fn toggle_thread_switcher_impl(
3598 &mut self,
3599 select_last: bool,
3600 window: &mut Window,
3601 cx: &mut Context<Self>,
3602 ) {
3603 if let Some(thread_switcher) = &self.thread_switcher {
3604 thread_switcher.update(cx, |switcher, cx| {
3605 if select_last {
3606 switcher.select_last(cx);
3607 } else {
3608 switcher.cycle_selection(cx);
3609 }
3610 });
3611 return;
3612 }
3613
3614 let entries = self.mru_threads_for_switcher(cx);
3615 if entries.len() < 2 {
3616 return;
3617 }
3618
3619 let weak_multi_workspace = self.multi_workspace.clone();
3620
3621 let original_metadata = match &self.active_entry {
3622 Some(ActiveEntry { thread_id, .. }) => entries
3623 .iter()
3624 .find(|e| *thread_id == e.metadata.thread_id)
3625 .map(|e| e.metadata.clone()),
3626 _ => None,
3627 };
3628 let original_workspace = self
3629 .multi_workspace
3630 .upgrade()
3631 .map(|mw| mw.read(cx).workspace().clone());
3632
3633 let thread_switcher = cx.new(|cx| ThreadSwitcher::new(entries, select_last, window, cx));
3634
3635 let mut subscriptions = Vec::new();
3636
3637 subscriptions.push(cx.subscribe_in(&thread_switcher, window, {
3638 let thread_switcher = thread_switcher.clone();
3639 move |this, _emitter, event: &ThreadSwitcherEvent, window, cx| match event {
3640 ThreadSwitcherEvent::Preview {
3641 metadata,
3642 workspace,
3643 } => {
3644 if let Some(mw) = weak_multi_workspace.upgrade() {
3645 mw.update(cx, |mw, cx| {
3646 mw.activate(workspace.clone(), window, cx);
3647 });
3648 }
3649 this.active_entry = Some(ActiveEntry {
3650 thread_id: metadata.thread_id,
3651 session_id: metadata.session_id.clone(),
3652 workspace: workspace.clone(),
3653 });
3654 this.update_entries(cx);
3655 Self::load_agent_thread_in_workspace(workspace, metadata, false, window, cx);
3656 let focus = thread_switcher.focus_handle(cx);
3657 window.focus(&focus, cx);
3658 }
3659 ThreadSwitcherEvent::Confirmed {
3660 metadata,
3661 workspace,
3662 } => {
3663 if let Some(mw) = weak_multi_workspace.upgrade() {
3664 mw.update(cx, |mw, cx| {
3665 mw.activate(workspace.clone(), window, cx);
3666 mw.retain_active_workspace(cx);
3667 });
3668 }
3669 this.record_thread_access(&metadata.session_id);
3670 this.active_entry = Some(ActiveEntry {
3671 thread_id: metadata.thread_id,
3672 session_id: metadata.session_id.clone(),
3673 workspace: workspace.clone(),
3674 });
3675 this.update_entries(cx);
3676 Self::load_agent_thread_in_workspace(workspace, metadata, false, window, cx);
3677 this.dismiss_thread_switcher(cx);
3678 workspace.update(cx, |workspace, cx| {
3679 workspace.focus_panel::<AgentPanel>(window, cx);
3680 });
3681 }
3682 ThreadSwitcherEvent::Dismissed => {
3683 if let Some(mw) = weak_multi_workspace.upgrade() {
3684 if let Some(original_ws) = &original_workspace {
3685 mw.update(cx, |mw, cx| {
3686 mw.activate(original_ws.clone(), window, cx);
3687 });
3688 }
3689 }
3690 if let Some(metadata) = &original_metadata {
3691 if let Some(original_ws) = &original_workspace {
3692 this.active_entry = Some(ActiveEntry {
3693 thread_id: metadata.thread_id,
3694 session_id: metadata.session_id.clone(),
3695 workspace: original_ws.clone(),
3696 });
3697 }
3698 this.update_entries(cx);
3699 if let Some(original_ws) = &original_workspace {
3700 Self::load_agent_thread_in_workspace(
3701 original_ws,
3702 metadata,
3703 false,
3704 window,
3705 cx,
3706 );
3707 }
3708 }
3709 this.dismiss_thread_switcher(cx);
3710 }
3711 }
3712 }));
3713
3714 subscriptions.push(cx.subscribe_in(
3715 &thread_switcher,
3716 window,
3717 |this, _emitter, _event: &gpui::DismissEvent, _window, cx| {
3718 this.dismiss_thread_switcher(cx);
3719 },
3720 ));
3721
3722 let focus = thread_switcher.focus_handle(cx);
3723 let overlay_view = gpui::AnyView::from(thread_switcher.clone());
3724
3725 // Replay the initial preview that was emitted during construction
3726 // before subscriptions were wired up.
3727 let initial_preview = thread_switcher
3728 .read(cx)
3729 .selected_entry()
3730 .map(|entry| (entry.metadata.clone(), entry.workspace.clone()));
3731
3732 self.thread_switcher = Some(thread_switcher);
3733 self._thread_switcher_subscriptions = subscriptions;
3734 if let Some(mw) = self.multi_workspace.upgrade() {
3735 mw.update(cx, |mw, cx| {
3736 mw.set_sidebar_overlay(Some(overlay_view), cx);
3737 });
3738 }
3739
3740 if let Some((metadata, workspace)) = initial_preview {
3741 if let Some(mw) = self.multi_workspace.upgrade() {
3742 mw.update(cx, |mw, cx| {
3743 mw.activate(workspace.clone(), window, cx);
3744 });
3745 }
3746 self.active_entry = Some(ActiveEntry {
3747 thread_id: metadata.thread_id,
3748 session_id: metadata.session_id.clone(),
3749 workspace: workspace.clone(),
3750 });
3751 self.update_entries(cx);
3752 Self::load_agent_thread_in_workspace(&workspace, &metadata, false, window, cx);
3753 }
3754
3755 window.focus(&focus, cx);
3756 }
3757
3758 fn render_thread(
3759 &self,
3760 ix: usize,
3761 thread: &ThreadEntry,
3762 is_active: bool,
3763 is_focused: bool,
3764 cx: &mut Context<Self>,
3765 ) -> AnyElement {
3766 let has_notification = self.contents.is_thread_notified(&thread.metadata.thread_id);
3767
3768 let title: SharedString = thread.metadata.display_title();
3769 let metadata = thread.metadata.clone();
3770 let thread_workspace = thread.workspace.clone();
3771 let is_draft = thread.is_draft;
3772
3773 let is_hovered = self.hovered_thread_index == Some(ix);
3774 let is_selected = is_active;
3775 let is_running = matches!(
3776 thread.status,
3777 AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
3778 );
3779
3780 let thread_id_for_actions = thread.metadata.thread_id;
3781 let session_id_for_delete = thread.metadata.session_id.clone();
3782 let thread_workspace_for_dismiss = thread.workspace.clone();
3783 let focus_handle = self.focus_handle.clone();
3784
3785 let id = SharedString::from(format!("thread-entry-{}", ix));
3786
3787 let color = cx.theme().colors();
3788 let sidebar_bg = color
3789 .title_bar_background
3790 .blend(color.panel_background.opacity(0.25));
3791
3792 let timestamp = format_history_entry_timestamp(
3793 self.thread_last_message_sent_or_queued
3794 .get(&thread.metadata.thread_id)
3795 .copied()
3796 .or(thread.metadata.created_at)
3797 .unwrap_or(thread.metadata.updated_at),
3798 );
3799
3800 let is_remote = thread.workspace.is_remote(cx);
3801
3802 ThreadItem::new(id, title)
3803 .base_bg(sidebar_bg)
3804 .icon(thread.icon)
3805 .status(thread.status)
3806 .is_remote(is_remote)
3807 .when_some(thread.icon_from_external_svg.clone(), |this, svg| {
3808 this.custom_icon_from_external_svg(svg)
3809 })
3810 .worktrees(
3811 thread
3812 .worktrees
3813 .iter()
3814 .map(|wt| ThreadItemWorktreeInfo {
3815 name: wt.name.clone(),
3816 full_path: wt.full_path.clone(),
3817 highlight_positions: wt.highlight_positions.clone(),
3818 kind: wt.kind,
3819 })
3820 .collect(),
3821 )
3822 .timestamp(timestamp)
3823 .highlight_positions(thread.highlight_positions.to_vec())
3824 .title_generating(thread.is_title_generating)
3825 .notified(has_notification)
3826 .when(thread.diff_stats.lines_added > 0, |this| {
3827 this.added(thread.diff_stats.lines_added as usize)
3828 })
3829 .when(thread.diff_stats.lines_removed > 0, |this| {
3830 this.removed(thread.diff_stats.lines_removed as usize)
3831 })
3832 .selected(is_selected)
3833 .focused(is_focused)
3834 .hovered(is_hovered)
3835 .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| {
3836 if *is_hovered {
3837 this.hovered_thread_index = Some(ix);
3838 } else if this.hovered_thread_index == Some(ix) {
3839 this.hovered_thread_index = None;
3840 }
3841 cx.notify();
3842 }))
3843 .when(is_hovered && is_running, |this| {
3844 this.action_slot(
3845 IconButton::new("stop-thread", IconName::Stop)
3846 .icon_size(IconSize::Small)
3847 .icon_color(Color::Error)
3848 .style(ButtonStyle::Tinted(TintColor::Error))
3849 .tooltip(Tooltip::text("Stop Generation"))
3850 .on_click({
3851 cx.listener(move |this, _, _window, cx| {
3852 this.stop_thread(&thread_id_for_actions, cx);
3853 })
3854 }),
3855 )
3856 })
3857 .when(is_hovered && !is_running && is_draft, |this| {
3858 this.action_slot(
3859 div()
3860 .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
3861 cx.stop_propagation();
3862 })
3863 .child(
3864 IconButton::new("close-draft", IconName::Close)
3865 .icon_size(IconSize::Small)
3866 .icon_color(Color::Muted)
3867 .tooltip(Tooltip::text("Remove Draft"))
3868 .on_click({
3869 let thread_workspace = thread_workspace_for_dismiss.clone();
3870 cx.listener(move |this, _, window, cx| {
3871 if let ThreadEntryWorkspace::Open(workspace) =
3872 &thread_workspace
3873 {
3874 this.remove_draft(
3875 thread_id_for_actions,
3876 workspace,
3877 window,
3878 cx,
3879 );
3880 }
3881 })
3882 }),
3883 ),
3884 )
3885 })
3886 .when(is_hovered && !is_running && !is_draft, |this| {
3887 this.action_slot(
3888 IconButton::new("archive-thread", IconName::Archive)
3889 .icon_size(IconSize::Small)
3890 .icon_color(Color::Muted)
3891 .tooltip({
3892 let focus_handle = focus_handle.clone();
3893 move |_window, cx| {
3894 Tooltip::for_action_in(
3895 "Archive Thread",
3896 &RemoveSelectedThread,
3897 &focus_handle,
3898 cx,
3899 )
3900 }
3901 })
3902 .on_click({
3903 let session_id = session_id_for_delete.clone();
3904 cx.listener(move |this, _, window, cx| {
3905 if let Some(ref session_id) = session_id {
3906 this.archive_thread(session_id, window, cx);
3907 }
3908 })
3909 }),
3910 )
3911 })
3912 .on_click({
3913 cx.listener(move |this, _, window, cx| {
3914 this.selection = None;
3915 match &thread_workspace {
3916 ThreadEntryWorkspace::Open(workspace) => {
3917 this.activate_thread(metadata.clone(), workspace, false, window, cx);
3918 }
3919 ThreadEntryWorkspace::Closed {
3920 folder_paths,
3921 project_group_key,
3922 } => {
3923 this.open_workspace_and_activate_thread(
3924 metadata.clone(),
3925 folder_paths.clone(),
3926 project_group_key,
3927 window,
3928 cx,
3929 );
3930 }
3931 }
3932 })
3933 })
3934 .into_any_element()
3935 }
3936
3937 fn render_filter_input(&self, cx: &mut Context<Self>) -> impl IntoElement {
3938 div()
3939 .min_w_0()
3940 .flex_1()
3941 .capture_action(
3942 cx.listener(|this, _: &editor::actions::Newline, window, cx| {
3943 this.editor_confirm(window, cx);
3944 }),
3945 )
3946 .child(self.filter_editor.clone())
3947 }
3948
3949 fn render_recent_projects_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
3950 let multi_workspace = self.multi_workspace.upgrade();
3951
3952 let workspace = multi_workspace
3953 .as_ref()
3954 .map(|mw| mw.read(cx).workspace().downgrade());
3955
3956 let focus_handle = workspace
3957 .as_ref()
3958 .and_then(|ws| ws.upgrade())
3959 .map(|w| w.read(cx).focus_handle(cx))
3960 .unwrap_or_else(|| cx.focus_handle());
3961
3962 let window_project_groups: Vec<ProjectGroupKey> = multi_workspace
3963 .as_ref()
3964 .map(|mw| mw.read(cx).project_group_keys())
3965 .unwrap_or_default();
3966
3967 let popover_handle = self.recent_projects_popover_handle.clone();
3968
3969 PopoverMenu::new("sidebar-recent-projects-menu")
3970 .with_handle(popover_handle)
3971 .menu(move |window, cx| {
3972 workspace.as_ref().map(|ws| {
3973 SidebarRecentProjects::popover(
3974 ws.clone(),
3975 window_project_groups.clone(),
3976 focus_handle.clone(),
3977 window,
3978 cx,
3979 )
3980 })
3981 })
3982 .trigger_with_tooltip(
3983 IconButton::new("open-project", IconName::OpenFolder)
3984 .icon_size(IconSize::Small)
3985 .selected_style(ButtonStyle::Tinted(TintColor::Accent)),
3986 |_window, cx| {
3987 Tooltip::for_action(
3988 "Add Project",
3989 &OpenRecent {
3990 create_new_window: false,
3991 },
3992 cx,
3993 )
3994 },
3995 )
3996 .offset(gpui::Point {
3997 x: px(-2.0),
3998 y: px(-2.0),
3999 })
4000 .anchor(gpui::Corner::BottomRight)
4001 }
4002
4003 fn render_view_more(
4004 &self,
4005 ix: usize,
4006 key: &ProjectGroupKey,
4007 is_fully_expanded: bool,
4008 is_selected: bool,
4009 cx: &mut Context<Self>,
4010 ) -> AnyElement {
4011 let key = key.clone();
4012 let id = SharedString::from(format!("view-more-{}", ix));
4013
4014 let label: SharedString = if is_fully_expanded {
4015 "Collapse".into()
4016 } else {
4017 "View More".into()
4018 };
4019
4020 ThreadItem::new(id, label)
4021 .focused(is_selected)
4022 .icon_visible(false)
4023 .title_label_color(Color::Muted)
4024 .on_click(cx.listener(move |this, _, _window, cx| {
4025 this.selection = None;
4026 if is_fully_expanded {
4027 this.reset_thread_group_expansion(&key, cx);
4028 } else {
4029 this.expand_thread_group(&key, cx);
4030 }
4031 }))
4032 .into_any_element()
4033 }
4034
4035 fn new_thread_in_group(
4036 &mut self,
4037 _: &NewThreadInGroup,
4038 window: &mut Window,
4039 cx: &mut Context<Self>,
4040 ) {
4041 // If there is a keyboard selection, walk backwards through
4042 // `project_header_indices` to find the header that owns the selected
4043 // row. Otherwise fall back to the active workspace.
4044 // Always use the currently active workspace so that drafts
4045 // are created in the linked worktree the user is focused on,
4046 // not the main worktree resolved from the project header.
4047 let workspace = self
4048 .multi_workspace
4049 .upgrade()
4050 .map(|mw| mw.read(cx).workspace().clone());
4051
4052 let Some(workspace) = workspace else {
4053 return;
4054 };
4055
4056 self.create_new_thread(&workspace, window, cx);
4057 }
4058
4059 fn create_new_thread(
4060 &mut self,
4061 workspace: &Entity<Workspace>,
4062 window: &mut Window,
4063 cx: &mut Context<Self>,
4064 ) {
4065 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
4066 return;
4067 };
4068
4069 multi_workspace.update(cx, |multi_workspace, cx| {
4070 multi_workspace.activate(workspace.clone(), window, cx);
4071 });
4072
4073 let draft_id = workspace.update(cx, |workspace, cx| {
4074 let panel = workspace.panel::<AgentPanel>(cx)?;
4075 let draft_id = panel.update(cx, |panel, cx| {
4076 if let Some(id) = panel.draft_thread_ids(cx).first().copied() {
4077 if panel.active_thread_id(cx) != Some(id) {
4078 panel.activate_retained_thread(id, true, window, cx);
4079 }
4080 id
4081 } else {
4082 let id = panel.create_thread(window, cx);
4083 panel.activate_retained_thread(id, true, window, cx);
4084 id
4085 }
4086 });
4087 workspace.focus_panel::<AgentPanel>(window, cx);
4088 Some(draft_id)
4089 });
4090
4091 if let Some(draft_id) = draft_id {
4092 self.active_entry = Some(ActiveEntry {
4093 thread_id: draft_id,
4094 session_id: None,
4095 workspace: workspace.clone(),
4096 });
4097 }
4098 }
4099
4100 fn remove_draft(
4101 &mut self,
4102 draft_id: ThreadId,
4103 workspace: &Entity<Workspace>,
4104 window: &mut Window,
4105 cx: &mut Context<Self>,
4106 ) {
4107 workspace.update(cx, |ws, cx| {
4108 if let Some(panel) = ws.panel::<AgentPanel>(cx) {
4109 panel.update(cx, |panel, cx| {
4110 panel.remove_thread(draft_id, cx);
4111 });
4112 }
4113 });
4114
4115 let was_active = self
4116 .active_entry
4117 .as_ref()
4118 .is_some_and(|e| e.is_active_thread(&draft_id));
4119
4120 if was_active {
4121 let group_key = workspace.read(cx).project_group_key(cx);
4122
4123 // Find any remaining thread in the same group.
4124 let next = self.contents.entries.iter().find_map(|entry| {
4125 if let ListEntry::Thread(thread) = entry {
4126 if thread.metadata.thread_id != draft_id {
4127 if let ThreadEntryWorkspace::Open(ws) = &thread.workspace {
4128 if ws.read(cx).project_group_key(cx) == group_key {
4129 return Some((thread.metadata.clone(), ws.clone()));
4130 }
4131 }
4132 }
4133 }
4134 None
4135 });
4136 if let Some((metadata, ws)) = next {
4137 self.activate_thread(metadata, &ws, false, window, cx);
4138 } else {
4139 self.active_entry = None;
4140 }
4141 }
4142
4143 self.update_entries(cx);
4144 }
4145
4146 /// Cleans, collapses whitespace, and truncates raw editor text
4147 /// for display as a draft label in the sidebar.
4148 fn truncate_draft_label(raw: &str) -> Option<SharedString> {
4149 let first_line = raw.lines().next().unwrap_or("");
4150 let cleaned = Self::clean_mention_links(first_line);
4151 let mut text: String = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
4152 if text.is_empty() {
4153 return None;
4154 }
4155 const MAX_CHARS: usize = 250;
4156 if let Some((truncate_at, _)) = text.char_indices().nth(MAX_CHARS) {
4157 text.truncate(truncate_at);
4158 }
4159 Some(text.into())
4160 }
4161
4162 /// Reads a draft's prompt text from its ConversationView in the AgentPanel.
4163 fn read_draft_text(
4164 &self,
4165 draft_id: ThreadId,
4166 workspace: &Entity<Workspace>,
4167 cx: &App,
4168 ) -> Option<SharedString> {
4169 let panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
4170 let raw = panel.read(cx).editor_text(draft_id, cx)?;
4171 Self::truncate_draft_label(&raw)
4172 }
4173
4174 fn active_project_group_key(&self, cx: &App) -> Option<ProjectGroupKey> {
4175 let multi_workspace = self.multi_workspace.upgrade()?;
4176 let multi_workspace = multi_workspace.read(cx);
4177 Some(multi_workspace.project_group_key_for_workspace(multi_workspace.workspace(), cx))
4178 }
4179
4180 fn active_project_header_position(&self, cx: &App) -> Option<usize> {
4181 let active_key = self.active_project_group_key(cx)?;
4182 self.contents
4183 .project_header_indices
4184 .iter()
4185 .position(|&entry_ix| {
4186 matches!(
4187 &self.contents.entries[entry_ix],
4188 ListEntry::ProjectHeader { key, .. } if *key == active_key
4189 )
4190 })
4191 }
4192
4193 fn cycle_project_impl(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
4194 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
4195 return;
4196 };
4197
4198 let header_count = self.contents.project_header_indices.len();
4199 if header_count == 0 {
4200 return;
4201 }
4202
4203 let current_pos = self.active_project_header_position(cx);
4204
4205 let next_pos = match current_pos {
4206 Some(pos) => {
4207 if forward {
4208 (pos + 1) % header_count
4209 } else {
4210 (pos + header_count - 1) % header_count
4211 }
4212 }
4213 None => 0,
4214 };
4215
4216 let header_entry_ix = self.contents.project_header_indices[next_pos];
4217 let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(header_entry_ix)
4218 else {
4219 return;
4220 };
4221 let key = key.clone();
4222
4223 // Uncollapse the target group so that threads become visible.
4224 self.set_group_expanded(&key, true, cx);
4225
4226 if let Some(workspace) = self.multi_workspace.upgrade().and_then(|mw| {
4227 mw.read(cx)
4228 .workspace_for_paths(key.path_list(), key.host().as_ref(), cx)
4229 }) {
4230 multi_workspace.update(cx, |multi_workspace, cx| {
4231 multi_workspace.activate(workspace, window, cx);
4232 multi_workspace.retain_active_workspace(cx);
4233 });
4234 } else {
4235 self.open_workspace_for_group(&key, window, cx);
4236 }
4237 }
4238
4239 fn on_next_project(&mut self, _: &NextProject, window: &mut Window, cx: &mut Context<Self>) {
4240 self.cycle_project_impl(true, window, cx);
4241 }
4242
4243 fn on_previous_project(
4244 &mut self,
4245 _: &PreviousProject,
4246 window: &mut Window,
4247 cx: &mut Context<Self>,
4248 ) {
4249 self.cycle_project_impl(false, window, cx);
4250 }
4251
4252 fn cycle_thread_impl(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
4253 let thread_indices: Vec<usize> = self
4254 .contents
4255 .entries
4256 .iter()
4257 .enumerate()
4258 .filter_map(|(ix, entry)| match entry {
4259 ListEntry::Thread(_) => Some(ix),
4260 _ => None,
4261 })
4262 .collect();
4263
4264 if thread_indices.is_empty() {
4265 return;
4266 }
4267
4268 let current_thread_pos = self.active_entry.as_ref().and_then(|active| {
4269 thread_indices
4270 .iter()
4271 .position(|&ix| active.matches_entry(&self.contents.entries[ix]))
4272 });
4273
4274 let next_pos = match current_thread_pos {
4275 Some(pos) => {
4276 let count = thread_indices.len();
4277 if forward {
4278 (pos + 1) % count
4279 } else {
4280 (pos + count - 1) % count
4281 }
4282 }
4283 None => 0,
4284 };
4285
4286 let entry_ix = thread_indices[next_pos];
4287 let ListEntry::Thread(thread) = &self.contents.entries[entry_ix] else {
4288 return;
4289 };
4290
4291 let metadata = thread.metadata.clone();
4292 match &thread.workspace {
4293 ThreadEntryWorkspace::Open(workspace) => {
4294 let workspace = workspace.clone();
4295 self.activate_thread(metadata, &workspace, true, window, cx);
4296 }
4297 ThreadEntryWorkspace::Closed {
4298 folder_paths,
4299 project_group_key,
4300 } => {
4301 let folder_paths = folder_paths.clone();
4302 let project_group_key = project_group_key.clone();
4303 self.open_workspace_and_activate_thread(
4304 metadata,
4305 folder_paths,
4306 &project_group_key,
4307 window,
4308 cx,
4309 );
4310 }
4311 }
4312 }
4313
4314 fn on_next_thread(&mut self, _: &NextThread, window: &mut Window, cx: &mut Context<Self>) {
4315 self.cycle_thread_impl(true, window, cx);
4316 }
4317
4318 fn on_previous_thread(
4319 &mut self,
4320 _: &PreviousThread,
4321 window: &mut Window,
4322 cx: &mut Context<Self>,
4323 ) {
4324 self.cycle_thread_impl(false, window, cx);
4325 }
4326
4327 fn expand_thread_group(&mut self, project_group_key: &ProjectGroupKey, cx: &mut Context<Self>) {
4328 let current = self.group_extra_batches(project_group_key, cx);
4329 self.set_group_visible_thread_count(project_group_key, Some(current + 1), cx);
4330 self.update_entries(cx);
4331 }
4332
4333 fn reset_thread_group_expansion(
4334 &mut self,
4335 project_group_key: &ProjectGroupKey,
4336 cx: &mut Context<Self>,
4337 ) {
4338 self.set_group_visible_thread_count(project_group_key, None, cx);
4339 self.update_entries(cx);
4340 }
4341
4342 fn collapse_thread_group(
4343 &mut self,
4344 project_group_key: &ProjectGroupKey,
4345 cx: &mut Context<Self>,
4346 ) {
4347 let batches = self.group_extra_batches(project_group_key, cx);
4348 match batches {
4349 0 => return,
4350 1 => self.set_group_visible_thread_count(project_group_key, None, cx),
4351 _ => self.set_group_visible_thread_count(project_group_key, Some(batches - 1), cx),
4352 }
4353 self.update_entries(cx);
4354 }
4355
4356 fn on_show_more_threads(
4357 &mut self,
4358 _: &ShowMoreThreads,
4359 _window: &mut Window,
4360 cx: &mut Context<Self>,
4361 ) {
4362 let Some(active_key) = self.active_project_group_key(cx) else {
4363 return;
4364 };
4365 self.expand_thread_group(&active_key, cx);
4366 }
4367
4368 fn on_show_fewer_threads(
4369 &mut self,
4370 _: &ShowFewerThreads,
4371 _window: &mut Window,
4372 cx: &mut Context<Self>,
4373 ) {
4374 let Some(active_key) = self.active_project_group_key(cx) else {
4375 return;
4376 };
4377 self.collapse_thread_group(&active_key, cx);
4378 }
4379
4380 fn on_new_thread(
4381 &mut self,
4382 _: &workspace::NewThread,
4383 window: &mut Window,
4384 cx: &mut Context<Self>,
4385 ) {
4386 let Some(workspace) = self.active_workspace(cx) else {
4387 return;
4388 };
4389 self.create_new_thread(&workspace, window, cx);
4390 }
4391
4392 fn render_no_results(&self, cx: &mut Context<Self>) -> impl IntoElement {
4393 let has_query = self.has_filter_query(cx);
4394 let message = if has_query {
4395 "No threads match your search."
4396 } else {
4397 "No threads yet"
4398 };
4399
4400 v_flex()
4401 .id("sidebar-no-results")
4402 .p_4()
4403 .size_full()
4404 .items_center()
4405 .justify_center()
4406 .child(
4407 Label::new(message)
4408 .size(LabelSize::Small)
4409 .color(Color::Muted),
4410 )
4411 }
4412
4413 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
4414 v_flex()
4415 .id("sidebar-empty-state")
4416 .p_4()
4417 .size_full()
4418 .items_center()
4419 .justify_center()
4420 .gap_1()
4421 .track_focus(&self.focus_handle(cx))
4422 .child(
4423 Button::new("open_project", "Open Project")
4424 .full_width()
4425 .key_binding(KeyBinding::for_action(&workspace::Open::default(), cx))
4426 .on_click(|_, window, cx| {
4427 window.dispatch_action(
4428 Open {
4429 create_new_window: false,
4430 }
4431 .boxed_clone(),
4432 cx,
4433 );
4434 }),
4435 )
4436 .child(
4437 h_flex()
4438 .w_1_2()
4439 .gap_2()
4440 .child(Divider::horizontal().color(ui::DividerColor::Border))
4441 .child(Label::new("or").size(LabelSize::XSmall).color(Color::Muted))
4442 .child(Divider::horizontal().color(ui::DividerColor::Border)),
4443 )
4444 .child(
4445 Button::new("clone_repo", "Clone Repository")
4446 .full_width()
4447 .on_click(|_, window, cx| {
4448 window.dispatch_action(git::Clone.boxed_clone(), cx);
4449 }),
4450 )
4451 }
4452
4453 fn render_sidebar_header(
4454 &self,
4455 no_open_projects: bool,
4456 window: &Window,
4457 cx: &mut Context<Self>,
4458 ) -> impl IntoElement {
4459 let has_query = self.has_filter_query(cx);
4460 let sidebar_on_left = self.side(cx) == SidebarSide::Left;
4461 let sidebar_on_right = self.side(cx) == SidebarSide::Right;
4462 let not_fullscreen = !window.is_fullscreen();
4463 let traffic_lights = cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left;
4464 let left_window_controls = !cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left;
4465 let right_window_controls =
4466 !cfg!(target_os = "macos") && not_fullscreen && sidebar_on_right;
4467 let header_height = platform_title_bar_height(window);
4468
4469 h_flex()
4470 .h(header_height)
4471 .mt_px()
4472 .pb_px()
4473 .when(left_window_controls, |this| {
4474 this.children(Self::render_left_window_controls(window, cx))
4475 })
4476 .map(|this| {
4477 if traffic_lights {
4478 this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
4479 } else if !left_window_controls {
4480 this.pl_1p5()
4481 } else {
4482 this
4483 }
4484 })
4485 .when(!right_window_controls, |this| this.pr_1p5())
4486 .gap_1()
4487 .when(!no_open_projects, |this| {
4488 this.border_b_1()
4489 .border_color(cx.theme().colors().border)
4490 .when(traffic_lights, |this| {
4491 this.child(Divider::vertical().color(ui::DividerColor::Border))
4492 })
4493 .child(
4494 div().ml_1().child(
4495 Icon::new(IconName::MagnifyingGlass)
4496 .size(IconSize::Small)
4497 .color(Color::Muted),
4498 ),
4499 )
4500 .child(self.render_filter_input(cx))
4501 .child(
4502 h_flex()
4503 .gap_1()
4504 .when(
4505 self.selection.is_some()
4506 && !self.filter_editor.focus_handle(cx).is_focused(window),
4507 |this| this.child(KeyBinding::for_action(&FocusSidebarFilter, cx)),
4508 )
4509 .when(has_query, |this| {
4510 this.child(
4511 IconButton::new("clear_filter", IconName::Close)
4512 .icon_size(IconSize::Small)
4513 .tooltip(Tooltip::text("Clear Search"))
4514 .on_click(cx.listener(|this, _, window, cx| {
4515 this.reset_filter_editor_text(window, cx);
4516 this.update_entries(cx);
4517 })),
4518 )
4519 }),
4520 )
4521 })
4522 .when(right_window_controls, |this| {
4523 this.children(Self::render_right_window_controls(window, cx))
4524 })
4525 }
4526
4527 fn render_left_window_controls(window: &Window, cx: &mut App) -> Option<AnyElement> {
4528 platform_title_bar::render_left_window_controls(
4529 cx.button_layout(),
4530 Box::new(CloseWindow),
4531 window,
4532 )
4533 }
4534
4535 fn render_right_window_controls(window: &Window, cx: &mut App) -> Option<AnyElement> {
4536 platform_title_bar::render_right_window_controls(
4537 cx.button_layout(),
4538 Box::new(CloseWindow),
4539 window,
4540 )
4541 }
4542
4543 fn render_sidebar_toggle_button(&self, _cx: &mut Context<Self>) -> impl IntoElement {
4544 let on_right = AgentSettings::get_global(_cx).sidebar_side() == SidebarSide::Right;
4545
4546 sidebar_side_context_menu("sidebar-toggle-menu", _cx)
4547 .anchor(if on_right {
4548 gpui::Corner::BottomRight
4549 } else {
4550 gpui::Corner::BottomLeft
4551 })
4552 .attach(if on_right {
4553 gpui::Corner::TopRight
4554 } else {
4555 gpui::Corner::TopLeft
4556 })
4557 .trigger(move |_is_active, _window, _cx| {
4558 let icon = if on_right {
4559 IconName::ThreadsSidebarRightOpen
4560 } else {
4561 IconName::ThreadsSidebarLeftOpen
4562 };
4563 IconButton::new("sidebar-close-toggle", icon)
4564 .icon_size(IconSize::Small)
4565 .tooltip(Tooltip::element(move |_window, cx| {
4566 v_flex()
4567 .gap_1()
4568 .child(
4569 h_flex()
4570 .gap_2()
4571 .justify_between()
4572 .child(Label::new("Toggle Sidebar"))
4573 .child(KeyBinding::for_action(&ToggleWorkspaceSidebar, cx)),
4574 )
4575 .child(
4576 h_flex()
4577 .pt_1()
4578 .gap_2()
4579 .border_t_1()
4580 .border_color(cx.theme().colors().border_variant)
4581 .justify_between()
4582 .child(Label::new("Focus Sidebar"))
4583 .child(KeyBinding::for_action(&FocusWorkspaceSidebar, cx)),
4584 )
4585 .into_any_element()
4586 }))
4587 .on_click(|_, window, cx| {
4588 if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
4589 multi_workspace.update(cx, |multi_workspace, cx| {
4590 multi_workspace.close_sidebar(window, cx);
4591 });
4592 }
4593 })
4594 })
4595 }
4596
4597 fn render_sidebar_bottom_bar(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
4598 let is_archive = matches!(self.view, SidebarView::Archive(..));
4599 let show_import_button = is_archive && !self.should_render_acp_import_onboarding(cx);
4600 let on_right = self.side(cx) == SidebarSide::Right;
4601
4602 let action_buttons = h_flex()
4603 .gap_1()
4604 .when(on_right, |this| this.flex_row_reverse())
4605 .when(show_import_button, |this| {
4606 this.child(
4607 IconButton::new("thread-import", IconName::ThreadImport)
4608 .icon_size(IconSize::Small)
4609 .tooltip(Tooltip::text("Import ACP Threads"))
4610 .on_click(cx.listener(|this, _, window, cx| {
4611 this.show_archive(window, cx);
4612 this.show_thread_import_modal(window, cx);
4613 })),
4614 )
4615 })
4616 .child(
4617 IconButton::new("archive", IconName::Archive)
4618 .icon_size(IconSize::Small)
4619 .toggle_state(is_archive)
4620 .tooltip(move |_, cx| {
4621 Tooltip::for_action("Toggle Archived Threads", &ToggleArchive, cx)
4622 })
4623 .on_click(cx.listener(|this, _, window, cx| {
4624 this.toggle_archive(&ToggleArchive, window, cx);
4625 })),
4626 )
4627 .child(self.render_recent_projects_button(cx));
4628
4629 h_flex()
4630 .p_1()
4631 .gap_1()
4632 .when(on_right, |this| this.flex_row_reverse())
4633 .justify_between()
4634 .border_t_1()
4635 .border_color(cx.theme().colors().border)
4636 .child(self.render_sidebar_toggle_button(cx))
4637 .child(action_buttons)
4638 }
4639
4640 fn active_workspace(&self, cx: &App) -> Option<Entity<Workspace>> {
4641 self.multi_workspace
4642 .upgrade()
4643 .map(|w| w.read(cx).workspace().clone())
4644 }
4645
4646 fn show_thread_import_modal(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4647 let Some(active_workspace) = self.active_workspace(cx) else {
4648 return;
4649 };
4650
4651 let Some(agent_registry_store) = AgentRegistryStore::try_global(cx) else {
4652 return;
4653 };
4654
4655 let agent_server_store = active_workspace
4656 .read(cx)
4657 .project()
4658 .read(cx)
4659 .agent_server_store()
4660 .clone();
4661
4662 let workspace_handle = active_workspace.downgrade();
4663 let multi_workspace = self.multi_workspace.clone();
4664
4665 active_workspace.update(cx, |workspace, cx| {
4666 workspace.toggle_modal(window, cx, |window, cx| {
4667 ThreadImportModal::new(
4668 agent_server_store,
4669 agent_registry_store,
4670 workspace_handle.clone(),
4671 multi_workspace.clone(),
4672 window,
4673 cx,
4674 )
4675 });
4676 });
4677 }
4678
4679 fn should_render_acp_import_onboarding(&self, cx: &App) -> bool {
4680 let has_external_agents = self
4681 .active_workspace(cx)
4682 .map(|ws| {
4683 ws.read(cx)
4684 .project()
4685 .read(cx)
4686 .agent_server_store()
4687 .read(cx)
4688 .has_external_agents()
4689 })
4690 .unwrap_or(false);
4691
4692 has_external_agents && !AcpThreadImportOnboarding::dismissed(cx)
4693 }
4694
4695 fn render_acp_import_onboarding(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
4696 let description = "Import threads from agents like Claude Agent, Codex, and more, whether started in Zed or another client.";
4697
4698 let bg = cx.theme().colors().text_accent;
4699
4700 v_flex()
4701 .min_w_0()
4702 .w_full()
4703 .p_2()
4704 .border_t_1()
4705 .border_color(cx.theme().colors().border)
4706 .bg(linear_gradient(
4707 360.,
4708 linear_color_stop(bg.opacity(0.06), 1.),
4709 linear_color_stop(bg.opacity(0.), 0.),
4710 ))
4711 .child(
4712 h_flex()
4713 .min_w_0()
4714 .w_full()
4715 .gap_1()
4716 .justify_between()
4717 .child(Label::new("Looking for threads from external agents?"))
4718 .child(
4719 IconButton::new("close-onboarding", IconName::Close)
4720 .icon_size(IconSize::Small)
4721 .on_click(|_, _window, cx| AcpThreadImportOnboarding::dismiss(cx)),
4722 ),
4723 )
4724 .child(Label::new(description).color(Color::Muted).mb_2())
4725 .child(
4726 Button::new("import-acp", "Import Threads")
4727 .full_width()
4728 .style(ButtonStyle::OutlinedCustom(cx.theme().colors().border))
4729 .label_size(LabelSize::Small)
4730 .start_icon(
4731 Icon::new(IconName::ThreadImport)
4732 .size(IconSize::Small)
4733 .color(Color::Muted),
4734 )
4735 .on_click(cx.listener(|this, _, window, cx| {
4736 this.show_archive(window, cx);
4737 this.show_thread_import_modal(window, cx);
4738 })),
4739 )
4740 }
4741
4742 fn toggle_archive(&mut self, _: &ToggleArchive, window: &mut Window, cx: &mut Context<Self>) {
4743 match &self.view {
4744 SidebarView::ThreadList => self.show_archive(window, cx),
4745 SidebarView::Archive(_) => self.show_thread_list(window, cx),
4746 }
4747 }
4748
4749 fn show_archive(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4750 let Some(active_workspace) = self
4751 .multi_workspace
4752 .upgrade()
4753 .map(|w| w.read(cx).workspace().clone())
4754 else {
4755 return;
4756 };
4757 let Some(agent_panel) = active_workspace.read(cx).panel::<AgentPanel>(cx) else {
4758 return;
4759 };
4760
4761 let agent_server_store = active_workspace
4762 .read(cx)
4763 .project()
4764 .read(cx)
4765 .agent_server_store()
4766 .downgrade();
4767
4768 let agent_connection_store = agent_panel.read(cx).connection_store().downgrade();
4769
4770 let archive_view = cx.new(|cx| {
4771 ThreadsArchiveView::new(
4772 active_workspace.downgrade(),
4773 agent_connection_store.clone(),
4774 agent_server_store.clone(),
4775 window,
4776 cx,
4777 )
4778 });
4779
4780 let subscription = cx.subscribe_in(
4781 &archive_view,
4782 window,
4783 |this, _, event: &ThreadsArchiveViewEvent, window, cx| match event {
4784 ThreadsArchiveViewEvent::Close => {
4785 this.show_thread_list(window, cx);
4786 }
4787 ThreadsArchiveViewEvent::Unarchive { thread } => {
4788 this.activate_archived_thread(thread.clone(), window, cx);
4789 }
4790 ThreadsArchiveViewEvent::CancelRestore { thread_id } => {
4791 this.restoring_tasks.remove(thread_id);
4792 }
4793 },
4794 );
4795
4796 self._subscriptions.push(subscription);
4797 self.view = SidebarView::Archive(archive_view.clone());
4798 archive_view.update(cx, |view, cx| view.focus_filter_editor(window, cx));
4799 self.serialize(cx);
4800 cx.notify();
4801 }
4802
4803 fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4804 self.view = SidebarView::ThreadList;
4805 self._subscriptions.clear();
4806 let handle = self.filter_editor.read(cx).focus_handle(cx);
4807 handle.focus(window, cx);
4808 self.serialize(cx);
4809 cx.notify();
4810 }
4811}
4812
4813impl WorkspaceSidebar for Sidebar {
4814 fn width(&self, _cx: &App) -> Pixels {
4815 self.width
4816 }
4817
4818 fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
4819 self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
4820 cx.notify();
4821 }
4822
4823 fn has_notifications(&self, _cx: &App) -> bool {
4824 !self.contents.notified_threads.is_empty()
4825 }
4826
4827 fn is_threads_list_view_active(&self) -> bool {
4828 matches!(self.view, SidebarView::ThreadList)
4829 }
4830
4831 fn side(&self, cx: &App) -> SidebarSide {
4832 AgentSettings::get_global(cx).sidebar_side()
4833 }
4834
4835 fn prepare_for_focus(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
4836 self.selection = None;
4837 cx.notify();
4838 }
4839
4840 fn toggle_thread_switcher(
4841 &mut self,
4842 select_last: bool,
4843 window: &mut Window,
4844 cx: &mut Context<Self>,
4845 ) {
4846 self.toggle_thread_switcher_impl(select_last, window, cx);
4847 }
4848
4849 fn cycle_project(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
4850 self.cycle_project_impl(forward, window, cx);
4851 }
4852
4853 fn cycle_thread(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
4854 self.cycle_thread_impl(forward, window, cx);
4855 }
4856
4857 fn serialized_state(&self, _cx: &App) -> Option<String> {
4858 let serialized = SerializedSidebar {
4859 width: Some(f32::from(self.width)),
4860 active_view: match self.view {
4861 SidebarView::ThreadList => SerializedSidebarView::ThreadList,
4862 SidebarView::Archive(_) => SerializedSidebarView::Archive,
4863 },
4864 };
4865 serde_json::to_string(&serialized).ok()
4866 }
4867
4868 fn restore_serialized_state(
4869 &mut self,
4870 state: &str,
4871 window: &mut Window,
4872 cx: &mut Context<Self>,
4873 ) {
4874 if let Some(serialized) = serde_json::from_str::<SerializedSidebar>(state).log_err() {
4875 if let Some(width) = serialized.width {
4876 self.width = px(width).clamp(MIN_WIDTH, MAX_WIDTH);
4877 }
4878 if serialized.active_view == SerializedSidebarView::Archive {
4879 cx.defer_in(window, |this, window, cx| {
4880 this.show_archive(window, cx);
4881 });
4882 }
4883 }
4884 cx.notify();
4885 }
4886}
4887
4888impl gpui::EventEmitter<workspace::SidebarEvent> for Sidebar {}
4889
4890impl Focusable for Sidebar {
4891 fn focus_handle(&self, _cx: &App) -> FocusHandle {
4892 self.focus_handle.clone()
4893 }
4894}
4895
4896impl Render for Sidebar {
4897 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4898 let _titlebar_height = ui::utils::platform_title_bar_height(window);
4899 let ui_font = theme_settings::setup_ui_font(window, cx);
4900 let sticky_header = self.render_sticky_header(window, cx);
4901
4902 let color = cx.theme().colors();
4903 let bg = color
4904 .title_bar_background
4905 .blend(color.panel_background.opacity(0.25));
4906
4907 let no_open_projects = !self.contents.has_open_projects;
4908 let no_search_results = self.contents.entries.is_empty();
4909
4910 v_flex()
4911 .id("workspace-sidebar")
4912 .key_context(self.dispatch_context(window, cx))
4913 .track_focus(&self.focus_handle)
4914 .on_action(cx.listener(Self::select_next))
4915 .on_action(cx.listener(Self::select_previous))
4916 .on_action(cx.listener(Self::editor_move_down))
4917 .on_action(cx.listener(Self::editor_move_up))
4918 .on_action(cx.listener(Self::select_first))
4919 .on_action(cx.listener(Self::select_last))
4920 .on_action(cx.listener(Self::confirm))
4921 .on_action(cx.listener(Self::expand_selected_entry))
4922 .on_action(cx.listener(Self::collapse_selected_entry))
4923 .on_action(cx.listener(Self::toggle_selected_fold))
4924 .on_action(cx.listener(Self::fold_all))
4925 .on_action(cx.listener(Self::unfold_all))
4926 .on_action(cx.listener(Self::cancel))
4927 .on_action(cx.listener(Self::remove_selected_thread))
4928 .on_action(cx.listener(Self::new_thread_in_group))
4929 .on_action(cx.listener(Self::toggle_archive))
4930 .on_action(cx.listener(Self::focus_sidebar_filter))
4931 .on_action(cx.listener(Self::on_toggle_thread_switcher))
4932 .on_action(cx.listener(Self::on_next_project))
4933 .on_action(cx.listener(Self::on_previous_project))
4934 .on_action(cx.listener(Self::on_next_thread))
4935 .on_action(cx.listener(Self::on_previous_thread))
4936 .on_action(cx.listener(Self::on_show_more_threads))
4937 .on_action(cx.listener(Self::on_show_fewer_threads))
4938 .on_action(cx.listener(Self::on_new_thread))
4939 .on_action(cx.listener(|this, _: &OpenRecent, window, cx| {
4940 this.recent_projects_popover_handle.toggle(window, cx);
4941 }))
4942 .font(ui_font)
4943 .h_full()
4944 .w(self.width)
4945 .bg(bg)
4946 .when(self.side(cx) == SidebarSide::Left, |el| el.border_r_1())
4947 .when(self.side(cx) == SidebarSide::Right, |el| el.border_l_1())
4948 .border_color(color.border)
4949 .map(|this| match &self.view {
4950 SidebarView::ThreadList => this
4951 .child(self.render_sidebar_header(no_open_projects, window, cx))
4952 .map(|this| {
4953 if no_open_projects {
4954 this.child(self.render_empty_state(cx))
4955 } else {
4956 this.child(
4957 v_flex()
4958 .relative()
4959 .flex_1()
4960 .overflow_hidden()
4961 .child(
4962 list(
4963 self.list_state.clone(),
4964 cx.processor(Self::render_list_entry),
4965 )
4966 .flex_1()
4967 .size_full(),
4968 )
4969 .when(no_search_results, |this| {
4970 this.child(self.render_no_results(cx))
4971 })
4972 .when_some(sticky_header, |this, header| this.child(header))
4973 .vertical_scrollbar_for(&self.list_state, window, cx),
4974 )
4975 }
4976 }),
4977 SidebarView::Archive(archive_view) => this.child(archive_view.clone()),
4978 })
4979 .when(self.should_render_acp_import_onboarding(cx), |this| {
4980 this.child(self.render_acp_import_onboarding(cx))
4981 })
4982 .child(self.render_sidebar_bottom_bar(cx))
4983 }
4984}
4985
4986fn all_thread_infos_for_workspace(
4987 workspace: &Entity<Workspace>,
4988 cx: &App,
4989) -> impl Iterator<Item = ActiveThreadInfo> {
4990 let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
4991 return None.into_iter().flatten();
4992 };
4993 let agent_panel = agent_panel.read(cx);
4994 let threads = agent_panel
4995 .conversation_views()
4996 .into_iter()
4997 .filter_map(|conversation_view| {
4998 let has_pending_tool_call = conversation_view
4999 .read(cx)
5000 .root_thread_has_pending_tool_call(cx);
5001 let conversation_thread_id = conversation_view.read(cx).parent_id();
5002 let thread_view = conversation_view.read(cx).root_thread(cx)?;
5003 let thread_view_ref = thread_view.read(cx);
5004 let thread = thread_view_ref.thread.read(cx);
5005
5006 let icon = thread_view_ref.agent_icon;
5007 let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone();
5008 let title = thread
5009 .title()
5010 .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into());
5011 let is_native = thread_view_ref.as_native_thread(cx).is_some();
5012 let is_title_generating = is_native && thread.has_provisional_title();
5013 let session_id = thread.session_id().clone();
5014 let is_background = agent_panel.is_retained_thread(&conversation_thread_id);
5015
5016 let status = if has_pending_tool_call {
5017 AgentThreadStatus::WaitingForConfirmation
5018 } else if thread.had_error() {
5019 AgentThreadStatus::Error
5020 } else {
5021 match thread.status() {
5022 ThreadStatus::Generating => AgentThreadStatus::Running,
5023 ThreadStatus::Idle => AgentThreadStatus::Completed,
5024 }
5025 };
5026
5027 let diff_stats = thread.action_log().read(cx).diff_stats(cx);
5028
5029 Some(ActiveThreadInfo {
5030 session_id,
5031 title,
5032 status,
5033 icon,
5034 icon_from_external_svg,
5035 is_background,
5036 is_title_generating,
5037 diff_stats,
5038 })
5039 });
5040
5041 Some(threads).into_iter().flatten()
5042}
5043
5044pub fn dump_workspace_info(
5045 workspace: &mut Workspace,
5046 _: &DumpWorkspaceInfo,
5047 window: &mut gpui::Window,
5048 cx: &mut gpui::Context<Workspace>,
5049) {
5050 use std::fmt::Write;
5051
5052 let mut output = String::new();
5053 let this_entity = cx.entity();
5054
5055 let multi_workspace = workspace.multi_workspace().and_then(|weak| weak.upgrade());
5056 let workspaces: Vec<gpui::Entity<Workspace>> = match &multi_workspace {
5057 Some(mw) => mw.read(cx).workspaces().cloned().collect(),
5058 None => vec![this_entity.clone()],
5059 };
5060 let active_workspace = multi_workspace
5061 .as_ref()
5062 .map(|mw| mw.read(cx).workspace().clone());
5063
5064 writeln!(output, "MultiWorkspace: {} workspace(s)", workspaces.len()).ok();
5065
5066 if let Some(mw) = &multi_workspace {
5067 let keys: Vec<_> = mw.read(cx).project_group_keys();
5068 writeln!(output, "Project group keys ({}):", keys.len()).ok();
5069 for key in keys {
5070 writeln!(output, " - {key:?}").ok();
5071 }
5072 }
5073
5074 writeln!(output).ok();
5075
5076 for (index, ws) in workspaces.iter().enumerate() {
5077 let is_active = active_workspace.as_ref() == Some(ws);
5078 writeln!(
5079 output,
5080 "--- Workspace {index}{} ---",
5081 if is_active { " (active)" } else { "" }
5082 )
5083 .ok();
5084
5085 // project_group_key_for_workspace internally reads the workspace,
5086 // so we can only call it for workspaces other than this_entity
5087 // (which is already being updated).
5088 if let Some(mw) = &multi_workspace {
5089 if *ws == this_entity {
5090 let workspace_key = workspace.project_group_key(cx);
5091 writeln!(output, "ProjectGroupKey: {workspace_key:?}").ok();
5092 } else {
5093 let effective_key = mw.read(cx).project_group_key_for_workspace(ws, cx);
5094 let workspace_key = ws.read(cx).project_group_key(cx);
5095 if effective_key != workspace_key {
5096 writeln!(
5097 output,
5098 "ProjectGroupKey (multi_workspace): {effective_key:?}"
5099 )
5100 .ok();
5101 writeln!(
5102 output,
5103 "ProjectGroupKey (workspace, DISAGREES): {workspace_key:?}"
5104 )
5105 .ok();
5106 } else {
5107 writeln!(output, "ProjectGroupKey: {effective_key:?}").ok();
5108 }
5109 }
5110 } else {
5111 let workspace_key = workspace.project_group_key(cx);
5112 writeln!(output, "ProjectGroupKey: {workspace_key:?}").ok();
5113 }
5114
5115 // The action handler is already inside an update on `this_entity`,
5116 // so we must avoid a nested read/update on that same entity.
5117 if *ws == this_entity {
5118 dump_single_workspace(workspace, &mut output, cx);
5119 } else {
5120 ws.read_with(cx, |ws, cx| {
5121 dump_single_workspace(ws, &mut output, cx);
5122 });
5123 }
5124 }
5125
5126 let project = workspace.project().clone();
5127 cx.spawn_in(window, async move |_this, cx| {
5128 let buffer = project
5129 .update(cx, |project, cx| project.create_buffer(None, false, cx))
5130 .await?;
5131
5132 buffer.update(cx, |buffer, cx| {
5133 buffer.set_text(output, cx);
5134 });
5135
5136 let buffer = cx.new(|cx| {
5137 editor::MultiBuffer::singleton(buffer, cx).with_title("Workspace Info".into())
5138 });
5139
5140 _this.update_in(cx, |workspace, window, cx| {
5141 workspace.add_item_to_active_pane(
5142 Box::new(cx.new(|cx| {
5143 let mut editor =
5144 editor::Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
5145 editor.set_read_only(true);
5146 editor.set_should_serialize(false, cx);
5147 editor.set_breadcrumb_header("Workspace Info".into());
5148 editor
5149 })),
5150 None,
5151 true,
5152 window,
5153 cx,
5154 );
5155 })
5156 })
5157 .detach_and_log_err(cx);
5158}
5159
5160fn dump_single_workspace(workspace: &Workspace, output: &mut String, cx: &gpui::App) {
5161 use std::fmt::Write;
5162
5163 let workspace_db_id = workspace.database_id();
5164 match workspace_db_id {
5165 Some(id) => writeln!(output, "Workspace DB ID: {id:?}").ok(),
5166 None => writeln!(output, "Workspace DB ID: (none)").ok(),
5167 };
5168
5169 let project = workspace.project().read(cx);
5170
5171 let repos: Vec<_> = project
5172 .repositories(cx)
5173 .values()
5174 .map(|repo| repo.read(cx).snapshot())
5175 .collect();
5176
5177 writeln!(output, "Worktrees:").ok();
5178 for worktree in project.worktrees(cx) {
5179 let worktree = worktree.read(cx);
5180 let abs_path = worktree.abs_path();
5181 let visible = worktree.is_visible();
5182
5183 let repo_info = repos
5184 .iter()
5185 .find(|snapshot| abs_path.starts_with(&*snapshot.work_directory_abs_path));
5186
5187 let is_linked = repo_info.map(|s| s.is_linked_worktree()).unwrap_or(false);
5188 let original_repo_path = repo_info.map(|s| &s.original_repo_abs_path);
5189 let branch = repo_info.and_then(|s| s.branch.as_ref().map(|b| b.ref_name.clone()));
5190
5191 write!(output, " - {}", abs_path.display()).ok();
5192 if !visible {
5193 write!(output, " (hidden)").ok();
5194 }
5195 if let Some(branch) = &branch {
5196 write!(output, " [branch: {branch}]").ok();
5197 }
5198 if is_linked {
5199 if let Some(original) = original_repo_path {
5200 write!(output, " [linked worktree -> {}]", original.display()).ok();
5201 } else {
5202 write!(output, " [linked worktree]").ok();
5203 }
5204 }
5205 writeln!(output).ok();
5206 }
5207
5208 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
5209 let panel = panel.read(cx);
5210
5211 let panel_workspace_id = panel.workspace_id();
5212 if panel_workspace_id != workspace_db_id {
5213 writeln!(
5214 output,
5215 " \u{26a0} workspace ID mismatch! panel has {panel_workspace_id:?}, workspace has {workspace_db_id:?}"
5216 )
5217 .ok();
5218 }
5219
5220 if let Some(thread) = panel.active_agent_thread(cx) {
5221 let thread = thread.read(cx);
5222 let title = thread.title().unwrap_or_else(|| "(untitled)".into());
5223 let session_id = thread.session_id();
5224 let status = match thread.status() {
5225 ThreadStatus::Idle => "idle",
5226 ThreadStatus::Generating => "generating",
5227 };
5228 let entry_count = thread.entries().len();
5229 write!(output, "Active thread: {title} (session: {session_id})").ok();
5230 write!(output, " [{status}, {entry_count} entries").ok();
5231 if panel
5232 .active_conversation_view()
5233 .is_some_and(|conversation_view| {
5234 conversation_view
5235 .read(cx)
5236 .root_thread_has_pending_tool_call(cx)
5237 })
5238 {
5239 write!(output, ", awaiting confirmation").ok();
5240 }
5241 writeln!(output, "]").ok();
5242 } else {
5243 writeln!(output, "Active thread: (none)").ok();
5244 }
5245
5246 let background_threads = panel.retained_threads();
5247 if !background_threads.is_empty() {
5248 writeln!(
5249 output,
5250 "Background threads ({}): ",
5251 background_threads.len()
5252 )
5253 .ok();
5254 for (session_id, conversation_view) in background_threads {
5255 if let Some(thread_view) = conversation_view.read(cx).root_thread(cx) {
5256 let thread = thread_view.read(cx).thread.read(cx);
5257 let title = thread.title().unwrap_or_else(|| "(untitled)".into());
5258 let status = match thread.status() {
5259 ThreadStatus::Idle => "idle",
5260 ThreadStatus::Generating => "generating",
5261 };
5262 let entry_count = thread.entries().len();
5263 write!(output, " - {title} (thread: {session_id:?})").ok();
5264 write!(output, " [{status}, {entry_count} entries").ok();
5265 if conversation_view
5266 .read(cx)
5267 .root_thread_has_pending_tool_call(cx)
5268 {
5269 write!(output, ", awaiting confirmation").ok();
5270 }
5271 writeln!(output, "]").ok();
5272 } else {
5273 writeln!(output, " - (not connected) (thread: {session_id:?})").ok();
5274 }
5275 }
5276 }
5277 } else {
5278 writeln!(output, "Agent panel: not loaded").ok();
5279 }
5280
5281 writeln!(output).ok();
5282}