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