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