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