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