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