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