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