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