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 let group_has_threads = self
1423 .contents
1424 .entries
1425 .iter()
1426 .any(|e| matches!(e, ListEntry::ProjectHeader { key: hk, has_threads: true, .. } if hk == key));
1427 // Count drafts in the AgentPanel for this group's workspaces.
1428 let sibling_draft_count = workspace
1429 .as_ref()
1430 .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
1431 .map(|p| p.read(cx).draft_ids().len())
1432 .unwrap_or(0);
1433 let can_dismiss = group_has_threads || sibling_draft_count > 1;
1434 self.render_draft_thread(
1435 ix,
1436 *draft_id,
1437 key,
1438 workspace.as_ref(),
1439 is_active,
1440 worktrees,
1441 is_selected,
1442 can_dismiss,
1443 cx,
1444 )
1445 }
1446 };
1447
1448 if is_group_header_after_first {
1449 v_flex()
1450 .w_full()
1451 .border_t_1()
1452 .border_color(cx.theme().colors().border)
1453 .child(rendered)
1454 .into_any_element()
1455 } else {
1456 rendered
1457 }
1458 }
1459
1460 fn render_remote_project_icon(
1461 &self,
1462 ix: usize,
1463 host: Option<&RemoteConnectionOptions>,
1464 ) -> Option<AnyElement> {
1465 let remote_icon_per_type = match host? {
1466 RemoteConnectionOptions::Wsl(_) => IconName::Linux,
1467 RemoteConnectionOptions::Docker(_) => IconName::Box,
1468 _ => IconName::Server,
1469 };
1470
1471 Some(
1472 div()
1473 .id(format!("remote-project-icon-{}", ix))
1474 .child(
1475 Icon::new(remote_icon_per_type)
1476 .size(IconSize::XSmall)
1477 .color(Color::Muted),
1478 )
1479 .tooltip(Tooltip::text("Remote Project"))
1480 .into_any_element(),
1481 )
1482 }
1483
1484 fn render_project_header(
1485 &self,
1486 ix: usize,
1487 is_sticky: bool,
1488 key: &ProjectGroupKey,
1489 label: &SharedString,
1490 highlight_positions: &[usize],
1491 has_running_threads: bool,
1492 waiting_thread_count: usize,
1493 is_active: bool,
1494 is_focused: bool,
1495 has_threads: bool,
1496 cx: &mut Context<Self>,
1497 ) -> AnyElement {
1498 let host = key.host();
1499
1500 let id_prefix = if is_sticky { "sticky-" } else { "" };
1501 let id = SharedString::from(format!("{id_prefix}project-header-{ix}"));
1502 let disclosure_id = SharedString::from(format!("disclosure-{ix}"));
1503 let group_name = SharedString::from(format!("{id_prefix}header-group-{ix}"));
1504
1505 let is_collapsed = self.collapsed_groups.contains(key);
1506 let (disclosure_icon, disclosure_tooltip) = if is_collapsed {
1507 (IconName::ChevronRight, "Expand Project")
1508 } else {
1509 (IconName::ChevronDown, "Collapse Project")
1510 };
1511
1512 let key_for_toggle = key.clone();
1513 let key_for_collapse = key.clone();
1514 let view_more_expanded = self.expanded_groups.contains_key(key);
1515
1516 let label = if highlight_positions.is_empty() {
1517 Label::new(label.clone())
1518 .when(!is_active, |this| this.color(Color::Muted))
1519 .into_any_element()
1520 } else {
1521 HighlightedLabel::new(label.clone(), highlight_positions.to_vec())
1522 .when(!is_active, |this| this.color(Color::Muted))
1523 .into_any_element()
1524 };
1525
1526 let color = cx.theme().colors();
1527 let sidebar_base_bg = color
1528 .title_bar_background
1529 .blend(color.panel_background.opacity(0.25));
1530
1531 let base_bg = color.background.blend(sidebar_base_bg);
1532
1533 let hover_base = color
1534 .element_active
1535 .blend(color.element_background.opacity(0.2));
1536 let hover_solid = base_bg.blend(hover_base);
1537 let real_hover_color = if is_active { base_bg } else { hover_solid };
1538
1539 let group_name_for_gradient = group_name.clone();
1540 let gradient_overlay = move || {
1541 GradientFade::new(base_bg, real_hover_color, real_hover_color)
1542 .width(px(64.0))
1543 .right(px(-2.0))
1544 .gradient_stop(0.75)
1545 .group_name(group_name_for_gradient.clone())
1546 };
1547
1548 let is_ellipsis_menu_open = self.project_header_menu_ix == Some(ix);
1549
1550 h_flex()
1551 .id(id)
1552 .group(&group_name)
1553 .h(Tab::content_height(cx))
1554 .relative()
1555 .w_full()
1556 .pl(px(5.))
1557 .pr_1p5()
1558 .justify_between()
1559 .border_1()
1560 .map(|this| {
1561 if is_focused {
1562 this.border_color(color.border_focused)
1563 } else {
1564 this.border_color(gpui::transparent_black())
1565 }
1566 })
1567 .child(
1568 h_flex()
1569 .relative()
1570 .min_w_0()
1571 .w_full()
1572 .gap(px(5.))
1573 .child(
1574 IconButton::new(disclosure_id, disclosure_icon)
1575 .shape(ui::IconButtonShape::Square)
1576 .icon_size(IconSize::Small)
1577 .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.5)))
1578 .tooltip(Tooltip::text(disclosure_tooltip))
1579 .on_click(cx.listener(move |this, _, window, cx| {
1580 this.selection = None;
1581 this.toggle_collapse(&key_for_toggle, window, cx);
1582 })),
1583 )
1584 .child(label)
1585 .when_some(
1586 self.render_remote_project_icon(ix, host.as_ref()),
1587 |this, icon| this.child(icon),
1588 )
1589 .when(is_collapsed, |this| {
1590 this.when(has_running_threads, |this| {
1591 this.child(
1592 Icon::new(IconName::LoadCircle)
1593 .size(IconSize::XSmall)
1594 .color(Color::Muted)
1595 .with_rotate_animation(2),
1596 )
1597 })
1598 .when(waiting_thread_count > 0, |this| {
1599 let tooltip_text = if waiting_thread_count == 1 {
1600 "1 thread is waiting for confirmation".to_string()
1601 } else {
1602 format!(
1603 "{waiting_thread_count} threads are waiting for confirmation",
1604 )
1605 };
1606 this.child(
1607 div()
1608 .id(format!("{id_prefix}waiting-indicator-{ix}"))
1609 .child(
1610 Icon::new(IconName::Warning)
1611 .size(IconSize::XSmall)
1612 .color(Color::Warning),
1613 )
1614 .tooltip(Tooltip::text(tooltip_text)),
1615 )
1616 })
1617 }),
1618 )
1619 .child(gradient_overlay())
1620 .child(
1621 h_flex()
1622 .when(!is_ellipsis_menu_open, |this| {
1623 this.visible_on_hover(&group_name)
1624 })
1625 .child(gradient_overlay())
1626 .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
1627 cx.stop_propagation();
1628 })
1629 .child(self.render_project_header_ellipsis_menu(ix, id_prefix, key, cx))
1630 .when(view_more_expanded && !is_collapsed, |this| {
1631 this.child(
1632 IconButton::new(
1633 SharedString::from(format!(
1634 "{id_prefix}project-header-collapse-{ix}",
1635 )),
1636 IconName::ListCollapse,
1637 )
1638 .icon_size(IconSize::Small)
1639 .tooltip(Tooltip::text("Collapse Displayed Threads"))
1640 .on_click(cx.listener({
1641 let key_for_collapse = key_for_collapse.clone();
1642 move |this, _, _window, cx| {
1643 this.selection = None;
1644 this.expanded_groups.remove(&key_for_collapse);
1645 this.serialize(cx);
1646 this.update_entries(cx);
1647 }
1648 })),
1649 )
1650 })
1651 .child({
1652 let key = key.clone();
1653 let focus_handle = self.focus_handle.clone();
1654
1655 IconButton::new(
1656 SharedString::from(format!(
1657 "{id_prefix}project-header-new-thread-{ix}",
1658 )),
1659 IconName::Plus,
1660 )
1661 .icon_size(IconSize::Small)
1662 .tooltip(move |_, cx| {
1663 Tooltip::for_action_in(
1664 "Start New Agent Thread",
1665 &NewThread,
1666 &focus_handle,
1667 cx,
1668 )
1669 })
1670 .on_click(cx.listener(
1671 move |this, _, window, cx| {
1672 this.collapsed_groups.remove(&key);
1673 this.selection = None;
1674 // If the active workspace belongs to this
1675 // group, use it (preserves linked worktree
1676 // context). Otherwise resolve from the key.
1677 let workspace = this.multi_workspace.upgrade().and_then(|mw| {
1678 let mw = mw.read(cx);
1679 let active = mw.workspace().clone();
1680 let active_key = active.read(cx).project_group_key(cx);
1681 if active_key == key {
1682 Some(active)
1683 } else {
1684 mw.workspace_for_paths(
1685 key.path_list(),
1686 key.host().as_ref(),
1687 cx,
1688 )
1689 }
1690 });
1691 if let Some(workspace) = workspace {
1692 this.create_new_thread(&workspace, window, cx);
1693 } else {
1694 this.open_workspace_and_create_draft(&key, window, cx);
1695 }
1696 },
1697 ))
1698 }),
1699 )
1700 .map(|this| {
1701 if !has_threads && is_active {
1702 this
1703 } else {
1704 let key = key.clone();
1705 this.cursor_pointer()
1706 .when(!is_active, |this| this.hover(|s| s.bg(hover_solid)))
1707 .tooltip(Tooltip::text("Open Workspace"))
1708 .on_click(cx.listener(move |this, _, window, cx| {
1709 if let Some(workspace) = this.multi_workspace.upgrade().and_then(|mw| {
1710 mw.read(cx).workspace_for_paths(
1711 key.path_list(),
1712 key.host().as_ref(),
1713 cx,
1714 )
1715 }) {
1716 // Just activate the workspace. The
1717 // AgentPanel remembers what was last
1718 // shown, so the user returns to whatever
1719 // thread/draft they were looking at.
1720 this.activate_workspace(&workspace, window, cx);
1721 } else {
1722 this.open_workspace_for_group(&key, window, cx);
1723 }
1724 }))
1725 }
1726 })
1727 .into_any_element()
1728 }
1729
1730 fn render_project_header_ellipsis_menu(
1731 &self,
1732 ix: usize,
1733 id_prefix: &str,
1734 project_group_key: &ProjectGroupKey,
1735 cx: &mut Context<Self>,
1736 ) -> impl IntoElement {
1737 let multi_workspace = self.multi_workspace.clone();
1738 let this = cx.weak_entity();
1739 let project_group_key = project_group_key.clone();
1740
1741 PopoverMenu::new(format!("{id_prefix}project-header-menu-{ix}"))
1742 .on_open(Rc::new({
1743 let this = this.clone();
1744 move |_window, cx| {
1745 this.update(cx, |sidebar, cx| {
1746 sidebar.project_header_menu_ix = Some(ix);
1747 cx.notify();
1748 })
1749 .ok();
1750 }
1751 }))
1752 .menu(move |window, cx| {
1753 let multi_workspace = multi_workspace.clone();
1754 let project_group_key = project_group_key.clone();
1755
1756 let menu =
1757 ContextMenu::build_persistent(window, cx, move |menu, _window, menu_cx| {
1758 let weak_menu = menu_cx.weak_entity();
1759 let mut menu = menu
1760 .header("Project Folders")
1761 .end_slot_action(Box::new(menu::EndSlot));
1762
1763 for path in project_group_key.path_list().paths() {
1764 let Some(name) = path.file_name() else {
1765 continue;
1766 };
1767 let name: SharedString = name.to_string_lossy().into_owned().into();
1768 let path = path.clone();
1769 let project_group_key = project_group_key.clone();
1770 let multi_workspace = multi_workspace.clone();
1771 let weak_menu = weak_menu.clone();
1772 menu = menu.entry_with_end_slot_on_hover(
1773 name.clone(),
1774 None,
1775 |_, _| {},
1776 IconName::Close,
1777 "Remove Folder".into(),
1778 move |_window, cx| {
1779 multi_workspace
1780 .update(cx, |multi_workspace, cx| {
1781 multi_workspace.remove_folder_from_project_group(
1782 &project_group_key,
1783 &path,
1784 cx,
1785 );
1786 })
1787 .ok();
1788 weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
1789 },
1790 );
1791 }
1792
1793 let menu = menu.separator().entry(
1794 "Add Folder to Project",
1795 Some(Box::new(AddFolderToProject)),
1796 {
1797 let project_group_key = project_group_key.clone();
1798 let multi_workspace = multi_workspace.clone();
1799 let weak_menu = weak_menu.clone();
1800 move |window, cx| {
1801 multi_workspace
1802 .update(cx, |multi_workspace, cx| {
1803 multi_workspace.prompt_to_add_folders_to_project_group(
1804 &project_group_key,
1805 window,
1806 cx,
1807 );
1808 })
1809 .ok();
1810 weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
1811 }
1812 },
1813 );
1814
1815 let project_group_key = project_group_key.clone();
1816 let multi_workspace = multi_workspace.clone();
1817 menu.separator()
1818 .entry("Remove Project", None, move |window, cx| {
1819 multi_workspace
1820 .update(cx, |multi_workspace, cx| {
1821 multi_workspace
1822 .remove_project_group(&project_group_key, window, cx)
1823 .detach_and_log_err(cx);
1824 })
1825 .ok();
1826 weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
1827 })
1828 });
1829
1830 let this = this.clone();
1831 window
1832 .subscribe(&menu, cx, move |_, _: &gpui::DismissEvent, _window, cx| {
1833 this.update(cx, |sidebar, cx| {
1834 sidebar.project_header_menu_ix = None;
1835 cx.notify();
1836 })
1837 .ok();
1838 })
1839 .detach();
1840
1841 Some(menu)
1842 })
1843 .trigger(
1844 IconButton::new(
1845 SharedString::from(format!("{id_prefix}-ellipsis-menu-{ix}")),
1846 IconName::Ellipsis,
1847 )
1848 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1849 .icon_size(IconSize::Small),
1850 )
1851 .anchor(gpui::Corner::TopRight)
1852 .offset(gpui::Point {
1853 x: px(0.),
1854 y: px(1.),
1855 })
1856 }
1857
1858 fn render_sticky_header(
1859 &self,
1860 window: &mut Window,
1861 cx: &mut Context<Self>,
1862 ) -> Option<AnyElement> {
1863 let scroll_top = self.list_state.logical_scroll_top();
1864
1865 let &header_idx = self
1866 .contents
1867 .project_header_indices
1868 .iter()
1869 .rev()
1870 .find(|&&idx| idx <= scroll_top.item_ix)?;
1871
1872 let needs_sticky = header_idx < scroll_top.item_ix
1873 || (header_idx == scroll_top.item_ix && scroll_top.offset_in_item > px(0.));
1874
1875 if !needs_sticky {
1876 return None;
1877 }
1878
1879 let ListEntry::ProjectHeader {
1880 key,
1881 label,
1882 highlight_positions,
1883 has_running_threads,
1884 waiting_thread_count,
1885 is_active,
1886 has_threads,
1887 } = self.contents.entries.get(header_idx)?
1888 else {
1889 return None;
1890 };
1891
1892 let is_focused = self.focus_handle.is_focused(window);
1893 let is_selected = is_focused && self.selection == Some(header_idx);
1894
1895 let header_element = self.render_project_header(
1896 header_idx,
1897 true,
1898 key,
1899 &label,
1900 &highlight_positions,
1901 *has_running_threads,
1902 *waiting_thread_count,
1903 *is_active,
1904 *has_threads,
1905 is_selected,
1906 cx,
1907 );
1908
1909 let top_offset = self
1910 .contents
1911 .project_header_indices
1912 .iter()
1913 .find(|&&idx| idx > header_idx)
1914 .and_then(|&next_idx| {
1915 let bounds = self.list_state.bounds_for_item(next_idx)?;
1916 let viewport = self.list_state.viewport_bounds();
1917 let y_in_viewport = bounds.origin.y - viewport.origin.y;
1918 let header_height = bounds.size.height;
1919 (y_in_viewport < header_height).then_some(y_in_viewport - header_height)
1920 })
1921 .unwrap_or(px(0.));
1922
1923 let color = cx.theme().colors();
1924 let background = color
1925 .title_bar_background
1926 .blend(color.panel_background.opacity(0.2));
1927
1928 let element = v_flex()
1929 .absolute()
1930 .top(top_offset)
1931 .left_0()
1932 .w_full()
1933 .bg(background)
1934 .border_b_1()
1935 .border_color(color.border.opacity(0.5))
1936 .child(header_element)
1937 .shadow_xs()
1938 .into_any_element();
1939
1940 Some(element)
1941 }
1942
1943 fn toggle_collapse(
1944 &mut self,
1945 project_group_key: &ProjectGroupKey,
1946 _window: &mut Window,
1947 cx: &mut Context<Self>,
1948 ) {
1949 if self.collapsed_groups.contains(project_group_key) {
1950 self.collapsed_groups.remove(project_group_key);
1951 } else {
1952 self.collapsed_groups.insert(project_group_key.clone());
1953 }
1954 self.serialize(cx);
1955 self.update_entries(cx);
1956 }
1957
1958 fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
1959 let mut dispatch_context = KeyContext::new_with_defaults();
1960 dispatch_context.add("ThreadsSidebar");
1961 dispatch_context.add("menu");
1962
1963 let is_archived_search_focused = matches!(&self.view, SidebarView::Archive(archive) if archive.read(cx).is_filter_editor_focused(window, cx));
1964
1965 let identifier = if self.filter_editor.focus_handle(cx).is_focused(window)
1966 || is_archived_search_focused
1967 {
1968 "searching"
1969 } else {
1970 "not_searching"
1971 };
1972
1973 dispatch_context.add(identifier);
1974 dispatch_context
1975 }
1976
1977 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1978 if !self.focus_handle.is_focused(window) {
1979 return;
1980 }
1981
1982 if let SidebarView::Archive(archive) = &self.view {
1983 let has_selection = archive.read(cx).has_selection();
1984 if !has_selection {
1985 archive.update(cx, |view, cx| view.focus_filter_editor(window, cx));
1986 }
1987 } else if self.selection.is_none() {
1988 self.filter_editor.focus_handle(cx).focus(window, cx);
1989 }
1990 }
1991
1992 fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
1993 if self.reset_filter_editor_text(window, cx) {
1994 self.update_entries(cx);
1995 } else {
1996 self.selection = None;
1997 self.filter_editor.focus_handle(cx).focus(window, cx);
1998 cx.notify();
1999 }
2000 }
2001
2002 fn focus_sidebar_filter(
2003 &mut self,
2004 _: &FocusSidebarFilter,
2005 window: &mut Window,
2006 cx: &mut Context<Self>,
2007 ) {
2008 self.selection = None;
2009 if let SidebarView::Archive(archive) = &self.view {
2010 archive.update(cx, |view, cx| {
2011 view.clear_selection();
2012 view.focus_filter_editor(window, cx);
2013 });
2014 } else {
2015 self.filter_editor.focus_handle(cx).focus(window, cx);
2016 }
2017
2018 // When vim mode is active, the editor defaults to normal mode which
2019 // blocks text input. Switch to insert mode so the user can type
2020 // immediately.
2021 if vim_mode_setting::VimModeSetting::get_global(cx).0 {
2022 if let Ok(action) = cx.build_action("vim::SwitchToInsertMode", None) {
2023 window.dispatch_action(action, cx);
2024 }
2025 }
2026
2027 cx.notify();
2028 }
2029
2030 fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
2031 self.filter_editor.update(cx, |editor, cx| {
2032 if editor.buffer().read(cx).len(cx).0 > 0 {
2033 editor.set_text("", window, cx);
2034 true
2035 } else {
2036 false
2037 }
2038 })
2039 }
2040
2041 fn has_filter_query(&self, cx: &App) -> bool {
2042 !self.filter_editor.read(cx).text(cx).is_empty()
2043 }
2044
2045 fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
2046 self.select_next(&SelectNext, window, cx);
2047 if self.selection.is_some() {
2048 self.focus_handle.focus(window, cx);
2049 }
2050 }
2051
2052 fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
2053 self.select_previous(&SelectPrevious, window, cx);
2054 if self.selection.is_some() {
2055 self.focus_handle.focus(window, cx);
2056 }
2057 }
2058
2059 fn editor_confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2060 if self.selection.is_none() {
2061 self.select_next(&SelectNext, window, cx);
2062 }
2063 if self.selection.is_some() {
2064 self.focus_handle.focus(window, cx);
2065 }
2066 }
2067
2068 fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
2069 let next = match self.selection {
2070 Some(ix) if ix + 1 < self.contents.entries.len() => ix + 1,
2071 Some(_) if !self.contents.entries.is_empty() => 0,
2072 None if !self.contents.entries.is_empty() => 0,
2073 _ => return,
2074 };
2075 self.selection = Some(next);
2076 self.list_state.scroll_to_reveal_item(next);
2077 cx.notify();
2078 }
2079
2080 fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
2081 match self.selection {
2082 Some(0) => {
2083 self.selection = None;
2084 self.filter_editor.focus_handle(cx).focus(window, cx);
2085 cx.notify();
2086 }
2087 Some(ix) => {
2088 self.selection = Some(ix - 1);
2089 self.list_state.scroll_to_reveal_item(ix - 1);
2090 cx.notify();
2091 }
2092 None if !self.contents.entries.is_empty() => {
2093 let last = self.contents.entries.len() - 1;
2094 self.selection = Some(last);
2095 self.list_state.scroll_to_reveal_item(last);
2096 cx.notify();
2097 }
2098 None => {}
2099 }
2100 }
2101
2102 fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
2103 if !self.contents.entries.is_empty() {
2104 self.selection = Some(0);
2105 self.list_state.scroll_to_reveal_item(0);
2106 cx.notify();
2107 }
2108 }
2109
2110 fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
2111 if let Some(last) = self.contents.entries.len().checked_sub(1) {
2112 self.selection = Some(last);
2113 self.list_state.scroll_to_reveal_item(last);
2114 cx.notify();
2115 }
2116 }
2117
2118 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
2119 let Some(ix) = self.selection else { return };
2120 let Some(entry) = self.contents.entries.get(ix) else {
2121 return;
2122 };
2123
2124 match entry {
2125 ListEntry::ProjectHeader { key, .. } => {
2126 let key = key.clone();
2127 self.toggle_collapse(&key, window, cx);
2128 }
2129 ListEntry::Thread(thread) => {
2130 let metadata = thread.metadata.clone();
2131 match &thread.workspace {
2132 ThreadEntryWorkspace::Open(workspace) => {
2133 let workspace = workspace.clone();
2134 self.activate_thread(metadata, &workspace, false, window, cx);
2135 }
2136 ThreadEntryWorkspace::Closed {
2137 folder_paths,
2138 project_group_key,
2139 } => {
2140 let folder_paths = folder_paths.clone();
2141 let project_group_key = project_group_key.clone();
2142 self.open_workspace_and_activate_thread(
2143 metadata,
2144 folder_paths,
2145 &project_group_key,
2146 window,
2147 cx,
2148 );
2149 }
2150 }
2151 }
2152 ListEntry::ViewMore {
2153 key,
2154 is_fully_expanded,
2155 ..
2156 } => {
2157 let key = key.clone();
2158 if *is_fully_expanded {
2159 self.reset_thread_group_expansion(&key, cx);
2160 } else {
2161 self.expand_thread_group(&key, cx);
2162 }
2163 }
2164 ListEntry::DraftThread {
2165 draft_id,
2166 key,
2167 workspace,
2168 ..
2169 } => {
2170 let draft_id = *draft_id;
2171 let key = key.clone();
2172 let workspace = workspace.clone();
2173 if let Some(draft_id) = draft_id {
2174 if let Some(workspace) = workspace {
2175 self.activate_draft(draft_id, &workspace, window, cx);
2176 }
2177 } else if let Some(workspace) = workspace {
2178 self.activate_workspace(&workspace, window, cx);
2179 workspace.update(cx, |ws, cx| {
2180 ws.focus_panel::<AgentPanel>(window, cx);
2181 });
2182 } else {
2183 self.open_workspace_for_group(&key, window, cx);
2184 }
2185 }
2186 }
2187 }
2188
2189 fn find_workspace_across_windows(
2190 &self,
2191 cx: &App,
2192 predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
2193 ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
2194 cx.windows()
2195 .into_iter()
2196 .filter_map(|window| window.downcast::<MultiWorkspace>())
2197 .find_map(|window| {
2198 let workspace = window.read(cx).ok().and_then(|multi_workspace| {
2199 multi_workspace
2200 .workspaces()
2201 .find(|workspace| predicate(workspace, cx))
2202 .cloned()
2203 })?;
2204 Some((window, workspace))
2205 })
2206 }
2207
2208 fn find_workspace_in_current_window(
2209 &self,
2210 cx: &App,
2211 predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
2212 ) -> Option<Entity<Workspace>> {
2213 self.multi_workspace.upgrade().and_then(|multi_workspace| {
2214 multi_workspace
2215 .read(cx)
2216 .workspaces()
2217 .find(|workspace| predicate(workspace, cx))
2218 .cloned()
2219 })
2220 }
2221
2222 fn load_agent_thread_in_workspace(
2223 workspace: &Entity<Workspace>,
2224 metadata: &ThreadMetadata,
2225 focus: bool,
2226 window: &mut Window,
2227 cx: &mut App,
2228 ) {
2229 workspace.update(cx, |workspace, cx| {
2230 workspace.reveal_panel::<AgentPanel>(window, cx);
2231 });
2232
2233 if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2234 agent_panel.update(cx, |panel, cx| {
2235 panel.load_agent_thread(
2236 Agent::from(metadata.agent_id.clone()),
2237 metadata.session_id.clone(),
2238 Some(metadata.folder_paths().clone()),
2239 Some(metadata.title.clone()),
2240 focus,
2241 window,
2242 cx,
2243 );
2244 });
2245 }
2246 }
2247
2248 fn activate_thread_locally(
2249 &mut self,
2250 metadata: &ThreadMetadata,
2251 workspace: &Entity<Workspace>,
2252 retain: bool,
2253 window: &mut Window,
2254 cx: &mut Context<Self>,
2255 ) {
2256 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2257 return;
2258 };
2259
2260 // Set active_entry eagerly so the sidebar highlight updates
2261 // immediately, rather than waiting for a deferred AgentPanel
2262 // event which can race with ActiveWorkspaceChanged clearing it.
2263 self.active_entry = Some(ActiveEntry::Thread {
2264 session_id: metadata.session_id.clone(),
2265 workspace: workspace.clone(),
2266 });
2267 self.record_thread_access(&metadata.session_id);
2268
2269 multi_workspace.update(cx, |multi_workspace, cx| {
2270 multi_workspace.activate(workspace.clone(), window, cx);
2271 if retain {
2272 multi_workspace.retain_active_workspace(cx);
2273 }
2274 });
2275
2276 Self::load_agent_thread_in_workspace(workspace, metadata, true, window, cx);
2277
2278 self.update_entries(cx);
2279 }
2280
2281 fn activate_thread_in_other_window(
2282 &self,
2283 metadata: ThreadMetadata,
2284 workspace: Entity<Workspace>,
2285 target_window: WindowHandle<MultiWorkspace>,
2286 cx: &mut Context<Self>,
2287 ) {
2288 let target_session_id = metadata.session_id.clone();
2289 let workspace_for_entry = workspace.clone();
2290
2291 let activated = target_window
2292 .update(cx, |multi_workspace, window, cx| {
2293 window.activate_window();
2294 multi_workspace.activate(workspace.clone(), window, cx);
2295 Self::load_agent_thread_in_workspace(&workspace, &metadata, true, window, cx);
2296 })
2297 .log_err()
2298 .is_some();
2299
2300 if activated {
2301 if let Some(target_sidebar) = target_window
2302 .read(cx)
2303 .ok()
2304 .and_then(|multi_workspace| {
2305 multi_workspace.sidebar().map(|sidebar| sidebar.to_any())
2306 })
2307 .and_then(|sidebar| sidebar.downcast::<Self>().ok())
2308 {
2309 target_sidebar.update(cx, |sidebar, cx| {
2310 sidebar.active_entry = Some(ActiveEntry::Thread {
2311 session_id: target_session_id.clone(),
2312 workspace: workspace_for_entry.clone(),
2313 });
2314 sidebar.record_thread_access(&target_session_id);
2315 sidebar.update_entries(cx);
2316 });
2317 }
2318 }
2319 }
2320
2321 fn activate_thread(
2322 &mut self,
2323 metadata: ThreadMetadata,
2324 workspace: &Entity<Workspace>,
2325 retain: bool,
2326 window: &mut Window,
2327 cx: &mut Context<Self>,
2328 ) {
2329 if self
2330 .find_workspace_in_current_window(cx, |candidate, _| candidate == workspace)
2331 .is_some()
2332 {
2333 self.activate_thread_locally(&metadata, &workspace, retain, window, cx);
2334 return;
2335 }
2336
2337 let Some((target_window, workspace)) =
2338 self.find_workspace_across_windows(cx, |candidate, _| candidate == workspace)
2339 else {
2340 return;
2341 };
2342
2343 self.activate_thread_in_other_window(metadata, workspace, target_window, cx);
2344 }
2345
2346 fn open_workspace_and_activate_thread(
2347 &mut self,
2348 metadata: ThreadMetadata,
2349 folder_paths: PathList,
2350 project_group_key: &ProjectGroupKey,
2351 window: &mut Window,
2352 cx: &mut Context<Self>,
2353 ) {
2354 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2355 return;
2356 };
2357
2358 let pending_session_id = metadata.session_id.clone();
2359 // Mark the pending thread activation so rebuild_contents
2360 // preserves the Thread active_entry during loading (prevents
2361 // spurious draft flash).
2362 self.pending_remote_thread_activation = Some(pending_session_id.clone());
2363
2364 let host = project_group_key.host();
2365 let provisional_key = Some(project_group_key.clone());
2366 let active_workspace = multi_workspace.read(cx).workspace().clone();
2367 let modal_workspace = active_workspace.clone();
2368
2369 let open_task = multi_workspace.update(cx, |this, cx| {
2370 this.find_or_create_workspace(
2371 folder_paths,
2372 host,
2373 provisional_key,
2374 |options, window, cx| connect_remote(active_workspace, options, window, cx),
2375 window,
2376 cx,
2377 )
2378 });
2379
2380 cx.spawn_in(window, async move |this, cx| {
2381 let result = open_task.await;
2382 // Dismiss the modal as soon as the open attempt completes so
2383 // failures or cancellations do not leave a stale connection modal behind.
2384 remote_connection::dismiss_connection_modal(&modal_workspace, cx);
2385
2386 if result.is_err() {
2387 this.update(cx, |this, _cx| {
2388 if this.pending_remote_thread_activation.as_ref() == Some(&pending_session_id) {
2389 this.pending_remote_thread_activation = None;
2390 }
2391 })
2392 .ok();
2393 }
2394
2395 let workspace = result?;
2396 this.update_in(cx, |this, window, cx| {
2397 this.activate_thread(metadata, &workspace, false, window, cx);
2398 })?;
2399 anyhow::Ok(())
2400 })
2401 .detach_and_log_err(cx);
2402 }
2403
2404 fn find_current_workspace_for_path_list(
2405 &self,
2406 path_list: &PathList,
2407 cx: &App,
2408 ) -> Option<Entity<Workspace>> {
2409 self.find_workspace_in_current_window(cx, |workspace, cx| {
2410 workspace_path_list(workspace, cx).paths() == path_list.paths()
2411 })
2412 }
2413
2414 fn find_open_workspace_for_path_list(
2415 &self,
2416 path_list: &PathList,
2417 cx: &App,
2418 ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
2419 self.find_workspace_across_windows(cx, |workspace, cx| {
2420 workspace_path_list(workspace, cx).paths() == path_list.paths()
2421 })
2422 }
2423
2424 fn activate_archived_thread(
2425 &mut self,
2426 metadata: ThreadMetadata,
2427 window: &mut Window,
2428 cx: &mut Context<Self>,
2429 ) {
2430 let session_id = metadata.session_id.clone();
2431 let weak_archive_view = match &self.view {
2432 SidebarView::Archive(view) => Some(view.downgrade()),
2433 _ => None,
2434 };
2435
2436 if metadata.folder_paths().paths().is_empty() {
2437 ThreadMetadataStore::global(cx)
2438 .update(cx, |store, cx| store.unarchive(&session_id, cx));
2439
2440 let active_workspace = self
2441 .multi_workspace
2442 .upgrade()
2443 .map(|w| w.read(cx).workspace().clone());
2444
2445 if let Some(workspace) = active_workspace {
2446 self.activate_thread_locally(&metadata, &workspace, false, window, cx);
2447 } else {
2448 let path_list = metadata.folder_paths().clone();
2449 if let Some((target_window, workspace)) =
2450 self.find_open_workspace_for_path_list(&path_list, cx)
2451 {
2452 self.activate_thread_in_other_window(metadata, workspace, target_window, cx);
2453 } else {
2454 let key = ProjectGroupKey::new(None, path_list.clone());
2455 self.open_workspace_and_activate_thread(metadata, path_list, &key, window, cx);
2456 }
2457 }
2458 self.show_thread_list(window, cx);
2459 return;
2460 }
2461
2462 let store = ThreadMetadataStore::global(cx);
2463 let task = store
2464 .read(cx)
2465 .get_archived_worktrees_for_thread(session_id.0.to_string(), cx);
2466 let path_list = metadata.folder_paths().clone();
2467
2468 let task_session_id = session_id.clone();
2469 let restore_task = cx.spawn_in(window, async move |this, cx| {
2470 let result: anyhow::Result<()> = async {
2471 let archived_worktrees = task.await?;
2472
2473 if archived_worktrees.is_empty() {
2474 this.update_in(cx, |this, window, cx| {
2475 this.restoring_tasks.remove(&session_id);
2476 ThreadMetadataStore::global(cx)
2477 .update(cx, |store, cx| store.unarchive(&session_id, cx));
2478
2479 if let Some(workspace) =
2480 this.find_current_workspace_for_path_list(&path_list, cx)
2481 {
2482 this.activate_thread_locally(&metadata, &workspace, false, window, cx);
2483 } else if let Some((target_window, workspace)) =
2484 this.find_open_workspace_for_path_list(&path_list, cx)
2485 {
2486 this.activate_thread_in_other_window(
2487 metadata,
2488 workspace,
2489 target_window,
2490 cx,
2491 );
2492 } else {
2493 let key = ProjectGroupKey::new(None, path_list.clone());
2494 this.open_workspace_and_activate_thread(
2495 metadata, path_list, &key, window, cx,
2496 );
2497 }
2498 this.show_thread_list(window, cx);
2499 })?;
2500 return anyhow::Ok(());
2501 }
2502
2503 let mut path_replacements: Vec<(PathBuf, PathBuf)> = Vec::new();
2504 for row in &archived_worktrees {
2505 match thread_worktree_archive::restore_worktree_via_git(row, &mut *cx).await {
2506 Ok(restored_path) => {
2507 thread_worktree_archive::cleanup_archived_worktree_record(
2508 row, &mut *cx,
2509 )
2510 .await;
2511 path_replacements.push((row.worktree_path.clone(), restored_path));
2512 }
2513 Err(error) => {
2514 log::error!("Failed to restore worktree: {error:#}");
2515 this.update_in(cx, |this, _window, cx| {
2516 this.restoring_tasks.remove(&session_id);
2517 if let Some(weak_archive_view) = &weak_archive_view {
2518 weak_archive_view
2519 .update(cx, |view, cx| {
2520 view.clear_restoring(&session_id, cx);
2521 })
2522 .ok();
2523 }
2524
2525 if let Some(multi_workspace) = this.multi_workspace.upgrade() {
2526 let workspace = multi_workspace.read(cx).workspace().clone();
2527 workspace.update(cx, |workspace, cx| {
2528 struct RestoreWorktreeErrorToast;
2529 workspace.show_toast(
2530 Toast::new(
2531 NotificationId::unique::<RestoreWorktreeErrorToast>(
2532 ),
2533 format!("Failed to restore worktree: {error:#}"),
2534 )
2535 .autohide(),
2536 cx,
2537 );
2538 });
2539 }
2540 })
2541 .ok();
2542 return anyhow::Ok(());
2543 }
2544 }
2545 }
2546
2547 if !path_replacements.is_empty() {
2548 cx.update(|_window, cx| {
2549 store.update(cx, |store, cx| {
2550 store.update_restored_worktree_paths(
2551 &session_id,
2552 &path_replacements,
2553 cx,
2554 );
2555 });
2556 })?;
2557
2558 let updated_metadata =
2559 cx.update(|_window, cx| store.read(cx).entry(&session_id).cloned())?;
2560
2561 if let Some(updated_metadata) = updated_metadata {
2562 let new_paths = updated_metadata.folder_paths().clone();
2563
2564 cx.update(|_window, cx| {
2565 store.update(cx, |store, cx| {
2566 store.unarchive(&updated_metadata.session_id, cx);
2567 });
2568 })?;
2569
2570 this.update_in(cx, |this, window, cx| {
2571 this.restoring_tasks.remove(&session_id);
2572 let key = ProjectGroupKey::new(None, new_paths.clone());
2573 this.open_workspace_and_activate_thread(
2574 updated_metadata,
2575 new_paths,
2576 &key,
2577 window,
2578 cx,
2579 );
2580 this.show_thread_list(window, cx);
2581 })?;
2582 }
2583 }
2584
2585 anyhow::Ok(())
2586 }
2587 .await;
2588 if let Err(error) = result {
2589 log::error!("{error:#}");
2590 }
2591 });
2592 self.restoring_tasks.insert(task_session_id, restore_task);
2593 }
2594
2595 fn expand_selected_entry(
2596 &mut self,
2597 _: &SelectChild,
2598 _window: &mut Window,
2599 cx: &mut Context<Self>,
2600 ) {
2601 let Some(ix) = self.selection else { return };
2602
2603 match self.contents.entries.get(ix) {
2604 Some(ListEntry::ProjectHeader { key, .. }) => {
2605 if self.collapsed_groups.contains(key) {
2606 self.collapsed_groups.remove(key);
2607 self.update_entries(cx);
2608 } else if ix + 1 < self.contents.entries.len() {
2609 self.selection = Some(ix + 1);
2610 self.list_state.scroll_to_reveal_item(ix + 1);
2611 cx.notify();
2612 }
2613 }
2614 _ => {}
2615 }
2616 }
2617
2618 fn collapse_selected_entry(
2619 &mut self,
2620 _: &SelectParent,
2621 _window: &mut Window,
2622 cx: &mut Context<Self>,
2623 ) {
2624 let Some(ix) = self.selection else { return };
2625
2626 match self.contents.entries.get(ix) {
2627 Some(ListEntry::ProjectHeader { key, .. }) => {
2628 if !self.collapsed_groups.contains(key) {
2629 self.collapsed_groups.insert(key.clone());
2630 self.update_entries(cx);
2631 }
2632 }
2633 Some(
2634 ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::DraftThread { .. },
2635 ) => {
2636 for i in (0..ix).rev() {
2637 if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(i)
2638 {
2639 self.selection = Some(i);
2640 self.collapsed_groups.insert(key.clone());
2641 self.update_entries(cx);
2642 break;
2643 }
2644 }
2645 }
2646 None => {}
2647 }
2648 }
2649
2650 fn toggle_selected_fold(
2651 &mut self,
2652 _: &editor::actions::ToggleFold,
2653 _window: &mut Window,
2654 cx: &mut Context<Self>,
2655 ) {
2656 let Some(ix) = self.selection else { return };
2657
2658 // Find the group header for the current selection.
2659 let header_ix = match self.contents.entries.get(ix) {
2660 Some(ListEntry::ProjectHeader { .. }) => Some(ix),
2661 Some(
2662 ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::DraftThread { .. },
2663 ) => (0..ix).rev().find(|&i| {
2664 matches!(
2665 self.contents.entries.get(i),
2666 Some(ListEntry::ProjectHeader { .. })
2667 )
2668 }),
2669 None => None,
2670 };
2671
2672 if let Some(header_ix) = header_ix {
2673 if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(header_ix)
2674 {
2675 if self.collapsed_groups.contains(key) {
2676 self.collapsed_groups.remove(key);
2677 } else {
2678 self.selection = Some(header_ix);
2679 self.collapsed_groups.insert(key.clone());
2680 }
2681 self.update_entries(cx);
2682 }
2683 }
2684 }
2685
2686 fn fold_all(
2687 &mut self,
2688 _: &editor::actions::FoldAll,
2689 _window: &mut Window,
2690 cx: &mut Context<Self>,
2691 ) {
2692 for entry in &self.contents.entries {
2693 if let ListEntry::ProjectHeader { key, .. } = entry {
2694 self.collapsed_groups.insert(key.clone());
2695 }
2696 }
2697 self.update_entries(cx);
2698 }
2699
2700 fn unfold_all(
2701 &mut self,
2702 _: &editor::actions::UnfoldAll,
2703 _window: &mut Window,
2704 cx: &mut Context<Self>,
2705 ) {
2706 self.collapsed_groups.clear();
2707 self.update_entries(cx);
2708 }
2709
2710 fn stop_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
2711 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2712 return;
2713 };
2714
2715 let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect();
2716 for workspace in workspaces {
2717 if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2718 let cancelled =
2719 agent_panel.update(cx, |panel, cx| panel.cancel_thread(session_id, cx));
2720 if cancelled {
2721 return;
2722 }
2723 }
2724 }
2725 }
2726
2727 fn archive_thread(
2728 &mut self,
2729 session_id: &acp::SessionId,
2730 window: &mut Window,
2731 cx: &mut Context<Self>,
2732 ) {
2733 let metadata = ThreadMetadataStore::global(cx)
2734 .read(cx)
2735 .entry(session_id)
2736 .cloned();
2737 let thread_folder_paths = metadata.as_ref().map(|m| m.folder_paths().clone());
2738
2739 // Compute which linked worktree roots should be archived from disk if
2740 // this thread is archived. This must happen before we remove any
2741 // workspace from the MultiWorkspace, because `build_root_plan` needs
2742 // the currently open workspaces in order to find the affected projects
2743 // and repository handles for each linked worktree.
2744 let roots_to_archive = metadata
2745 .as_ref()
2746 .map(|metadata| {
2747 let mut workspaces = self
2748 .multi_workspace
2749 .upgrade()
2750 .map(|multi_workspace| {
2751 multi_workspace
2752 .read(cx)
2753 .workspaces()
2754 .cloned()
2755 .collect::<Vec<_>>()
2756 })
2757 .unwrap_or_default();
2758 for workspace in thread_worktree_archive::all_open_workspaces(cx) {
2759 if !workspaces.contains(&workspace) {
2760 workspaces.push(workspace);
2761 }
2762 }
2763 metadata
2764 .folder_paths()
2765 .ordered_paths()
2766 .filter_map(|path| {
2767 thread_worktree_archive::build_root_plan(path, &workspaces, cx)
2768 })
2769 .filter(|plan| {
2770 !thread_worktree_archive::path_is_referenced_by_other_unarchived_threads(
2771 session_id,
2772 &plan.root_path,
2773 cx,
2774 )
2775 })
2776 .collect::<Vec<_>>()
2777 })
2778 .unwrap_or_default();
2779
2780 // Find the neighbor thread in the sidebar (by display position).
2781 // Look below first, then above, for the nearest thread that isn't
2782 // the one being archived. We capture both the neighbor's metadata
2783 // (for activation) and its workspace paths (for the workspace
2784 // removal fallback).
2785 let current_pos = self.contents.entries.iter().position(
2786 |entry| matches!(entry, ListEntry::Thread(t) if &t.metadata.session_id == session_id),
2787 );
2788 let neighbor = current_pos.and_then(|pos| {
2789 self.contents.entries[pos + 1..]
2790 .iter()
2791 .chain(self.contents.entries[..pos].iter().rev())
2792 .find_map(|entry| match entry {
2793 ListEntry::Thread(t) if t.metadata.session_id != *session_id => {
2794 let workspace_paths = match &t.workspace {
2795 ThreadEntryWorkspace::Open(ws) => {
2796 PathList::new(&ws.read(cx).root_paths(cx))
2797 }
2798 ThreadEntryWorkspace::Closed { folder_paths, .. } => {
2799 folder_paths.clone()
2800 }
2801 };
2802 Some((t.metadata.clone(), workspace_paths))
2803 }
2804 _ => None,
2805 })
2806 });
2807
2808 // Check if archiving this thread would leave its worktree workspace
2809 // with no threads, requiring workspace removal.
2810 let workspace_to_remove = thread_folder_paths.as_ref().and_then(|folder_paths| {
2811 if folder_paths.is_empty() {
2812 return None;
2813 }
2814
2815 let remaining = ThreadMetadataStore::global(cx)
2816 .read(cx)
2817 .entries_for_path(folder_paths)
2818 .filter(|t| t.session_id != *session_id)
2819 .count();
2820
2821 if remaining > 0 {
2822 return None;
2823 }
2824
2825 let multi_workspace = self.multi_workspace.upgrade()?;
2826 let workspace = multi_workspace
2827 .read(cx)
2828 .workspace_for_paths(folder_paths, None, cx)?;
2829
2830 let group_key = workspace.read(cx).project_group_key(cx);
2831 let is_linked_worktree = group_key.path_list() != folder_paths;
2832
2833 is_linked_worktree.then_some(workspace)
2834 });
2835
2836 if let Some(workspace_to_remove) = workspace_to_remove {
2837 let multi_workspace = self.multi_workspace.upgrade().unwrap();
2838 let session_id = session_id.clone();
2839
2840 // For the workspace-removal fallback, use the neighbor's workspace
2841 // paths if available, otherwise fall back to the project group key.
2842 let fallback_paths = neighbor
2843 .as_ref()
2844 .map(|(_, paths)| paths.clone())
2845 .unwrap_or_else(|| {
2846 workspace_to_remove
2847 .read(cx)
2848 .project_group_key(cx)
2849 .path_list()
2850 .clone()
2851 });
2852
2853 let remove_task = multi_workspace.update(cx, |mw, cx| {
2854 mw.remove(
2855 [workspace_to_remove],
2856 move |this, window, cx| {
2857 this.find_or_create_local_workspace(fallback_paths, window, cx)
2858 },
2859 window,
2860 cx,
2861 )
2862 });
2863
2864 let neighbor_metadata = neighbor.map(|(metadata, _)| metadata);
2865 let thread_folder_paths = thread_folder_paths.clone();
2866 cx.spawn_in(window, async move |this, cx| {
2867 let removed = remove_task.await?;
2868 if removed {
2869 this.update_in(cx, |this, window, cx| {
2870 let in_flight =
2871 this.start_archive_worktree_task(&session_id, roots_to_archive, cx);
2872 this.archive_and_activate(
2873 &session_id,
2874 neighbor_metadata.as_ref(),
2875 thread_folder_paths.as_ref(),
2876 in_flight,
2877 window,
2878 cx,
2879 );
2880 })?;
2881 }
2882 anyhow::Ok(())
2883 })
2884 .detach_and_log_err(cx);
2885 } else {
2886 let neighbor_metadata = neighbor.map(|(metadata, _)| metadata);
2887 let in_flight = self.start_archive_worktree_task(session_id, roots_to_archive, cx);
2888 self.archive_and_activate(
2889 session_id,
2890 neighbor_metadata.as_ref(),
2891 thread_folder_paths.as_ref(),
2892 in_flight,
2893 window,
2894 cx,
2895 );
2896 }
2897 }
2898
2899 /// Archive a thread and activate the nearest neighbor or a draft.
2900 ///
2901 /// IMPORTANT: when activating a neighbor or creating a fallback draft,
2902 /// this method also activates the target workspace in the MultiWorkspace.
2903 /// This is critical because `rebuild_contents` derives the active
2904 /// workspace from `mw.workspace()`. If the linked worktree workspace is
2905 /// still active after archiving its last thread, `rebuild_contents` sees
2906 /// the threadless linked worktree as active and emits a spurious
2907 /// "+ New Thread" entry with the worktree chip — keeping the worktree
2908 /// alive and preventing disk cleanup.
2909 ///
2910 /// When `in_flight_archive` is present, it is the background task that
2911 /// persists the linked worktree's git state and deletes it from disk.
2912 /// We attach it to the metadata store at the same time we mark the thread
2913 /// archived so failures can automatically unarchive the thread and user-
2914 /// initiated unarchive can cancel the task.
2915 fn archive_and_activate(
2916 &mut self,
2917 session_id: &acp::SessionId,
2918 neighbor: Option<&ThreadMetadata>,
2919 thread_folder_paths: Option<&PathList>,
2920 in_flight_archive: Option<(Task<()>, smol::channel::Sender<()>)>,
2921 window: &mut Window,
2922 cx: &mut Context<Self>,
2923 ) {
2924 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
2925 store.archive(session_id, in_flight_archive, cx);
2926 });
2927
2928 let is_active = self
2929 .active_entry
2930 .as_ref()
2931 .is_some_and(|e| e.is_active_thread(session_id));
2932
2933 if !is_active {
2934 // The user is looking at a different thread/draft. Clear the
2935 // archived thread from its workspace's panel so that switching
2936 // to that workspace later doesn't show a stale thread.
2937 if let Some(folder_paths) = thread_folder_paths {
2938 if let Some(workspace) = self
2939 .multi_workspace
2940 .upgrade()
2941 .and_then(|mw| mw.read(cx).workspace_for_paths(folder_paths, None, cx))
2942 {
2943 if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
2944 let panel_shows_archived = panel
2945 .read(cx)
2946 .active_conversation_view()
2947 .and_then(|cv| cv.read(cx).parent_id(cx))
2948 .is_some_and(|id| id == *session_id);
2949 if panel_shows_archived {
2950 panel.update(cx, |panel, cx| {
2951 // Replace the archived thread with a
2952 // tracked draft so the panel isn't left
2953 // in Uninitialized state.
2954 let id = panel.create_draft(window, cx);
2955 panel.activate_draft(id, false, window, cx);
2956 });
2957 }
2958 }
2959 }
2960 }
2961 return;
2962 }
2963
2964 // Try to activate the neighbor thread. If its workspace is open,
2965 // tell the panel to load it and activate that workspace.
2966 // `rebuild_contents` will reconcile `active_entry` once the thread
2967 // finishes loading.
2968
2969 if let Some(metadata) = neighbor {
2970 if let Some(workspace) = self.multi_workspace.upgrade().and_then(|mw| {
2971 mw.read(cx)
2972 .workspace_for_paths(metadata.folder_paths(), None, cx)
2973 }) {
2974 self.activate_workspace(&workspace, window, cx);
2975 Self::load_agent_thread_in_workspace(&workspace, metadata, true, window, cx);
2976 return;
2977 }
2978 }
2979
2980 // No neighbor or its workspace isn't open — fall back to a new
2981 // draft. Use the group workspace (main project) rather than the
2982 // active entry workspace, which may be a linked worktree that is
2983 // about to be cleaned up or already removed.
2984 let fallback_workspace = thread_folder_paths
2985 .and_then(|folder_paths| {
2986 let mw = self.multi_workspace.upgrade()?;
2987 let mw = mw.read(cx);
2988 let thread_workspace = mw.workspace_for_paths(folder_paths, None, cx)?;
2989 let group_key = thread_workspace.read(cx).project_group_key(cx);
2990 mw.workspace_for_paths(group_key.path_list(), None, cx)
2991 })
2992 .or_else(|| {
2993 self.multi_workspace
2994 .upgrade()
2995 .map(|mw| mw.read(cx).workspace().clone())
2996 });
2997
2998 if let Some(workspace) = fallback_workspace {
2999 self.activate_workspace(&workspace, window, cx);
3000 self.create_new_thread(&workspace, window, cx);
3001 }
3002 }
3003
3004 fn start_archive_worktree_task(
3005 &self,
3006 session_id: &acp::SessionId,
3007 roots: Vec<thread_worktree_archive::RootPlan>,
3008 cx: &mut Context<Self>,
3009 ) -> Option<(Task<()>, smol::channel::Sender<()>)> {
3010 if roots.is_empty() {
3011 return None;
3012 }
3013
3014 let (cancel_tx, cancel_rx) = smol::channel::bounded::<()>(1);
3015 let session_id = session_id.clone();
3016 let task = cx.spawn(async move |_this, cx| {
3017 match Self::archive_worktree_roots(roots, cancel_rx, cx).await {
3018 Ok(ArchiveWorktreeOutcome::Success) => {
3019 cx.update(|cx| {
3020 ThreadMetadataStore::global(cx).update(cx, |store, _cx| {
3021 store.cleanup_completed_archive(&session_id);
3022 });
3023 });
3024 }
3025 Ok(ArchiveWorktreeOutcome::Cancelled) => {}
3026 Err(error) => {
3027 log::error!("Failed to archive worktree: {error:#}");
3028 cx.update(|cx| {
3029 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
3030 store.unarchive(&session_id, cx);
3031 });
3032 });
3033 }
3034 }
3035 });
3036
3037 Some((task, cancel_tx))
3038 }
3039
3040 async fn archive_worktree_roots(
3041 roots: Vec<thread_worktree_archive::RootPlan>,
3042 cancel_rx: smol::channel::Receiver<()>,
3043 cx: &mut gpui::AsyncApp,
3044 ) -> anyhow::Result<ArchiveWorktreeOutcome> {
3045 let mut completed_persists: Vec<(i64, thread_worktree_archive::RootPlan)> = Vec::new();
3046
3047 for root in &roots {
3048 if cancel_rx.is_closed() {
3049 for &(id, ref completed_root) in completed_persists.iter().rev() {
3050 thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
3051 }
3052 return Ok(ArchiveWorktreeOutcome::Cancelled);
3053 }
3054
3055 if root.worktree_repo.is_some() {
3056 match thread_worktree_archive::persist_worktree_state(root, cx).await {
3057 Ok(id) => {
3058 completed_persists.push((id, root.clone()));
3059 }
3060 Err(error) => {
3061 for &(id, ref completed_root) in completed_persists.iter().rev() {
3062 thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
3063 }
3064 return Err(error);
3065 }
3066 }
3067 }
3068
3069 if cancel_rx.is_closed() {
3070 for &(id, ref completed_root) in completed_persists.iter().rev() {
3071 thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
3072 }
3073 return Ok(ArchiveWorktreeOutcome::Cancelled);
3074 }
3075
3076 if let Err(error) = thread_worktree_archive::remove_root(root.clone(), cx).await {
3077 if let Some(&(id, ref completed_root)) = completed_persists.last() {
3078 if completed_root.root_path == root.root_path {
3079 thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
3080 completed_persists.pop();
3081 }
3082 }
3083 for &(id, ref completed_root) in completed_persists.iter().rev() {
3084 thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
3085 }
3086 return Err(error);
3087 }
3088 }
3089
3090 Ok(ArchiveWorktreeOutcome::Success)
3091 }
3092
3093 fn activate_workspace(
3094 &self,
3095 workspace: &Entity<Workspace>,
3096 window: &mut Window,
3097 cx: &mut Context<Self>,
3098 ) {
3099 if let Some(multi_workspace) = self.multi_workspace.upgrade() {
3100 multi_workspace.update(cx, |mw, cx| {
3101 mw.activate(workspace.clone(), window, cx);
3102 });
3103 }
3104 }
3105
3106 fn remove_selected_thread(
3107 &mut self,
3108 _: &RemoveSelectedThread,
3109 window: &mut Window,
3110 cx: &mut Context<Self>,
3111 ) {
3112 let Some(ix) = self.selection else {
3113 return;
3114 };
3115 match self.contents.entries.get(ix) {
3116 Some(ListEntry::Thread(thread)) => {
3117 match thread.status {
3118 AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation => {
3119 return;
3120 }
3121 AgentThreadStatus::Completed | AgentThreadStatus::Error => {}
3122 }
3123 let session_id = thread.metadata.session_id.clone();
3124 self.archive_thread(&session_id, window, cx);
3125 }
3126 Some(ListEntry::DraftThread {
3127 draft_id: Some(draft_id),
3128 workspace: Some(workspace),
3129 ..
3130 }) => {
3131 let draft_id = *draft_id;
3132 let workspace = workspace.clone();
3133 self.remove_draft(draft_id, &workspace, window, cx);
3134 }
3135 _ => {}
3136 }
3137 }
3138
3139 fn record_thread_access(&mut self, session_id: &acp::SessionId) {
3140 self.thread_last_accessed
3141 .insert(session_id.clone(), Utc::now());
3142 }
3143
3144 fn record_thread_message_sent(&mut self, session_id: &acp::SessionId) {
3145 self.thread_last_message_sent_or_queued
3146 .insert(session_id.clone(), Utc::now());
3147 }
3148
3149 fn mru_threads_for_switcher(&self, cx: &App) -> Vec<ThreadSwitcherEntry> {
3150 let mut current_header_label: Option<SharedString> = None;
3151 let mut current_header_key: Option<ProjectGroupKey> = None;
3152 let mut entries: Vec<ThreadSwitcherEntry> = self
3153 .contents
3154 .entries
3155 .iter()
3156 .filter_map(|entry| match entry {
3157 ListEntry::ProjectHeader { label, key, .. } => {
3158 current_header_label = Some(label.clone());
3159 current_header_key = Some(key.clone());
3160 None
3161 }
3162 ListEntry::Thread(thread) => {
3163 let workspace = match &thread.workspace {
3164 ThreadEntryWorkspace::Open(workspace) => Some(workspace.clone()),
3165 ThreadEntryWorkspace::Closed { .. } => {
3166 current_header_key.as_ref().and_then(|key| {
3167 self.multi_workspace.upgrade().and_then(|mw| {
3168 mw.read(cx).workspace_for_paths(
3169 key.path_list(),
3170 key.host().as_ref(),
3171 cx,
3172 )
3173 })
3174 })
3175 }
3176 }?;
3177 let notified = self
3178 .contents
3179 .is_thread_notified(&thread.metadata.session_id);
3180 let timestamp: SharedString = format_history_entry_timestamp(
3181 self.thread_last_message_sent_or_queued
3182 .get(&thread.metadata.session_id)
3183 .copied()
3184 .or(thread.metadata.created_at)
3185 .unwrap_or(thread.metadata.updated_at),
3186 )
3187 .into();
3188 Some(ThreadSwitcherEntry {
3189 session_id: thread.metadata.session_id.clone(),
3190 title: thread.metadata.title.clone(),
3191 icon: thread.icon,
3192 icon_from_external_svg: thread.icon_from_external_svg.clone(),
3193 status: thread.status,
3194 metadata: thread.metadata.clone(),
3195 workspace,
3196 project_name: current_header_label.clone(),
3197 worktrees: thread
3198 .worktrees
3199 .iter()
3200 .map(|wt| ThreadItemWorktreeInfo {
3201 name: wt.name.clone(),
3202 full_path: wt.full_path.clone(),
3203 highlight_positions: Vec::new(),
3204 kind: wt.kind,
3205 })
3206 .collect(),
3207 diff_stats: thread.diff_stats,
3208 is_title_generating: thread.is_title_generating,
3209 notified,
3210 timestamp,
3211 })
3212 }
3213 _ => None,
3214 })
3215 .collect();
3216
3217 entries.sort_by(|a, b| {
3218 let a_accessed = self.thread_last_accessed.get(&a.session_id);
3219 let b_accessed = self.thread_last_accessed.get(&b.session_id);
3220
3221 match (a_accessed, b_accessed) {
3222 (Some(a_time), Some(b_time)) => b_time.cmp(a_time),
3223 (Some(_), None) => std::cmp::Ordering::Less,
3224 (None, Some(_)) => std::cmp::Ordering::Greater,
3225 (None, None) => {
3226 let a_sent = self.thread_last_message_sent_or_queued.get(&a.session_id);
3227 let b_sent = self.thread_last_message_sent_or_queued.get(&b.session_id);
3228
3229 match (a_sent, b_sent) {
3230 (Some(a_time), Some(b_time)) => b_time.cmp(a_time),
3231 (Some(_), None) => std::cmp::Ordering::Less,
3232 (None, Some(_)) => std::cmp::Ordering::Greater,
3233 (None, None) => {
3234 let a_time = a.metadata.created_at.or(Some(a.metadata.updated_at));
3235 let b_time = b.metadata.created_at.or(Some(b.metadata.updated_at));
3236 b_time.cmp(&a_time)
3237 }
3238 }
3239 }
3240 }
3241 });
3242
3243 entries
3244 }
3245
3246 fn dismiss_thread_switcher(&mut self, cx: &mut Context<Self>) {
3247 self.thread_switcher = None;
3248 self._thread_switcher_subscriptions.clear();
3249 if let Some(mw) = self.multi_workspace.upgrade() {
3250 mw.update(cx, |mw, cx| {
3251 mw.set_sidebar_overlay(None, cx);
3252 });
3253 }
3254 }
3255
3256 fn on_toggle_thread_switcher(
3257 &mut self,
3258 action: &ToggleThreadSwitcher,
3259 window: &mut Window,
3260 cx: &mut Context<Self>,
3261 ) {
3262 self.toggle_thread_switcher_impl(action.select_last, window, cx);
3263 }
3264
3265 fn toggle_thread_switcher_impl(
3266 &mut self,
3267 select_last: bool,
3268 window: &mut Window,
3269 cx: &mut Context<Self>,
3270 ) {
3271 if let Some(thread_switcher) = &self.thread_switcher {
3272 thread_switcher.update(cx, |switcher, cx| {
3273 if select_last {
3274 switcher.select_last(cx);
3275 } else {
3276 switcher.cycle_selection(cx);
3277 }
3278 });
3279 return;
3280 }
3281
3282 let entries = self.mru_threads_for_switcher(cx);
3283 if entries.len() < 2 {
3284 return;
3285 }
3286
3287 let weak_multi_workspace = self.multi_workspace.clone();
3288
3289 let original_metadata = match &self.active_entry {
3290 Some(ActiveEntry::Thread { session_id, .. }) => entries
3291 .iter()
3292 .find(|e| &e.session_id == session_id)
3293 .map(|e| e.metadata.clone()),
3294 _ => None,
3295 };
3296 let original_workspace = self
3297 .multi_workspace
3298 .upgrade()
3299 .map(|mw| mw.read(cx).workspace().clone());
3300
3301 let thread_switcher = cx.new(|cx| ThreadSwitcher::new(entries, select_last, window, cx));
3302
3303 let mut subscriptions = Vec::new();
3304
3305 subscriptions.push(cx.subscribe_in(&thread_switcher, window, {
3306 let thread_switcher = thread_switcher.clone();
3307 move |this, _emitter, event: &ThreadSwitcherEvent, window, cx| match event {
3308 ThreadSwitcherEvent::Preview {
3309 metadata,
3310 workspace,
3311 } => {
3312 if let Some(mw) = weak_multi_workspace.upgrade() {
3313 mw.update(cx, |mw, cx| {
3314 mw.activate(workspace.clone(), window, cx);
3315 });
3316 }
3317 this.active_entry = Some(ActiveEntry::Thread {
3318 session_id: metadata.session_id.clone(),
3319 workspace: workspace.clone(),
3320 });
3321 this.update_entries(cx);
3322 Self::load_agent_thread_in_workspace(workspace, metadata, false, window, cx);
3323 let focus = thread_switcher.focus_handle(cx);
3324 window.focus(&focus, cx);
3325 }
3326 ThreadSwitcherEvent::Confirmed {
3327 metadata,
3328 workspace,
3329 } => {
3330 if let Some(mw) = weak_multi_workspace.upgrade() {
3331 mw.update(cx, |mw, cx| {
3332 mw.activate(workspace.clone(), window, cx);
3333 mw.retain_active_workspace(cx);
3334 });
3335 }
3336 this.record_thread_access(&metadata.session_id);
3337 this.active_entry = Some(ActiveEntry::Thread {
3338 session_id: metadata.session_id.clone(),
3339 workspace: workspace.clone(),
3340 });
3341 this.update_entries(cx);
3342 Self::load_agent_thread_in_workspace(workspace, metadata, false, window, cx);
3343 this.dismiss_thread_switcher(cx);
3344 workspace.update(cx, |workspace, cx| {
3345 workspace.focus_panel::<AgentPanel>(window, cx);
3346 });
3347 }
3348 ThreadSwitcherEvent::Dismissed => {
3349 if let Some(mw) = weak_multi_workspace.upgrade() {
3350 if let Some(original_ws) = &original_workspace {
3351 mw.update(cx, |mw, cx| {
3352 mw.activate(original_ws.clone(), window, cx);
3353 });
3354 }
3355 }
3356 if let Some(metadata) = &original_metadata {
3357 if let Some(original_ws) = &original_workspace {
3358 this.active_entry = Some(ActiveEntry::Thread {
3359 session_id: metadata.session_id.clone(),
3360 workspace: original_ws.clone(),
3361 });
3362 }
3363 this.update_entries(cx);
3364 if let Some(original_ws) = &original_workspace {
3365 Self::load_agent_thread_in_workspace(
3366 original_ws,
3367 metadata,
3368 false,
3369 window,
3370 cx,
3371 );
3372 }
3373 }
3374 this.dismiss_thread_switcher(cx);
3375 }
3376 }
3377 }));
3378
3379 subscriptions.push(cx.subscribe_in(
3380 &thread_switcher,
3381 window,
3382 |this, _emitter, _event: &gpui::DismissEvent, _window, cx| {
3383 this.dismiss_thread_switcher(cx);
3384 },
3385 ));
3386
3387 let focus = thread_switcher.focus_handle(cx);
3388 let overlay_view = gpui::AnyView::from(thread_switcher.clone());
3389
3390 // Replay the initial preview that was emitted during construction
3391 // before subscriptions were wired up.
3392 let initial_preview = thread_switcher
3393 .read(cx)
3394 .selected_entry()
3395 .map(|entry| (entry.metadata.clone(), entry.workspace.clone()));
3396
3397 self.thread_switcher = Some(thread_switcher);
3398 self._thread_switcher_subscriptions = subscriptions;
3399 if let Some(mw) = self.multi_workspace.upgrade() {
3400 mw.update(cx, |mw, cx| {
3401 mw.set_sidebar_overlay(Some(overlay_view), cx);
3402 });
3403 }
3404
3405 if let Some((metadata, workspace)) = initial_preview {
3406 if let Some(mw) = self.multi_workspace.upgrade() {
3407 mw.update(cx, |mw, cx| {
3408 mw.activate(workspace.clone(), window, cx);
3409 });
3410 }
3411 self.active_entry = Some(ActiveEntry::Thread {
3412 session_id: metadata.session_id.clone(),
3413 workspace: workspace.clone(),
3414 });
3415 self.update_entries(cx);
3416 Self::load_agent_thread_in_workspace(&workspace, &metadata, false, window, cx);
3417 }
3418
3419 window.focus(&focus, cx);
3420 }
3421
3422 fn render_thread(
3423 &self,
3424 ix: usize,
3425 thread: &ThreadEntry,
3426 is_active: bool,
3427 is_focused: bool,
3428 cx: &mut Context<Self>,
3429 ) -> AnyElement {
3430 let has_notification = self
3431 .contents
3432 .is_thread_notified(&thread.metadata.session_id);
3433
3434 let title: SharedString = thread.metadata.title.clone();
3435 let metadata = thread.metadata.clone();
3436 let thread_workspace = thread.workspace.clone();
3437
3438 let is_hovered = self.hovered_thread_index == Some(ix);
3439 let is_selected = is_active;
3440 let is_running = matches!(
3441 thread.status,
3442 AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
3443 );
3444
3445 let session_id_for_delete = thread.metadata.session_id.clone();
3446 let focus_handle = self.focus_handle.clone();
3447
3448 let id = SharedString::from(format!("thread-entry-{}", ix));
3449
3450 let color = cx.theme().colors();
3451 let sidebar_bg = color
3452 .title_bar_background
3453 .blend(color.panel_background.opacity(0.25));
3454
3455 let timestamp = format_history_entry_timestamp(
3456 self.thread_last_message_sent_or_queued
3457 .get(&thread.metadata.session_id)
3458 .copied()
3459 .or(thread.metadata.created_at)
3460 .unwrap_or(thread.metadata.updated_at),
3461 );
3462
3463 let is_remote = thread.workspace.is_remote(cx);
3464
3465 ThreadItem::new(id, title)
3466 .base_bg(sidebar_bg)
3467 .icon(thread.icon)
3468 .status(thread.status)
3469 .is_remote(is_remote)
3470 .when_some(thread.icon_from_external_svg.clone(), |this, svg| {
3471 this.custom_icon_from_external_svg(svg)
3472 })
3473 .worktrees(
3474 thread
3475 .worktrees
3476 .iter()
3477 .map(|wt| ThreadItemWorktreeInfo {
3478 name: wt.name.clone(),
3479 full_path: wt.full_path.clone(),
3480 highlight_positions: wt.highlight_positions.clone(),
3481 kind: wt.kind,
3482 })
3483 .collect(),
3484 )
3485 .timestamp(timestamp)
3486 .highlight_positions(thread.highlight_positions.to_vec())
3487 .title_generating(thread.is_title_generating)
3488 .notified(has_notification)
3489 .when(thread.diff_stats.lines_added > 0, |this| {
3490 this.added(thread.diff_stats.lines_added as usize)
3491 })
3492 .when(thread.diff_stats.lines_removed > 0, |this| {
3493 this.removed(thread.diff_stats.lines_removed as usize)
3494 })
3495 .selected(is_selected)
3496 .focused(is_focused)
3497 .hovered(is_hovered)
3498 .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| {
3499 if *is_hovered {
3500 this.hovered_thread_index = Some(ix);
3501 } else if this.hovered_thread_index == Some(ix) {
3502 this.hovered_thread_index = None;
3503 }
3504 cx.notify();
3505 }))
3506 .when(is_hovered && is_running, |this| {
3507 this.action_slot(
3508 IconButton::new("stop-thread", IconName::Stop)
3509 .icon_size(IconSize::Small)
3510 .icon_color(Color::Error)
3511 .style(ButtonStyle::Tinted(TintColor::Error))
3512 .tooltip(Tooltip::text("Stop Generation"))
3513 .on_click({
3514 let session_id = session_id_for_delete.clone();
3515 cx.listener(move |this, _, _window, cx| {
3516 this.stop_thread(&session_id, cx);
3517 })
3518 }),
3519 )
3520 })
3521 .when(is_hovered && !is_running, |this| {
3522 this.action_slot(
3523 IconButton::new("archive-thread", IconName::Archive)
3524 .icon_size(IconSize::Small)
3525 .icon_color(Color::Muted)
3526 .tooltip({
3527 let focus_handle = focus_handle.clone();
3528 move |_window, cx| {
3529 Tooltip::for_action_in(
3530 "Archive Thread",
3531 &RemoveSelectedThread,
3532 &focus_handle,
3533 cx,
3534 )
3535 }
3536 })
3537 .on_click({
3538 let session_id = session_id_for_delete.clone();
3539 cx.listener(move |this, _, window, cx| {
3540 this.archive_thread(&session_id, window, cx);
3541 })
3542 }),
3543 )
3544 })
3545 .on_click({
3546 cx.listener(move |this, _, window, cx| {
3547 this.selection = None;
3548 match &thread_workspace {
3549 ThreadEntryWorkspace::Open(workspace) => {
3550 this.activate_thread(metadata.clone(), workspace, false, window, cx);
3551 }
3552 ThreadEntryWorkspace::Closed {
3553 folder_paths,
3554 project_group_key,
3555 } => {
3556 this.open_workspace_and_activate_thread(
3557 metadata.clone(),
3558 folder_paths.clone(),
3559 project_group_key,
3560 window,
3561 cx,
3562 );
3563 }
3564 }
3565 })
3566 })
3567 .into_any_element()
3568 }
3569
3570 fn render_filter_input(&self, cx: &mut Context<Self>) -> impl IntoElement {
3571 div()
3572 .min_w_0()
3573 .flex_1()
3574 .capture_action(
3575 cx.listener(|this, _: &editor::actions::Newline, window, cx| {
3576 this.editor_confirm(window, cx);
3577 }),
3578 )
3579 .child(self.filter_editor.clone())
3580 }
3581
3582 fn render_recent_projects_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
3583 let multi_workspace = self.multi_workspace.upgrade();
3584
3585 let workspace = multi_workspace
3586 .as_ref()
3587 .map(|mw| mw.read(cx).workspace().downgrade());
3588
3589 let focus_handle = workspace
3590 .as_ref()
3591 .and_then(|ws| ws.upgrade())
3592 .map(|w| w.read(cx).focus_handle(cx))
3593 .unwrap_or_else(|| cx.focus_handle());
3594
3595 let window_project_groups: Vec<ProjectGroupKey> = multi_workspace
3596 .as_ref()
3597 .map(|mw| mw.read(cx).project_group_keys().cloned().collect())
3598 .unwrap_or_default();
3599
3600 let popover_handle = self.recent_projects_popover_handle.clone();
3601
3602 PopoverMenu::new("sidebar-recent-projects-menu")
3603 .with_handle(popover_handle)
3604 .menu(move |window, cx| {
3605 workspace.as_ref().map(|ws| {
3606 SidebarRecentProjects::popover(
3607 ws.clone(),
3608 window_project_groups.clone(),
3609 focus_handle.clone(),
3610 window,
3611 cx,
3612 )
3613 })
3614 })
3615 .trigger_with_tooltip(
3616 IconButton::new("open-project", IconName::OpenFolder)
3617 .icon_size(IconSize::Small)
3618 .selected_style(ButtonStyle::Tinted(TintColor::Accent)),
3619 |_window, cx| {
3620 Tooltip::for_action(
3621 "Add Project",
3622 &OpenRecent {
3623 create_new_window: false,
3624 },
3625 cx,
3626 )
3627 },
3628 )
3629 .offset(gpui::Point {
3630 x: px(-2.0),
3631 y: px(-2.0),
3632 })
3633 .anchor(gpui::Corner::BottomRight)
3634 }
3635
3636 fn render_view_more(
3637 &self,
3638 ix: usize,
3639 key: &ProjectGroupKey,
3640 is_fully_expanded: bool,
3641 is_selected: bool,
3642 cx: &mut Context<Self>,
3643 ) -> AnyElement {
3644 let key = key.clone();
3645 let id = SharedString::from(format!("view-more-{}", ix));
3646
3647 let label: SharedString = if is_fully_expanded {
3648 "Collapse".into()
3649 } else {
3650 "View More".into()
3651 };
3652
3653 ThreadItem::new(id, label)
3654 .focused(is_selected)
3655 .icon_visible(false)
3656 .title_label_color(Color::Muted)
3657 .on_click(cx.listener(move |this, _, _window, cx| {
3658 this.selection = None;
3659 if is_fully_expanded {
3660 this.reset_thread_group_expansion(&key, cx);
3661 } else {
3662 this.expand_thread_group(&key, cx);
3663 }
3664 }))
3665 .into_any_element()
3666 }
3667
3668 fn new_thread_in_group(
3669 &mut self,
3670 _: &NewThreadInGroup,
3671 window: &mut Window,
3672 cx: &mut Context<Self>,
3673 ) {
3674 // If there is a keyboard selection, walk backwards through
3675 // `project_header_indices` to find the header that owns the selected
3676 // row. Otherwise fall back to the active workspace.
3677 // Always use the currently active workspace so that drafts
3678 // are created in the linked worktree the user is focused on,
3679 // not the main worktree resolved from the project header.
3680 let workspace = self
3681 .multi_workspace
3682 .upgrade()
3683 .map(|mw| mw.read(cx).workspace().clone());
3684
3685 let Some(workspace) = workspace else {
3686 return;
3687 };
3688
3689 self.create_new_thread(&workspace, window, cx);
3690 }
3691
3692 fn create_new_thread(
3693 &mut self,
3694 workspace: &Entity<Workspace>,
3695 window: &mut Window,
3696 cx: &mut Context<Self>,
3697 ) {
3698 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
3699 return;
3700 };
3701
3702 multi_workspace.update(cx, |multi_workspace, cx| {
3703 multi_workspace.activate(workspace.clone(), window, cx);
3704 });
3705
3706 let draft_id = workspace.update(cx, |workspace, cx| {
3707 let panel = workspace.panel::<AgentPanel>(cx)?;
3708 let draft_id = panel.update(cx, |panel, cx| {
3709 let id = panel.create_draft(window, cx);
3710 panel.activate_draft(id, true, window, cx);
3711 id
3712 });
3713 workspace.focus_panel::<AgentPanel>(window, cx);
3714 Some(draft_id)
3715 });
3716
3717 if let Some(draft_id) = draft_id {
3718 self.active_entry = Some(ActiveEntry::Draft {
3719 id: draft_id,
3720 workspace: workspace.clone(),
3721 });
3722 }
3723 }
3724
3725 fn activate_draft(
3726 &mut self,
3727 draft_id: DraftId,
3728 workspace: &Entity<Workspace>,
3729 window: &mut Window,
3730 cx: &mut Context<Self>,
3731 ) {
3732 if let Some(multi_workspace) = self.multi_workspace.upgrade() {
3733 multi_workspace.update(cx, |mw, cx| {
3734 mw.activate(workspace.clone(), window, cx);
3735 });
3736 }
3737
3738 workspace.update(cx, |ws, cx| {
3739 if let Some(panel) = ws.panel::<AgentPanel>(cx) {
3740 panel.update(cx, |panel, cx| {
3741 panel.activate_draft(draft_id, true, window, cx);
3742 });
3743 }
3744 ws.focus_panel::<AgentPanel>(window, cx);
3745 });
3746
3747 self.active_entry = Some(ActiveEntry::Draft {
3748 id: draft_id,
3749 workspace: workspace.clone(),
3750 });
3751
3752 self.observe_draft_editor(cx);
3753 }
3754
3755 fn remove_draft(
3756 &mut self,
3757 draft_id: DraftId,
3758 workspace: &Entity<Workspace>,
3759 window: &mut Window,
3760 cx: &mut Context<Self>,
3761 ) {
3762 workspace.update(cx, |ws, cx| {
3763 if let Some(panel) = ws.panel::<AgentPanel>(cx) {
3764 panel.update(cx, |panel, _cx| {
3765 panel.remove_draft(draft_id);
3766 });
3767 }
3768 });
3769
3770 let was_active = self
3771 .active_entry
3772 .as_ref()
3773 .is_some_and(|e| e.is_active_draft(draft_id));
3774
3775 if was_active {
3776 let mut switched = false;
3777 let group_key = workspace.read(cx).project_group_key(cx);
3778
3779 // Try the next draft below in the sidebar (smaller ID
3780 // since the list is newest-first). Fall back to the one
3781 // above (larger ID) if the deleted draft was last.
3782 if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
3783 let ids = panel.read(cx).draft_ids();
3784 let sibling = ids
3785 .iter()
3786 .find(|id| id.0 < draft_id.0)
3787 .or_else(|| ids.first());
3788 if let Some(&sibling_id) = sibling {
3789 self.activate_draft(sibling_id, workspace, window, cx);
3790 switched = true;
3791 }
3792 }
3793
3794 // No sibling draft — try the first thread in the group.
3795 if !switched {
3796 let first_thread = self.contents.entries.iter().find_map(|entry| {
3797 if let ListEntry::Thread(thread) = entry {
3798 if let ThreadEntryWorkspace::Open(ws) = &thread.workspace {
3799 if ws.read(cx).project_group_key(cx) == group_key {
3800 return Some((thread.metadata.clone(), ws.clone()));
3801 }
3802 }
3803 }
3804 None
3805 });
3806 if let Some((metadata, ws)) = first_thread {
3807 self.activate_thread(metadata, &ws, false, window, cx);
3808 switched = true;
3809 }
3810 }
3811
3812 if !switched {
3813 self.active_entry = None;
3814 }
3815 }
3816
3817 self.update_entries(cx);
3818 }
3819
3820 fn clear_draft(
3821 &mut self,
3822 draft_id: DraftId,
3823 workspace: &Entity<Workspace>,
3824 window: &mut Window,
3825 cx: &mut Context<Self>,
3826 ) {
3827 workspace.update(cx, |ws, cx| {
3828 if let Some(panel) = ws.panel::<AgentPanel>(cx) {
3829 panel.update(cx, |panel, cx| {
3830 panel.clear_draft_editor(draft_id, window, cx);
3831 });
3832 }
3833 });
3834 self.update_entries(cx);
3835 }
3836
3837 /// Cleans, collapses whitespace, and truncates raw editor text
3838 /// for display as a draft label in the sidebar.
3839 fn truncate_draft_label(raw: &str) -> Option<SharedString> {
3840 let cleaned = Self::clean_mention_links(raw);
3841 let mut text: String = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
3842 if text.is_empty() {
3843 return None;
3844 }
3845 const MAX_CHARS: usize = 250;
3846 if let Some((truncate_at, _)) = text.char_indices().nth(MAX_CHARS) {
3847 text.truncate(truncate_at);
3848 }
3849 Some(text.into())
3850 }
3851
3852 /// Reads a draft's prompt text from its ConversationView in the AgentPanel.
3853 fn read_draft_text(
3854 &self,
3855 draft_id: DraftId,
3856 workspace: &Entity<Workspace>,
3857 cx: &App,
3858 ) -> Option<SharedString> {
3859 let panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
3860 let raw = panel.read(cx).draft_editor_text(draft_id, cx)?;
3861 Self::truncate_draft_label(&raw)
3862 }
3863
3864 fn active_project_group_key(&self, cx: &App) -> Option<ProjectGroupKey> {
3865 let multi_workspace = self.multi_workspace.upgrade()?;
3866 let multi_workspace = multi_workspace.read(cx);
3867 Some(multi_workspace.project_group_key_for_workspace(multi_workspace.workspace(), cx))
3868 }
3869
3870 fn active_project_header_position(&self, cx: &App) -> Option<usize> {
3871 let active_key = self.active_project_group_key(cx)?;
3872 self.contents
3873 .project_header_indices
3874 .iter()
3875 .position(|&entry_ix| {
3876 matches!(
3877 &self.contents.entries[entry_ix],
3878 ListEntry::ProjectHeader { key, .. } if *key == active_key
3879 )
3880 })
3881 }
3882
3883 fn cycle_project_impl(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
3884 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
3885 return;
3886 };
3887
3888 let header_count = self.contents.project_header_indices.len();
3889 if header_count == 0 {
3890 return;
3891 }
3892
3893 let current_pos = self.active_project_header_position(cx);
3894
3895 let next_pos = match current_pos {
3896 Some(pos) => {
3897 if forward {
3898 (pos + 1) % header_count
3899 } else {
3900 (pos + header_count - 1) % header_count
3901 }
3902 }
3903 None => 0,
3904 };
3905
3906 let header_entry_ix = self.contents.project_header_indices[next_pos];
3907 let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(header_entry_ix)
3908 else {
3909 return;
3910 };
3911 let key = key.clone();
3912
3913 // Uncollapse the target group so that threads become visible.
3914 self.collapsed_groups.remove(&key);
3915
3916 if let Some(workspace) = self.multi_workspace.upgrade().and_then(|mw| {
3917 mw.read(cx)
3918 .workspace_for_paths(key.path_list(), key.host().as_ref(), cx)
3919 }) {
3920 multi_workspace.update(cx, |multi_workspace, cx| {
3921 multi_workspace.activate(workspace, window, cx);
3922 multi_workspace.retain_active_workspace(cx);
3923 });
3924 } else {
3925 self.open_workspace_for_group(&key, window, cx);
3926 }
3927 }
3928
3929 fn on_next_project(&mut self, _: &NextProject, window: &mut Window, cx: &mut Context<Self>) {
3930 self.cycle_project_impl(true, window, cx);
3931 }
3932
3933 fn on_previous_project(
3934 &mut self,
3935 _: &PreviousProject,
3936 window: &mut Window,
3937 cx: &mut Context<Self>,
3938 ) {
3939 self.cycle_project_impl(false, window, cx);
3940 }
3941
3942 fn cycle_thread_impl(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
3943 let thread_indices: Vec<usize> = self
3944 .contents
3945 .entries
3946 .iter()
3947 .enumerate()
3948 .filter_map(|(ix, entry)| match entry {
3949 ListEntry::Thread(_) => Some(ix),
3950 _ => None,
3951 })
3952 .collect();
3953
3954 if thread_indices.is_empty() {
3955 return;
3956 }
3957
3958 let current_thread_pos = self.active_entry.as_ref().and_then(|active| {
3959 thread_indices
3960 .iter()
3961 .position(|&ix| active.matches_entry(&self.contents.entries[ix]))
3962 });
3963
3964 let next_pos = match current_thread_pos {
3965 Some(pos) => {
3966 let count = thread_indices.len();
3967 if forward {
3968 (pos + 1) % count
3969 } else {
3970 (pos + count - 1) % count
3971 }
3972 }
3973 None => 0,
3974 };
3975
3976 let entry_ix = thread_indices[next_pos];
3977 let ListEntry::Thread(thread) = &self.contents.entries[entry_ix] else {
3978 return;
3979 };
3980
3981 let metadata = thread.metadata.clone();
3982 match &thread.workspace {
3983 ThreadEntryWorkspace::Open(workspace) => {
3984 let workspace = workspace.clone();
3985 self.activate_thread(metadata, &workspace, true, window, cx);
3986 }
3987 ThreadEntryWorkspace::Closed {
3988 folder_paths,
3989 project_group_key,
3990 } => {
3991 let folder_paths = folder_paths.clone();
3992 let project_group_key = project_group_key.clone();
3993 self.open_workspace_and_activate_thread(
3994 metadata,
3995 folder_paths,
3996 &project_group_key,
3997 window,
3998 cx,
3999 );
4000 }
4001 }
4002 }
4003
4004 fn on_next_thread(&mut self, _: &NextThread, window: &mut Window, cx: &mut Context<Self>) {
4005 self.cycle_thread_impl(true, window, cx);
4006 }
4007
4008 fn on_previous_thread(
4009 &mut self,
4010 _: &PreviousThread,
4011 window: &mut Window,
4012 cx: &mut Context<Self>,
4013 ) {
4014 self.cycle_thread_impl(false, window, cx);
4015 }
4016
4017 fn expand_thread_group(&mut self, project_group_key: &ProjectGroupKey, cx: &mut Context<Self>) {
4018 let current = self
4019 .expanded_groups
4020 .get(project_group_key)
4021 .copied()
4022 .unwrap_or(0);
4023 self.expanded_groups
4024 .insert(project_group_key.clone(), current + 1);
4025 self.serialize(cx);
4026 self.update_entries(cx);
4027 }
4028
4029 fn reset_thread_group_expansion(
4030 &mut self,
4031 project_group_key: &ProjectGroupKey,
4032 cx: &mut Context<Self>,
4033 ) {
4034 self.expanded_groups.remove(project_group_key);
4035 self.serialize(cx);
4036 self.update_entries(cx);
4037 }
4038
4039 fn collapse_thread_group(
4040 &mut self,
4041 project_group_key: &ProjectGroupKey,
4042 cx: &mut Context<Self>,
4043 ) {
4044 match self.expanded_groups.get(project_group_key).copied() {
4045 Some(batches) if batches > 1 => {
4046 self.expanded_groups
4047 .insert(project_group_key.clone(), batches - 1);
4048 }
4049 Some(_) => {
4050 self.expanded_groups.remove(project_group_key);
4051 }
4052 None => return,
4053 }
4054 self.serialize(cx);
4055 self.update_entries(cx);
4056 }
4057
4058 fn on_show_more_threads(
4059 &mut self,
4060 _: &ShowMoreThreads,
4061 _window: &mut Window,
4062 cx: &mut Context<Self>,
4063 ) {
4064 let Some(active_key) = self.active_project_group_key(cx) else {
4065 return;
4066 };
4067 self.expand_thread_group(&active_key, cx);
4068 }
4069
4070 fn on_show_fewer_threads(
4071 &mut self,
4072 _: &ShowFewerThreads,
4073 _window: &mut Window,
4074 cx: &mut Context<Self>,
4075 ) {
4076 let Some(active_key) = self.active_project_group_key(cx) else {
4077 return;
4078 };
4079 self.collapse_thread_group(&active_key, cx);
4080 }
4081
4082 fn on_new_thread(
4083 &mut self,
4084 _: &workspace::NewThread,
4085 window: &mut Window,
4086 cx: &mut Context<Self>,
4087 ) {
4088 let Some(workspace) = self.active_workspace(cx) else {
4089 return;
4090 };
4091 self.create_new_thread(&workspace, window, cx);
4092 }
4093
4094 fn render_draft_thread(
4095 &self,
4096 ix: usize,
4097 draft_id: Option<DraftId>,
4098 key: &ProjectGroupKey,
4099 workspace: Option<&Entity<Workspace>>,
4100 is_active: bool,
4101 worktrees: &[WorktreeInfo],
4102 is_selected: bool,
4103 can_dismiss: bool,
4104 cx: &mut Context<Self>,
4105 ) -> AnyElement {
4106 let label: SharedString = draft_id
4107 .and_then(|id| workspace.and_then(|ws| self.read_draft_text(id, ws, cx)))
4108 .unwrap_or_else(|| "New Agent Thread".into());
4109
4110 let id = SharedString::from(format!("draft-thread-btn-{}", ix));
4111
4112 let worktrees = worktrees
4113 .iter()
4114 .map(|worktree| ThreadItemWorktreeInfo {
4115 name: worktree.name.clone(),
4116 full_path: worktree.full_path.clone(),
4117 highlight_positions: worktree.highlight_positions.clone(),
4118 kind: worktree.kind,
4119 })
4120 .collect();
4121
4122 let is_hovered = self.hovered_thread_index == Some(ix);
4123
4124 let key = key.clone();
4125 let workspace_for_click = workspace.cloned();
4126 let workspace_for_remove = workspace.cloned();
4127 let workspace_for_clear = workspace.cloned();
4128
4129 ThreadItem::new(id, label)
4130 .icon(IconName::Pencil)
4131 .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.4)))
4132 .worktrees(worktrees)
4133 .selected(is_active)
4134 .focused(is_selected)
4135 .hovered(is_hovered)
4136 .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| {
4137 if *is_hovered {
4138 this.hovered_thread_index = Some(ix);
4139 } else if this.hovered_thread_index == Some(ix) {
4140 this.hovered_thread_index = None;
4141 }
4142 cx.notify();
4143 }))
4144 .on_click(cx.listener(move |this, _, window, cx| {
4145 if let Some(draft_id) = draft_id {
4146 if let Some(workspace) = &workspace_for_click {
4147 this.activate_draft(draft_id, workspace, window, cx);
4148 }
4149 } else if let Some(workspace) = &workspace_for_click {
4150 // Placeholder with an open workspace — just
4151 // activate it. The panel remembers its last view.
4152 this.activate_workspace(workspace, window, cx);
4153 workspace.update(cx, |ws, cx| {
4154 ws.focus_panel::<AgentPanel>(window, cx);
4155 });
4156 } else {
4157 // No workspace at all — just open one. The
4158 // panel's load fallback will create a draft.
4159 this.open_workspace_for_group(&key, window, cx);
4160 }
4161 }))
4162 .when_some(draft_id.filter(|_| can_dismiss), |this, draft_id| {
4163 this.action_slot(
4164 div()
4165 .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
4166 cx.stop_propagation();
4167 })
4168 .child(
4169 IconButton::new(
4170 SharedString::from(format!("close-draft-{}", ix)),
4171 IconName::Close,
4172 )
4173 .icon_size(IconSize::Small)
4174 .icon_color(Color::Muted)
4175 .tooltip(Tooltip::text("Remove Draft"))
4176 .on_click(cx.listener(
4177 move |this, _, window, cx| {
4178 if let Some(workspace) = &workspace_for_remove {
4179 this.remove_draft(draft_id, workspace, window, cx);
4180 }
4181 },
4182 )),
4183 ),
4184 )
4185 })
4186 .when_some(draft_id.filter(|_| !can_dismiss), |this, draft_id| {
4187 this.action_slot(
4188 div()
4189 .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
4190 cx.stop_propagation();
4191 })
4192 .child(
4193 IconButton::new(
4194 SharedString::from(format!("clear-draft-{}", ix)),
4195 IconName::Close,
4196 )
4197 .icon_size(IconSize::Small)
4198 .icon_color(Color::Muted)
4199 .tooltip(Tooltip::text("Clear Draft"))
4200 .on_click(cx.listener(
4201 move |this, _, window, cx| {
4202 if let Some(workspace) = &workspace_for_clear {
4203 this.clear_draft(draft_id, workspace, window, cx);
4204 }
4205 },
4206 )),
4207 ),
4208 )
4209 })
4210 .into_any_element()
4211 }
4212
4213 fn render_no_results(&self, cx: &mut Context<Self>) -> impl IntoElement {
4214 let has_query = self.has_filter_query(cx);
4215 let message = if has_query {
4216 "No threads match your search."
4217 } else {
4218 "No threads yet"
4219 };
4220
4221 v_flex()
4222 .id("sidebar-no-results")
4223 .p_4()
4224 .size_full()
4225 .items_center()
4226 .justify_center()
4227 .child(
4228 Label::new(message)
4229 .size(LabelSize::Small)
4230 .color(Color::Muted),
4231 )
4232 }
4233
4234 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
4235 v_flex()
4236 .id("sidebar-empty-state")
4237 .p_4()
4238 .size_full()
4239 .items_center()
4240 .justify_center()
4241 .gap_1()
4242 .track_focus(&self.focus_handle(cx))
4243 .child(
4244 Button::new("open_project", "Open Project")
4245 .full_width()
4246 .key_binding(KeyBinding::for_action(&workspace::Open::default(), cx))
4247 .on_click(|_, window, cx| {
4248 window.dispatch_action(
4249 Open {
4250 create_new_window: false,
4251 }
4252 .boxed_clone(),
4253 cx,
4254 );
4255 }),
4256 )
4257 .child(
4258 h_flex()
4259 .w_1_2()
4260 .gap_2()
4261 .child(Divider::horizontal().color(ui::DividerColor::Border))
4262 .child(Label::new("or").size(LabelSize::XSmall).color(Color::Muted))
4263 .child(Divider::horizontal().color(ui::DividerColor::Border)),
4264 )
4265 .child(
4266 Button::new("clone_repo", "Clone Repository")
4267 .full_width()
4268 .on_click(|_, window, cx| {
4269 window.dispatch_action(git::Clone.boxed_clone(), cx);
4270 }),
4271 )
4272 }
4273
4274 fn render_sidebar_header(
4275 &self,
4276 no_open_projects: bool,
4277 window: &Window,
4278 cx: &mut Context<Self>,
4279 ) -> impl IntoElement {
4280 let has_query = self.has_filter_query(cx);
4281 let sidebar_on_left = self.side(cx) == SidebarSide::Left;
4282 let sidebar_on_right = self.side(cx) == SidebarSide::Right;
4283 let not_fullscreen = !window.is_fullscreen();
4284 let traffic_lights = cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left;
4285 let left_window_controls = !cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left;
4286 let right_window_controls =
4287 !cfg!(target_os = "macos") && not_fullscreen && sidebar_on_right;
4288 let header_height = platform_title_bar_height(window);
4289
4290 h_flex()
4291 .h(header_height)
4292 .mt_px()
4293 .pb_px()
4294 .when(left_window_controls, |this| {
4295 this.children(Self::render_left_window_controls(window, cx))
4296 })
4297 .map(|this| {
4298 if traffic_lights {
4299 this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
4300 } else if !left_window_controls {
4301 this.pl_1p5()
4302 } else {
4303 this
4304 }
4305 })
4306 .when(!right_window_controls, |this| this.pr_1p5())
4307 .gap_1()
4308 .when(!no_open_projects, |this| {
4309 this.border_b_1()
4310 .border_color(cx.theme().colors().border)
4311 .when(traffic_lights, |this| {
4312 this.child(Divider::vertical().color(ui::DividerColor::Border))
4313 })
4314 .child(
4315 div().ml_1().child(
4316 Icon::new(IconName::MagnifyingGlass)
4317 .size(IconSize::Small)
4318 .color(Color::Muted),
4319 ),
4320 )
4321 .child(self.render_filter_input(cx))
4322 .child(
4323 h_flex()
4324 .gap_1()
4325 .when(
4326 self.selection.is_some()
4327 && !self.filter_editor.focus_handle(cx).is_focused(window),
4328 |this| this.child(KeyBinding::for_action(&FocusSidebarFilter, cx)),
4329 )
4330 .when(has_query, |this| {
4331 this.child(
4332 IconButton::new("clear_filter", IconName::Close)
4333 .icon_size(IconSize::Small)
4334 .tooltip(Tooltip::text("Clear Search"))
4335 .on_click(cx.listener(|this, _, window, cx| {
4336 this.reset_filter_editor_text(window, cx);
4337 this.update_entries(cx);
4338 })),
4339 )
4340 }),
4341 )
4342 })
4343 .when(right_window_controls, |this| {
4344 this.children(Self::render_right_window_controls(window, cx))
4345 })
4346 }
4347
4348 fn render_left_window_controls(window: &Window, cx: &mut App) -> Option<AnyElement> {
4349 platform_title_bar::render_left_window_controls(
4350 cx.button_layout(),
4351 Box::new(CloseWindow),
4352 window,
4353 )
4354 }
4355
4356 fn render_right_window_controls(window: &Window, cx: &mut App) -> Option<AnyElement> {
4357 platform_title_bar::render_right_window_controls(
4358 cx.button_layout(),
4359 Box::new(CloseWindow),
4360 window,
4361 )
4362 }
4363
4364 fn render_sidebar_toggle_button(&self, _cx: &mut Context<Self>) -> impl IntoElement {
4365 let on_right = AgentSettings::get_global(_cx).sidebar_side() == SidebarSide::Right;
4366
4367 sidebar_side_context_menu("sidebar-toggle-menu", _cx)
4368 .anchor(if on_right {
4369 gpui::Corner::BottomRight
4370 } else {
4371 gpui::Corner::BottomLeft
4372 })
4373 .attach(if on_right {
4374 gpui::Corner::TopRight
4375 } else {
4376 gpui::Corner::TopLeft
4377 })
4378 .trigger(move |_is_active, _window, _cx| {
4379 let icon = if on_right {
4380 IconName::ThreadsSidebarRightOpen
4381 } else {
4382 IconName::ThreadsSidebarLeftOpen
4383 };
4384 IconButton::new("sidebar-close-toggle", icon)
4385 .icon_size(IconSize::Small)
4386 .tooltip(Tooltip::element(move |_window, cx| {
4387 v_flex()
4388 .gap_1()
4389 .child(
4390 h_flex()
4391 .gap_2()
4392 .justify_between()
4393 .child(Label::new("Toggle Sidebar"))
4394 .child(KeyBinding::for_action(&ToggleWorkspaceSidebar, cx)),
4395 )
4396 .child(
4397 h_flex()
4398 .pt_1()
4399 .gap_2()
4400 .border_t_1()
4401 .border_color(cx.theme().colors().border_variant)
4402 .justify_between()
4403 .child(Label::new("Focus Sidebar"))
4404 .child(KeyBinding::for_action(&FocusWorkspaceSidebar, cx)),
4405 )
4406 .into_any_element()
4407 }))
4408 .on_click(|_, window, cx| {
4409 if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
4410 multi_workspace.update(cx, |multi_workspace, cx| {
4411 multi_workspace.close_sidebar(window, cx);
4412 });
4413 }
4414 })
4415 })
4416 }
4417
4418 fn render_sidebar_bottom_bar(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
4419 let is_archive = matches!(self.view, SidebarView::Archive(..));
4420 let show_import_button = is_archive && !self.should_render_acp_import_onboarding(cx);
4421 let on_right = self.side(cx) == SidebarSide::Right;
4422
4423 let action_buttons = h_flex()
4424 .gap_1()
4425 .when(on_right, |this| this.flex_row_reverse())
4426 .when(show_import_button, |this| {
4427 this.child(
4428 IconButton::new("thread-import", IconName::ThreadImport)
4429 .icon_size(IconSize::Small)
4430 .tooltip(Tooltip::text("Import ACP Threads"))
4431 .on_click(cx.listener(|this, _, window, cx| {
4432 this.show_archive(window, cx);
4433 this.show_thread_import_modal(window, cx);
4434 })),
4435 )
4436 })
4437 .child(
4438 IconButton::new("archive", IconName::Archive)
4439 .icon_size(IconSize::Small)
4440 .toggle_state(is_archive)
4441 .tooltip(move |_, cx| {
4442 Tooltip::for_action("Toggle Archived Threads", &ToggleArchive, cx)
4443 })
4444 .on_click(cx.listener(|this, _, window, cx| {
4445 this.toggle_archive(&ToggleArchive, window, cx);
4446 })),
4447 )
4448 .child(self.render_recent_projects_button(cx));
4449
4450 h_flex()
4451 .p_1()
4452 .gap_1()
4453 .when(on_right, |this| this.flex_row_reverse())
4454 .justify_between()
4455 .border_t_1()
4456 .border_color(cx.theme().colors().border)
4457 .child(self.render_sidebar_toggle_button(cx))
4458 .child(action_buttons)
4459 }
4460
4461 fn active_workspace(&self, cx: &App) -> Option<Entity<Workspace>> {
4462 self.multi_workspace
4463 .upgrade()
4464 .map(|w| w.read(cx).workspace().clone())
4465 }
4466
4467 fn show_thread_import_modal(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4468 let Some(active_workspace) = self.active_workspace(cx) else {
4469 return;
4470 };
4471
4472 let Some(agent_registry_store) = AgentRegistryStore::try_global(cx) else {
4473 return;
4474 };
4475
4476 let agent_server_store = active_workspace
4477 .read(cx)
4478 .project()
4479 .read(cx)
4480 .agent_server_store()
4481 .clone();
4482
4483 let workspace_handle = active_workspace.downgrade();
4484 let multi_workspace = self.multi_workspace.clone();
4485
4486 active_workspace.update(cx, |workspace, cx| {
4487 workspace.toggle_modal(window, cx, |window, cx| {
4488 ThreadImportModal::new(
4489 agent_server_store,
4490 agent_registry_store,
4491 workspace_handle.clone(),
4492 multi_workspace.clone(),
4493 window,
4494 cx,
4495 )
4496 });
4497 });
4498 }
4499
4500 fn should_render_acp_import_onboarding(&self, cx: &App) -> bool {
4501 let has_external_agents = self
4502 .active_workspace(cx)
4503 .map(|ws| {
4504 ws.read(cx)
4505 .project()
4506 .read(cx)
4507 .agent_server_store()
4508 .read(cx)
4509 .has_external_agents()
4510 })
4511 .unwrap_or(false);
4512
4513 has_external_agents && !AcpThreadImportOnboarding::dismissed(cx)
4514 }
4515
4516 fn render_acp_import_onboarding(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
4517 let description = "Import threads from agents like Claude Agent, Codex, and more, whether started in Zed or another client.";
4518
4519 let bg = cx.theme().colors().text_accent;
4520
4521 v_flex()
4522 .min_w_0()
4523 .w_full()
4524 .p_2()
4525 .border_t_1()
4526 .border_color(cx.theme().colors().border)
4527 .bg(linear_gradient(
4528 360.,
4529 linear_color_stop(bg.opacity(0.06), 1.),
4530 linear_color_stop(bg.opacity(0.), 0.),
4531 ))
4532 .child(
4533 h_flex()
4534 .min_w_0()
4535 .w_full()
4536 .gap_1()
4537 .justify_between()
4538 .child(Label::new("Looking for threads from external agents?"))
4539 .child(
4540 IconButton::new("close-onboarding", IconName::Close)
4541 .icon_size(IconSize::Small)
4542 .on_click(|_, _window, cx| AcpThreadImportOnboarding::dismiss(cx)),
4543 ),
4544 )
4545 .child(Label::new(description).color(Color::Muted).mb_2())
4546 .child(
4547 Button::new("import-acp", "Import Threads")
4548 .full_width()
4549 .style(ButtonStyle::OutlinedCustom(cx.theme().colors().border))
4550 .label_size(LabelSize::Small)
4551 .start_icon(
4552 Icon::new(IconName::ThreadImport)
4553 .size(IconSize::Small)
4554 .color(Color::Muted),
4555 )
4556 .on_click(cx.listener(|this, _, window, cx| {
4557 this.show_archive(window, cx);
4558 this.show_thread_import_modal(window, cx);
4559 })),
4560 )
4561 }
4562
4563 fn toggle_archive(&mut self, _: &ToggleArchive, window: &mut Window, cx: &mut Context<Self>) {
4564 match &self.view {
4565 SidebarView::ThreadList => self.show_archive(window, cx),
4566 SidebarView::Archive(_) => self.show_thread_list(window, cx),
4567 }
4568 }
4569
4570 fn show_archive(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4571 let Some(active_workspace) = self
4572 .multi_workspace
4573 .upgrade()
4574 .map(|w| w.read(cx).workspace().clone())
4575 else {
4576 return;
4577 };
4578 let Some(agent_panel) = active_workspace.read(cx).panel::<AgentPanel>(cx) else {
4579 return;
4580 };
4581
4582 let agent_server_store = active_workspace
4583 .read(cx)
4584 .project()
4585 .read(cx)
4586 .agent_server_store()
4587 .downgrade();
4588
4589 let agent_connection_store = agent_panel.read(cx).connection_store().downgrade();
4590
4591 let archive_view = cx.new(|cx| {
4592 ThreadsArchiveView::new(
4593 active_workspace.downgrade(),
4594 agent_connection_store.clone(),
4595 agent_server_store.clone(),
4596 window,
4597 cx,
4598 )
4599 });
4600
4601 let subscription = cx.subscribe_in(
4602 &archive_view,
4603 window,
4604 |this, _, event: &ThreadsArchiveViewEvent, window, cx| match event {
4605 ThreadsArchiveViewEvent::Close => {
4606 this.show_thread_list(window, cx);
4607 }
4608 ThreadsArchiveViewEvent::Unarchive { thread } => {
4609 this.activate_archived_thread(thread.clone(), window, cx);
4610 }
4611 ThreadsArchiveViewEvent::CancelRestore { session_id } => {
4612 this.restoring_tasks.remove(session_id);
4613 }
4614 },
4615 );
4616
4617 self._subscriptions.push(subscription);
4618 self.view = SidebarView::Archive(archive_view.clone());
4619 archive_view.update(cx, |view, cx| view.focus_filter_editor(window, cx));
4620 self.serialize(cx);
4621 cx.notify();
4622 }
4623
4624 fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4625 self.view = SidebarView::ThreadList;
4626 self._subscriptions.clear();
4627 let handle = self.filter_editor.read(cx).focus_handle(cx);
4628 handle.focus(window, cx);
4629 self.serialize(cx);
4630 cx.notify();
4631 }
4632}
4633
4634impl WorkspaceSidebar for Sidebar {
4635 fn width(&self, _cx: &App) -> Pixels {
4636 self.width
4637 }
4638
4639 fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
4640 self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
4641 cx.notify();
4642 }
4643
4644 fn has_notifications(&self, _cx: &App) -> bool {
4645 !self.contents.notified_threads.is_empty()
4646 }
4647
4648 fn is_threads_list_view_active(&self) -> bool {
4649 matches!(self.view, SidebarView::ThreadList)
4650 }
4651
4652 fn side(&self, cx: &App) -> SidebarSide {
4653 AgentSettings::get_global(cx).sidebar_side()
4654 }
4655
4656 fn prepare_for_focus(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
4657 self.selection = None;
4658 cx.notify();
4659 }
4660
4661 fn toggle_thread_switcher(
4662 &mut self,
4663 select_last: bool,
4664 window: &mut Window,
4665 cx: &mut Context<Self>,
4666 ) {
4667 self.toggle_thread_switcher_impl(select_last, window, cx);
4668 }
4669
4670 fn cycle_project(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
4671 self.cycle_project_impl(forward, window, cx);
4672 }
4673
4674 fn cycle_thread(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
4675 self.cycle_thread_impl(forward, window, cx);
4676 }
4677
4678 fn serialized_state(&self, _cx: &App) -> Option<String> {
4679 let serialized = SerializedSidebar {
4680 width: Some(f32::from(self.width)),
4681 collapsed_groups: self
4682 .collapsed_groups
4683 .iter()
4684 .cloned()
4685 .map(SerializedProjectGroupKey::from)
4686 .collect(),
4687 expanded_groups: self
4688 .expanded_groups
4689 .iter()
4690 .map(|(key, count)| (SerializedProjectGroupKey::from(key.clone()), *count))
4691 .collect(),
4692 active_view: match self.view {
4693 SidebarView::ThreadList => SerializedSidebarView::ThreadList,
4694 SidebarView::Archive(_) => SerializedSidebarView::Archive,
4695 },
4696 };
4697 serde_json::to_string(&serialized).ok()
4698 }
4699
4700 fn restore_serialized_state(
4701 &mut self,
4702 state: &str,
4703 window: &mut Window,
4704 cx: &mut Context<Self>,
4705 ) {
4706 if let Some(serialized) = serde_json::from_str::<SerializedSidebar>(state).log_err() {
4707 if let Some(width) = serialized.width {
4708 self.width = px(width).clamp(MIN_WIDTH, MAX_WIDTH);
4709 }
4710 self.collapsed_groups = serialized
4711 .collapsed_groups
4712 .into_iter()
4713 .map(ProjectGroupKey::from)
4714 .collect();
4715 self.expanded_groups = serialized
4716 .expanded_groups
4717 .into_iter()
4718 .map(|(s, count)| (ProjectGroupKey::from(s), count))
4719 .collect();
4720 if serialized.active_view == SerializedSidebarView::Archive {
4721 cx.defer_in(window, |this, window, cx| {
4722 this.show_archive(window, cx);
4723 });
4724 }
4725 }
4726 cx.notify();
4727 }
4728}
4729
4730impl gpui::EventEmitter<workspace::SidebarEvent> for Sidebar {}
4731
4732impl Focusable for Sidebar {
4733 fn focus_handle(&self, _cx: &App) -> FocusHandle {
4734 self.focus_handle.clone()
4735 }
4736}
4737
4738impl Render for Sidebar {
4739 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4740 let _titlebar_height = ui::utils::platform_title_bar_height(window);
4741 let ui_font = theme_settings::setup_ui_font(window, cx);
4742 let sticky_header = self.render_sticky_header(window, cx);
4743
4744 let color = cx.theme().colors();
4745 let bg = color
4746 .title_bar_background
4747 .blend(color.panel_background.opacity(0.25));
4748
4749 let no_open_projects = !self.contents.has_open_projects;
4750 let no_search_results = self.contents.entries.is_empty();
4751
4752 v_flex()
4753 .id("workspace-sidebar")
4754 .key_context(self.dispatch_context(window, cx))
4755 .track_focus(&self.focus_handle)
4756 .on_action(cx.listener(Self::select_next))
4757 .on_action(cx.listener(Self::select_previous))
4758 .on_action(cx.listener(Self::editor_move_down))
4759 .on_action(cx.listener(Self::editor_move_up))
4760 .on_action(cx.listener(Self::select_first))
4761 .on_action(cx.listener(Self::select_last))
4762 .on_action(cx.listener(Self::confirm))
4763 .on_action(cx.listener(Self::expand_selected_entry))
4764 .on_action(cx.listener(Self::collapse_selected_entry))
4765 .on_action(cx.listener(Self::toggle_selected_fold))
4766 .on_action(cx.listener(Self::fold_all))
4767 .on_action(cx.listener(Self::unfold_all))
4768 .on_action(cx.listener(Self::cancel))
4769 .on_action(cx.listener(Self::remove_selected_thread))
4770 .on_action(cx.listener(Self::new_thread_in_group))
4771 .on_action(cx.listener(Self::toggle_archive))
4772 .on_action(cx.listener(Self::focus_sidebar_filter))
4773 .on_action(cx.listener(Self::on_toggle_thread_switcher))
4774 .on_action(cx.listener(Self::on_next_project))
4775 .on_action(cx.listener(Self::on_previous_project))
4776 .on_action(cx.listener(Self::on_next_thread))
4777 .on_action(cx.listener(Self::on_previous_thread))
4778 .on_action(cx.listener(Self::on_show_more_threads))
4779 .on_action(cx.listener(Self::on_show_fewer_threads))
4780 .on_action(cx.listener(Self::on_new_thread))
4781 .on_action(cx.listener(|this, _: &OpenRecent, window, cx| {
4782 this.recent_projects_popover_handle.toggle(window, cx);
4783 }))
4784 .font(ui_font)
4785 .h_full()
4786 .w(self.width)
4787 .bg(bg)
4788 .when(self.side(cx) == SidebarSide::Left, |el| el.border_r_1())
4789 .when(self.side(cx) == SidebarSide::Right, |el| el.border_l_1())
4790 .border_color(color.border)
4791 .map(|this| match &self.view {
4792 SidebarView::ThreadList => this
4793 .child(self.render_sidebar_header(no_open_projects, window, cx))
4794 .map(|this| {
4795 if no_open_projects {
4796 this.child(self.render_empty_state(cx))
4797 } else {
4798 this.child(
4799 v_flex()
4800 .relative()
4801 .flex_1()
4802 .overflow_hidden()
4803 .child(
4804 list(
4805 self.list_state.clone(),
4806 cx.processor(Self::render_list_entry),
4807 )
4808 .flex_1()
4809 .size_full(),
4810 )
4811 .when(no_search_results, |this| {
4812 this.child(self.render_no_results(cx))
4813 })
4814 .when_some(sticky_header, |this, header| this.child(header))
4815 .vertical_scrollbar_for(&self.list_state, window, cx),
4816 )
4817 }
4818 }),
4819 SidebarView::Archive(archive_view) => this.child(archive_view.clone()),
4820 })
4821 .when(self.should_render_acp_import_onboarding(cx), |this| {
4822 this.child(self.render_acp_import_onboarding(cx))
4823 })
4824 .child(self.render_sidebar_bottom_bar(cx))
4825 }
4826}
4827
4828fn all_thread_infos_for_workspace(
4829 workspace: &Entity<Workspace>,
4830 cx: &App,
4831) -> impl Iterator<Item = ActiveThreadInfo> {
4832 let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
4833 return None.into_iter().flatten();
4834 };
4835 let agent_panel = agent_panel.read(cx);
4836 let threads = agent_panel
4837 .conversation_views()
4838 .into_iter()
4839 .filter_map(|conversation_view| {
4840 let has_pending_tool_call = conversation_view
4841 .read(cx)
4842 .root_thread_has_pending_tool_call(cx);
4843 let thread_view = conversation_view.read(cx).root_thread(cx)?;
4844 let thread_view_ref = thread_view.read(cx);
4845 let thread = thread_view_ref.thread.read(cx);
4846
4847 let icon = thread_view_ref.agent_icon;
4848 let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone();
4849 let title = thread
4850 .title()
4851 .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into());
4852 let is_native = thread_view_ref.as_native_thread(cx).is_some();
4853 let is_title_generating = is_native && thread.has_provisional_title();
4854 let session_id = thread.session_id().clone();
4855 let is_background = agent_panel.is_background_thread(&session_id);
4856
4857 let status = if has_pending_tool_call {
4858 AgentThreadStatus::WaitingForConfirmation
4859 } else if thread.had_error() {
4860 AgentThreadStatus::Error
4861 } else {
4862 match thread.status() {
4863 ThreadStatus::Generating => AgentThreadStatus::Running,
4864 ThreadStatus::Idle => AgentThreadStatus::Completed,
4865 }
4866 };
4867
4868 let diff_stats = thread.action_log().read(cx).diff_stats(cx);
4869
4870 Some(ActiveThreadInfo {
4871 session_id,
4872 title,
4873 status,
4874 icon,
4875 icon_from_external_svg,
4876 is_background,
4877 is_title_generating,
4878 diff_stats,
4879 })
4880 });
4881
4882 Some(threads).into_iter().flatten()
4883}
4884
4885pub fn dump_workspace_info(
4886 workspace: &mut Workspace,
4887 _: &DumpWorkspaceInfo,
4888 window: &mut gpui::Window,
4889 cx: &mut gpui::Context<Workspace>,
4890) {
4891 use std::fmt::Write;
4892
4893 let mut output = String::new();
4894 let this_entity = cx.entity();
4895
4896 let multi_workspace = workspace.multi_workspace().and_then(|weak| weak.upgrade());
4897 let workspaces: Vec<gpui::Entity<Workspace>> = match &multi_workspace {
4898 Some(mw) => mw.read(cx).workspaces().cloned().collect(),
4899 None => vec![this_entity.clone()],
4900 };
4901 let active_workspace = multi_workspace
4902 .as_ref()
4903 .map(|mw| mw.read(cx).workspace().clone());
4904
4905 writeln!(output, "MultiWorkspace: {} workspace(s)", workspaces.len()).ok();
4906
4907 if let Some(mw) = &multi_workspace {
4908 let keys: Vec<_> = mw.read(cx).project_group_keys().cloned().collect();
4909 writeln!(output, "Project group keys ({}):", keys.len()).ok();
4910 for key in keys {
4911 writeln!(output, " - {key:?}").ok();
4912 }
4913 }
4914
4915 writeln!(output).ok();
4916
4917 for (index, ws) in workspaces.iter().enumerate() {
4918 let is_active = active_workspace.as_ref() == Some(ws);
4919 writeln!(
4920 output,
4921 "--- Workspace {index}{} ---",
4922 if is_active { " (active)" } else { "" }
4923 )
4924 .ok();
4925
4926 // project_group_key_for_workspace internally reads the workspace,
4927 // so we can only call it for workspaces other than this_entity
4928 // (which is already being updated).
4929 if let Some(mw) = &multi_workspace {
4930 if *ws == this_entity {
4931 let workspace_key = workspace.project_group_key(cx);
4932 writeln!(output, "ProjectGroupKey: {workspace_key:?}").ok();
4933 } else {
4934 let effective_key = mw.read(cx).project_group_key_for_workspace(ws, cx);
4935 let workspace_key = ws.read(cx).project_group_key(cx);
4936 if effective_key != workspace_key {
4937 writeln!(
4938 output,
4939 "ProjectGroupKey (multi_workspace): {effective_key:?}"
4940 )
4941 .ok();
4942 writeln!(
4943 output,
4944 "ProjectGroupKey (workspace, DISAGREES): {workspace_key:?}"
4945 )
4946 .ok();
4947 } else {
4948 writeln!(output, "ProjectGroupKey: {effective_key:?}").ok();
4949 }
4950 }
4951 } else {
4952 let workspace_key = workspace.project_group_key(cx);
4953 writeln!(output, "ProjectGroupKey: {workspace_key:?}").ok();
4954 }
4955
4956 // The action handler is already inside an update on `this_entity`,
4957 // so we must avoid a nested read/update on that same entity.
4958 if *ws == this_entity {
4959 dump_single_workspace(workspace, &mut output, cx);
4960 } else {
4961 ws.read_with(cx, |ws, cx| {
4962 dump_single_workspace(ws, &mut output, cx);
4963 });
4964 }
4965 }
4966
4967 let project = workspace.project().clone();
4968 cx.spawn_in(window, async move |_this, cx| {
4969 let buffer = project
4970 .update(cx, |project, cx| project.create_buffer(None, false, cx))
4971 .await?;
4972
4973 buffer.update(cx, |buffer, cx| {
4974 buffer.set_text(output, cx);
4975 });
4976
4977 let buffer = cx.new(|cx| {
4978 editor::MultiBuffer::singleton(buffer, cx).with_title("Workspace Info".into())
4979 });
4980
4981 _this.update_in(cx, |workspace, window, cx| {
4982 workspace.add_item_to_active_pane(
4983 Box::new(cx.new(|cx| {
4984 let mut editor =
4985 editor::Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
4986 editor.set_read_only(true);
4987 editor.set_should_serialize(false, cx);
4988 editor.set_breadcrumb_header("Workspace Info".into());
4989 editor
4990 })),
4991 None,
4992 true,
4993 window,
4994 cx,
4995 );
4996 })
4997 })
4998 .detach_and_log_err(cx);
4999}
5000
5001fn dump_single_workspace(workspace: &Workspace, output: &mut String, cx: &gpui::App) {
5002 use std::fmt::Write;
5003
5004 let workspace_db_id = workspace.database_id();
5005 match workspace_db_id {
5006 Some(id) => writeln!(output, "Workspace DB ID: {id:?}").ok(),
5007 None => writeln!(output, "Workspace DB ID: (none)").ok(),
5008 };
5009
5010 let project = workspace.project().read(cx);
5011
5012 let repos: Vec<_> = project
5013 .repositories(cx)
5014 .values()
5015 .map(|repo| repo.read(cx).snapshot())
5016 .collect();
5017
5018 writeln!(output, "Worktrees:").ok();
5019 for worktree in project.worktrees(cx) {
5020 let worktree = worktree.read(cx);
5021 let abs_path = worktree.abs_path();
5022 let visible = worktree.is_visible();
5023
5024 let repo_info = repos
5025 .iter()
5026 .find(|snapshot| abs_path.starts_with(&*snapshot.work_directory_abs_path));
5027
5028 let is_linked = repo_info.map(|s| s.is_linked_worktree()).unwrap_or(false);
5029 let original_repo_path = repo_info.map(|s| &s.original_repo_abs_path);
5030 let branch = repo_info.and_then(|s| s.branch.as_ref().map(|b| b.ref_name.clone()));
5031
5032 write!(output, " - {}", abs_path.display()).ok();
5033 if !visible {
5034 write!(output, " (hidden)").ok();
5035 }
5036 if let Some(branch) = &branch {
5037 write!(output, " [branch: {branch}]").ok();
5038 }
5039 if is_linked {
5040 if let Some(original) = original_repo_path {
5041 write!(output, " [linked worktree -> {}]", original.display()).ok();
5042 } else {
5043 write!(output, " [linked worktree]").ok();
5044 }
5045 }
5046 writeln!(output).ok();
5047 }
5048
5049 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
5050 let panel = panel.read(cx);
5051
5052 let panel_workspace_id = panel.workspace_id();
5053 if panel_workspace_id != workspace_db_id {
5054 writeln!(
5055 output,
5056 " \u{26a0} workspace ID mismatch! panel has {panel_workspace_id:?}, workspace has {workspace_db_id:?}"
5057 )
5058 .ok();
5059 }
5060
5061 if let Some(thread) = panel.active_agent_thread(cx) {
5062 let thread = thread.read(cx);
5063 let title = thread.title().unwrap_or_else(|| "(untitled)".into());
5064 let session_id = thread.session_id();
5065 let status = match thread.status() {
5066 ThreadStatus::Idle => "idle",
5067 ThreadStatus::Generating => "generating",
5068 };
5069 let entry_count = thread.entries().len();
5070 write!(output, "Active thread: {title} (session: {session_id})").ok();
5071 write!(output, " [{status}, {entry_count} entries").ok();
5072 if panel
5073 .active_conversation_view()
5074 .is_some_and(|conversation_view| {
5075 conversation_view
5076 .read(cx)
5077 .root_thread_has_pending_tool_call(cx)
5078 })
5079 {
5080 write!(output, ", awaiting confirmation").ok();
5081 }
5082 writeln!(output, "]").ok();
5083 } else {
5084 writeln!(output, "Active thread: (none)").ok();
5085 }
5086
5087 let background_threads = panel.background_threads();
5088 if !background_threads.is_empty() {
5089 writeln!(
5090 output,
5091 "Background threads ({}): ",
5092 background_threads.len()
5093 )
5094 .ok();
5095 for (session_id, conversation_view) in background_threads {
5096 if let Some(thread_view) = conversation_view.read(cx).root_thread(cx) {
5097 let thread = thread_view.read(cx).thread.read(cx);
5098 let title = thread.title().unwrap_or_else(|| "(untitled)".into());
5099 let status = match thread.status() {
5100 ThreadStatus::Idle => "idle",
5101 ThreadStatus::Generating => "generating",
5102 };
5103 let entry_count = thread.entries().len();
5104 write!(output, " - {title} (session: {session_id})").ok();
5105 write!(output, " [{status}, {entry_count} entries").ok();
5106 if conversation_view
5107 .read(cx)
5108 .root_thread_has_pending_tool_call(cx)
5109 {
5110 write!(output, ", awaiting confirmation").ok();
5111 }
5112 writeln!(output, "]").ok();
5113 } else {
5114 writeln!(output, " - (not connected) (session: {session_id})").ok();
5115 }
5116 }
5117 }
5118 } else {
5119 writeln!(output, "Agent panel: not loaded").ok();
5120 }
5121
5122 writeln!(output).ok();
5123}