Skip to repository content8492 lines · 330.4 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T01:52:43.591Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
sidebar.rs
1mod thread_switcher;
2
3use acp_thread::ThreadStatus;
4use action_log::DiffStats;
5use agent::{ThreadStore, OMEGA_AGENT_ID};
6use agent_client_protocol::schema::v1 as acp;
7use agent_settings::AgentSettings;
8use agent_ui::terminal_thread_metadata_store::{
9 TerminalThreadMetadata, TerminalThreadMetadataStore, terminal_title_prefix,
10};
11use agent_ui::thread_metadata_store::{
12 ThreadMetadata, ThreadMetadataStore, WorktreePaths, worktree_info_from_thread_paths,
13};
14use agent_ui::threads_archive_view::{
15 ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp,
16 fuzzy_match_positions,
17};
18use agent_ui::{
19 AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, AgentThreadSource,
20 ArchiveSelectedThread, CrossChannelImportOnboarding, DEFAULT_THREAD_TITLE, NewTerminalThread,
21 NewThread, RenameSelectedThread, TerminalId, ThreadId, ThreadImportModal,
22 ThreadTitleRegenerationResult, channels_with_threads, import_threads_from_other_channels,
23};
24use agent_ui::{MessageEditorEvent, StateChange, thread_worktree_archive};
25use chrono::{DateTime, Utc};
26use editor::Editor;
27use feature_flags::{
28 AgentThreadWorktreeLabel, AgentThreadWorktreeLabelFlag, FeatureFlag, FeatureFlagAppExt as _,
29};
30use gpui::{
31 Action as _, AnyElement, App, ClickEvent, Context, Decorations, DismissEvent, Entity, EntityId,
32 FocusHandle, Focusable, KeyContext, ListState, Modifiers, Pixels, Render, SharedString, Task,
33 TaskExt, WeakEntity, Window, WindowBackgroundAppearance, WindowHandle, linear_color_stop,
34 linear_gradient, list, prelude::*, px,
35};
36use itertools::Itertools;
37use language_model::LanguageModelRegistry;
38use menu::{
39 Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious,
40};
41use notifications::status_toast::StatusToast;
42use project::{AgentId, AgentRegistryStore, Event as ProjectEvent, WorktreeId};
43use recent_projects::sidebar_recent_projects::SidebarRecentProjects;
44use remote::{RemoteConnectionOptions, same_remote_connection_identity};
45use ui::utils::platform_title_bar_height;
46
47use serde::{Deserialize, Serialize};
48use settings::Settings as _;
49use std::cmp::Ordering;
50use std::collections::{HashMap, HashSet};
51use std::mem;
52use std::path::{Path, PathBuf};
53use std::rc::Rc;
54use std::sync::Arc;
55use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING};
56use ui::{
57 AgentThreadStatus, CommonAnimationExt, ContextMenu, ContextMenuEntry, Divider, GradientFade,
58 HighlightedLabel, KeyBinding, PopoverMenu, PopoverMenuHandle, ProjectEmptyState, ScrollAxes,
59 Scrollbars, Tab, ThreadItem, ThreadItemWorktreeInfo, TintColor, Tooltip, WithScrollbar,
60 prelude::*, render_modifiers, right_click_menu,
61};
62use unicode_segmentation::UnicodeSegmentation as _;
63use util::ResultExt as _;
64use util::path_list::PathList;
65use workspace::{
66 CloseWindow, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, NextProject,
67 NextThread, Open, OpenMode, PreviousProject, PreviousThread, ProjectGroupKey, SaveIntent,
68 Sidebar as WorkspaceSidebar, SidebarSide, Toast, ToggleWorkspaceSidebar, Workspace,
69 notifications::NotificationId, sidebar_side_context_menu,
70};
71
72use git_ui::worktree_service::{RemoteBranchName, worktree_create_targets};
73use zed_actions::editor::{MoveDown, MoveUp};
74use zed_actions::{CreateWorktree, NewWorktreeBranchTarget, OpenRecent};
75
76use zed_actions::agents_sidebar::{FocusSidebarFilter, ToggleThreadSwitcher};
77
78use crate::thread_switcher::{
79 ThreadSwitcher, ThreadSwitcherEntry, ThreadSwitcherEvent, ThreadSwitcherSelection,
80 ThreadSwitcherTerminalEntry, ThreadSwitcherThreadEntry,
81};
82
83#[cfg(test)]
84mod sidebar_tests;
85
86gpui::actions!(
87 agents_sidebar,
88 [
89 /// Creates a new thread in the currently selected or active project group.
90 NewThreadInGroup,
91 /// Toggles between the thread list and the thread history.
92 ToggleThreadHistory,
93 ]
94);
95
96gpui::actions!(
97 dev,
98 [
99 /// Dumps multi-workspace state (projects, worktrees, active threads) into a new buffer.
100 DumpWorkspaceInfo,
101 ]
102);
103
104const DEFAULT_WIDTH: Pixels = px(300.0);
105const MIN_WIDTH: Pixels = px(200.0);
106const MAX_WIDTH: Pixels = px(800.0);
107
108#[derive(Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
109enum SerializedSidebarView {
110 #[default]
111 ThreadList,
112 #[serde(alias = "Archive")]
113 History,
114}
115
116#[derive(Clone, Copy)]
117enum NewEntryTarget {
118 LastCreatedKind,
119 Terminal,
120}
121
122#[derive(Default, Serialize, Deserialize)]
123struct SerializedSidebar {
124 #[serde(default)]
125 width: Option<f32>,
126 #[serde(default)]
127 active_view: SerializedSidebarView,
128}
129
130#[derive(Debug, Default)]
131enum SidebarView {
132 #[default]
133 ThreadList,
134 Archive(Entity<ThreadsArchiveView>),
135}
136
137enum ArchiveWorktreeOutcome {
138 Success,
139 Cancelled,
140}
141
142#[derive(Clone, Debug)]
143enum ActiveEntry {
144 Thread {
145 thread_id: agent_ui::ThreadId,
146 /// Stable remote identifier, used for matching when thread_id
147 /// differs (e.g. after cross-window activation creates a new
148 /// local ThreadId).
149 session_id: Option<acp::SessionId>,
150 workspace: Entity<Workspace>,
151 },
152 Terminal {
153 terminal_id: TerminalId,
154 workspace: Entity<Workspace>,
155 },
156}
157
158impl ActiveEntry {
159 fn workspace(&self) -> &Entity<Workspace> {
160 match self {
161 ActiveEntry::Thread { workspace, .. } | ActiveEntry::Terminal { workspace, .. } => {
162 workspace
163 }
164 }
165 }
166
167 fn is_active_thread(&self, thread_id: &agent_ui::ThreadId) -> bool {
168 matches!(self, ActiveEntry::Thread { thread_id: active_thread_id, .. } if active_thread_id == thread_id)
169 }
170
171 fn is_active_terminal(&self, terminal_id: TerminalId) -> bool {
172 matches!(self, ActiveEntry::Terminal { terminal_id: active_terminal_id, .. } if *active_terminal_id == terminal_id)
173 }
174
175 fn matches_entry(&self, entry: &ListEntry) -> bool {
176 match (self, entry) {
177 (
178 ActiveEntry::Thread {
179 thread_id,
180 session_id,
181 ..
182 },
183 ListEntry::Thread(thread),
184 ) => {
185 *thread_id == thread.metadata.thread_id
186 || session_id
187 .as_ref()
188 .zip(thread.metadata.session_id.as_ref())
189 .is_some_and(|(a, b)| a == b)
190 }
191 (ActiveEntry::Terminal { terminal_id, .. }, ListEntry::Terminal(terminal)) => {
192 *terminal_id == terminal.metadata.terminal_id
193 }
194 _ => false,
195 }
196 }
197}
198
199#[derive(Clone, Debug)]
200struct ActiveThreadInfo {
201 session_id: acp::SessionId,
202 title: SharedString,
203 status: AgentThreadStatus,
204 icon: IconName,
205 icon_from_external_svg: Option<SharedString>,
206 is_background: bool,
207 is_title_generating: bool,
208 diff_stats: DiffStats,
209}
210
211#[derive(Clone)]
212enum ThreadEntryWorkspace {
213 Open(Entity<Workspace>),
214 Closed {
215 /// The paths this entry uses (may point to linked worktrees).
216 folder_paths: PathList,
217 /// The project group this entry belongs to.
218 project_group_key: ProjectGroupKey,
219 },
220}
221
222impl ThreadEntryWorkspace {
223 fn is_remote(&self, cx: &App) -> bool {
224 match self {
225 ThreadEntryWorkspace::Open(workspace) => {
226 !workspace.read(cx).project().read(cx).is_local()
227 }
228 ThreadEntryWorkspace::Closed {
229 project_group_key, ..
230 } => project_group_key.host().is_some(),
231 }
232 }
233}
234
235/// If the title begins with a decorative prefix (such as a leading emoji,
236/// spinner glyph, or symbol the agent prefixed the title with), splits that
237/// prefix off so a single representative glyph can be displayed in place of the
238/// entry's icon.
239fn split_leading_icon_char(
240 title: &SharedString,
241 highlight_positions: &[usize],
242) -> Option<(SharedString, SharedString, Vec<usize>)> {
243 let prefix = terminal_title_prefix(title)?;
244 let icon_char = pick_icon_glyph(prefix)?;
245
246 let stripped_len = prefix.len();
247 let trimmed_title = &title[stripped_len..];
248 if trimmed_title.is_empty() {
249 return None;
250 }
251
252 let adjusted_positions = highlight_positions
253 .iter()
254 .filter(|&&position| position >= stripped_len)
255 .map(|&position| position - stripped_len)
256 .collect();
257
258 Some((
259 icon_char,
260 trimmed_title.to_string().into(),
261 adjusted_positions,
262 ))
263}
264
265/// Picks a single glyph to render as the icon from a detected title prefix.
266///
267/// We only ever show one glyph, so this makes a best effort to choose a
268/// meaningful one by glancing at the leading characters of the prefix:
269/// runs of `.` are condensed into a single ellipsis, surrounding ASCII brackets
270/// are stripped (so `[!]` yields `!`), and a leading run of the same character
271/// is collapsed (so `>>>` yields `>`). The result is the first grapheme cluster
272/// of whatever remains, keeping multi-codepoint emoji intact.
273fn pick_icon_glyph(prefix: &str) -> Option<SharedString> {
274 let prefix = prefix.trim();
275 if prefix.is_empty() {
276 return None;
277 }
278
279 // Strip a single pair of surrounding ASCII brackets, e.g. `[!]` -> `!`.
280 let unwrapped = match prefix.chars().next() {
281 Some('[') => prefix.strip_prefix('[').and_then(|s| s.strip_suffix(']')),
282 Some('(') => prefix.strip_prefix('(').and_then(|s| s.strip_suffix(')')),
283 Some('{') => prefix.strip_prefix('{').and_then(|s| s.strip_suffix('}')),
284 Some('<') => prefix.strip_prefix('<').and_then(|s| s.strip_suffix('>')),
285 _ => None,
286 };
287 let prefix = unwrapped
288 .map(str::trim)
289 .filter(|s| !s.is_empty())
290 .unwrap_or(prefix);
291
292 // Condense a leading run of dots (`...`) into a single ellipsis.
293 if prefix.starts_with("..") {
294 return Some("\u{2026}".into());
295 }
296
297 // Take the first grapheme cluster so multi-codepoint emoji stay intact.
298 let first_grapheme = prefix.graphemes(true).next()?;
299 if first_grapheme.trim().is_empty() {
300 return None;
301 }
302
303 Some(first_grapheme.to_string().into())
304}
305
306fn draft_display_label_for_thread_metadata(
307 metadata: &ThreadMetadata,
308 workspace: &ThreadEntryWorkspace,
309 cx: &App,
310) -> Option<(SharedString, DraftKind)> {
311 let workspace = match workspace {
312 ThreadEntryWorkspace::Open(workspace) => Some(workspace),
313 ThreadEntryWorkspace::Closed { .. } => None,
314 };
315
316 if let Some(label) =
317 agent_ui::draft_prompt_store::display_label_for_draft(workspace, metadata.thread_id, cx)
318 {
319 return Some((label, DraftKind::WithContent));
320 }
321
322 let placeholder = agent_ui::draft_prompt_store::empty_draft_placeholder_label(
323 workspace,
324 &metadata.agent_id,
325 cx,
326 );
327 Some((placeholder, DraftKind::Empty))
328}
329
330fn thread_metadata_would_render_sidebar_row(
331 metadata: &ThreadMetadata,
332 workspace: &ThreadEntryWorkspace,
333 cx: &App,
334) -> bool {
335 if !metadata.is_draft() {
336 return true;
337 }
338
339 draft_display_label_for_thread_metadata(metadata, workspace, cx).is_some()
340}
341
342#[derive(Clone, Copy, PartialEq, Eq, Debug)]
343enum DraftKind {
344 WithContent,
345 Empty,
346}
347
348#[derive(Clone)]
349struct ThreadEntry {
350 metadata: ThreadMetadata,
351 icon: IconName,
352 icon_from_external_svg: Option<SharedString>,
353 status: AgentThreadStatus,
354 workspace: ThreadEntryWorkspace,
355 is_live: bool,
356 is_background: bool,
357 is_title_generating: bool,
358 draft: Option<DraftKind>,
359 highlight_positions: Vec<usize>,
360 worktrees: Vec<ThreadItemWorktreeInfo>,
361 diff_stats: DiffStats,
362}
363
364#[derive(Clone)]
365struct TerminalEntry {
366 metadata: TerminalThreadMetadata,
367 workspace: ThreadEntryWorkspace,
368 worktrees: Vec<ThreadItemWorktreeInfo>,
369 has_notification: bool,
370 highlight_positions: Vec<usize>,
371}
372
373impl ThreadEntry {
374 /// Updates this thread entry with active thread information.
375 ///
376 /// The existing [`ThreadEntry`] was likely deserialized from the database
377 /// but if we have a correspond thread already loaded we want to apply the
378 /// live information.
379 fn apply_active_info(&mut self, info: &ActiveThreadInfo) {
380 self.metadata.title = Some(info.title.clone());
381 self.status = info.status;
382 self.icon = info.icon;
383 self.icon_from_external_svg = info.icon_from_external_svg.clone();
384 self.is_live = true;
385 self.is_background = info.is_background;
386 self.is_title_generating = info.is_title_generating;
387 self.diff_stats = info.diff_stats;
388 }
389}
390
391#[derive(Clone)]
392enum ListEntry {
393 ProjectHeader {
394 key: ProjectGroupKey,
395 label: SharedString,
396 highlight_positions: Vec<usize>,
397 has_running_threads: bool,
398 waiting_thread_count: usize,
399 has_notifications: bool,
400 is_active: bool,
401 has_threads: bool,
402 },
403 Thread(Arc<ThreadEntry>),
404 Terminal(TerminalEntry),
405}
406
407#[derive(Clone)]
408enum ActivatableEntry {
409 Thread {
410 metadata: ThreadMetadata,
411 workspace: ThreadEntryWorkspace,
412 },
413 Terminal {
414 metadata: TerminalThreadMetadata,
415 workspace: ThreadEntryWorkspace,
416 },
417}
418
419impl ActivatableEntry {
420 fn from_list_entry(entry: &ListEntry) -> Option<Self> {
421 match entry {
422 ListEntry::Thread(thread) => Some(Self::Thread {
423 metadata: thread.metadata.clone(),
424 workspace: thread.workspace.clone(),
425 }),
426 ListEntry::Terminal(terminal) => Some(Self::Terminal {
427 metadata: terminal.metadata.clone(),
428 workspace: terminal.workspace.clone(),
429 }),
430 ListEntry::ProjectHeader { .. } => None,
431 }
432 }
433
434 fn project_location(&self, cx: &App) -> (PathList, ProjectGroupKey) {
435 match self {
436 Self::Thread {
437 workspace: ThreadEntryWorkspace::Open(workspace),
438 ..
439 }
440 | Self::Terminal {
441 workspace: ThreadEntryWorkspace::Open(workspace),
442 ..
443 } => (
444 PathList::new(&workspace.read(cx).root_paths(cx)),
445 workspace.read(cx).project_group_key(cx),
446 ),
447 Self::Thread {
448 workspace:
449 ThreadEntryWorkspace::Closed {
450 folder_paths,
451 project_group_key,
452 },
453 ..
454 }
455 | Self::Terminal {
456 workspace:
457 ThreadEntryWorkspace::Closed {
458 folder_paths,
459 project_group_key,
460 },
461 ..
462 } => (folder_paths.clone(), project_group_key.clone()),
463 }
464 }
465}
466
467#[cfg(test)]
468impl ListEntry {
469 fn session_id(&self) -> Option<&acp::SessionId> {
470 match self {
471 ListEntry::Thread(thread_entry) => thread_entry.metadata.session_id.as_ref(),
472 ListEntry::Terminal(_) | ListEntry::ProjectHeader { .. } => None,
473 }
474 }
475
476 fn reachable_workspaces<'a>(
477 &'a self,
478 multi_workspace: &'a workspace::MultiWorkspace,
479 cx: &'a App,
480 ) -> Vec<Entity<Workspace>> {
481 match self {
482 ListEntry::Thread(thread) => match &thread.workspace {
483 ThreadEntryWorkspace::Open(ws) => vec![ws.clone()],
484 ThreadEntryWorkspace::Closed { .. } => Vec::new(),
485 },
486 ListEntry::Terminal(terminal) => match &terminal.workspace {
487 ThreadEntryWorkspace::Open(workspace) => vec![workspace.clone()],
488 ThreadEntryWorkspace::Closed { .. } => Vec::new(),
489 },
490 ListEntry::ProjectHeader { key, .. } => multi_workspace
491 .workspaces_for_project_group(key, cx)
492 .unwrap_or_default(),
493 }
494 }
495}
496
497impl From<ThreadEntry> for ListEntry {
498 fn from(thread: ThreadEntry) -> Self {
499 ListEntry::Thread(Arc::new(thread))
500 }
501}
502
503impl From<TerminalEntry> for ListEntry {
504 fn from(terminal: TerminalEntry) -> Self {
505 ListEntry::Terminal(terminal)
506 }
507}
508
509#[derive(Default)]
510struct SidebarContents {
511 entries: Vec<ListEntry>,
512 notified_threads: HashSet<agent_ui::ThreadId>,
513 notified_terminals: HashSet<TerminalId>,
514 project_header_indices: Vec<usize>,
515 has_open_projects: bool,
516}
517
518/// Identity-and-layout key for a [`ListEntry`] used to preserve measured list items
519/// across rebuilds. Equal shapes must render to the same height; add any new
520/// height-affecting state here.
521#[derive(Debug, PartialEq, Eq)]
522enum EntryShape {
523 ProjectHeader {
524 key: ProjectGroupKey,
525 // Toggles the "No threads yet" empty-state row when not collapsed.
526 has_threads: bool,
527 // Determines whether the "No threads yet" row is rendered (only shown when
528 // `!is_collapsed && !has_threads`).
529 is_collapsed: bool,
530 },
531 Thread(ThreadId),
532 Terminal(TerminalId),
533}
534
535impl SidebarContents {
536 fn is_thread_notified(&self, thread_id: &agent_ui::ThreadId) -> bool {
537 self.notified_threads.contains(thread_id)
538 }
539
540 fn is_terminal_notified(&self, terminal_id: TerminalId) -> bool {
541 self.notified_terminals.contains(&terminal_id)
542 }
543}
544
545// TODO: The mapping from workspace root paths to git repositories needs a
546// unified approach across the codebase: this function, `AgentPanel::classify_worktrees`,
547// thread persistence (which PathList is saved to the database), and thread
548// querying (which PathList is used to read threads back). All of these need
549// to agree on how repos are resolved for a given workspace, especially in
550// multi-root and nested-repo configurations.
551fn root_repository_snapshots(
552 workspace: &Entity<Workspace>,
553 cx: &App,
554) -> impl Iterator<Item = project::git_store::RepositorySnapshot> {
555 let path_list = workspace_path_list(workspace, cx);
556 let project = workspace.read(cx).project().read(cx);
557 project.repositories(cx).values().filter_map(move |repo| {
558 let snapshot = repo.read(cx).snapshot();
559 let is_root = path_list
560 .paths()
561 .iter()
562 .any(|p| p.as_path() == snapshot.work_directory_abs_path.as_ref());
563 is_root.then_some(snapshot)
564 })
565}
566
567fn workspace_path_list(workspace: &Entity<Workspace>, cx: &App) -> PathList {
568 PathList::new(&workspace.read(cx).root_paths(cx))
569}
570
571fn linked_worktree_path_lists_for_workspaces(
572 workspaces: &[Entity<Workspace>],
573 cx: &App,
574) -> Vec<PathList> {
575 let mut linked_worktree_paths = Vec::new();
576 for workspace in workspaces {
577 if workspace.read(cx).visible_worktrees(cx).count() != 1 {
578 continue;
579 }
580 for snapshot in root_repository_snapshots(workspace, cx) {
581 linked_worktree_paths.extend(
582 snapshot.linked_worktrees().iter().map(|linked_worktree| {
583 PathList::new(std::slice::from_ref(&linked_worktree.path))
584 }),
585 );
586 }
587 }
588
589 linked_worktree_paths.sort_by(|a, b| a.paths()[0].cmp(&b.paths()[0]));
590 linked_worktree_paths
591}
592
593fn workspace_has_terminal_metadata_except(
594 workspace: &Entity<Workspace>,
595 except_terminal_id: Option<TerminalId>,
596 cx: &App,
597) -> bool {
598 let Some(store) = TerminalThreadMetadataStore::try_global(cx) else {
599 return false;
600 };
601 let path_list = workspace_path_list(workspace, cx);
602 let remote_connection = workspace
603 .read(cx)
604 .project()
605 .read(cx)
606 .remote_connection_options(cx);
607 store
608 .read(cx)
609 .entries_for_path(&path_list, remote_connection.as_ref())
610 .any(|terminal| except_terminal_id != Some(terminal.terminal_id))
611}
612
613#[derive(Clone)]
614struct WorkspaceMenuWorktreeLabel {
615 icon: Option<IconName>,
616 primary_name: SharedString,
617 secondary_name: Option<SharedString>,
618}
619
620impl WorkspaceMenuWorktreeLabel {
621 fn render(&self) -> impl IntoElement {
622 h_flex()
623 .min_w_0()
624 .gap_0p5()
625 .when_some(self.icon, |this, icon| {
626 this.child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted))
627 })
628 .child(Label::new(self.primary_name.clone()).truncate())
629 .when_some(self.secondary_name.clone(), |this, secondary_name| {
630 this.child(Label::new("/").alpha(0.5))
631 .child(Label::new(secondary_name).truncate())
632 })
633 }
634}
635
636fn workspace_menu_worktree_labels(
637 workspace: &Entity<Workspace>,
638 cx: &App,
639) -> Vec<WorkspaceMenuWorktreeLabel> {
640 let root_paths = workspace.read(cx).root_paths(cx);
641 let show_folder_name = root_paths.len() > 1;
642 let project = workspace.read(cx).project().clone();
643 let repository_snapshots: Vec<_> = project
644 .read(cx)
645 .repositories(cx)
646 .values()
647 .map(|repo| repo.read(cx).snapshot())
648 .collect();
649
650 root_paths
651 .into_iter()
652 .map(|root_path| {
653 let root_path = root_path.as_ref();
654 let folder_name = root_path
655 .file_name()
656 .map(|name| SharedString::from(name.to_string_lossy().to_string()))
657 .unwrap_or_default();
658 let repository_snapshot = repository_snapshots
659 .iter()
660 .find(|snapshot| snapshot.work_directory_abs_path.as_ref() == root_path);
661
662 if let Some(snapshot) = repository_snapshot {
663 let worktree_name = if snapshot.is_linked_worktree() {
664 snapshot
665 .main_worktree_abs_path()
666 .and_then(|main_worktree_path| {
667 project::linked_worktree_short_name(main_worktree_path, root_path)
668 })
669 .unwrap_or_else(|| folder_name.clone())
670 } else {
671 "main".into()
672 };
673
674 if show_folder_name {
675 WorkspaceMenuWorktreeLabel {
676 icon: Some(IconName::GitWorktree),
677 primary_name: folder_name,
678 secondary_name: Some(worktree_name),
679 }
680 } else {
681 WorkspaceMenuWorktreeLabel {
682 icon: Some(IconName::GitWorktree),
683 primary_name: worktree_name,
684 secondary_name: None,
685 }
686 }
687 } else {
688 WorkspaceMenuWorktreeLabel {
689 icon: None,
690 primary_name: folder_name,
691 secondary_name: None,
692 }
693 }
694 })
695 .collect()
696}
697
698fn apply_worktree_label_mode(
699 mut worktrees: Vec<ThreadItemWorktreeInfo>,
700 mode: AgentThreadWorktreeLabel,
701) -> Vec<ThreadItemWorktreeInfo> {
702 match mode {
703 AgentThreadWorktreeLabel::Both => {}
704 AgentThreadWorktreeLabel::Worktree => {
705 for wt in &mut worktrees {
706 wt.branch_name = None;
707 }
708 }
709 AgentThreadWorktreeLabel::Branch => {
710 for wt in &mut worktrees {
711 // Fall back to showing the worktree name when no branch is
712 // known; an empty chip would be worse than a mismatched icon.
713 if wt.branch_name.is_some() {
714 wt.worktree_name = None;
715 }
716 }
717 }
718 }
719 worktrees
720}
721
722/// Shows a [`RemoteConnectionModal`] on the given workspace and establishes
723/// an SSH connection. Suitable for passing to
724/// [`MultiWorkspace::find_or_create_workspace`] as the `connect_remote`
725/// argument.
726fn connect_remote(
727 modal_workspace: Entity<Workspace>,
728 connection_options: RemoteConnectionOptions,
729 window: &mut Window,
730 cx: &mut Context<MultiWorkspace>,
731) -> gpui::Task<anyhow::Result<Option<Entity<remote::RemoteClient>>>> {
732 remote_connection::connect_with_modal(&modal_workspace, connection_options, window, cx)
733}
734
735// Per-project-group cache of the remote default branch, used to populate the
736// "Create New Worktree" submenu without doing git I/O while the menu is open.
737enum DefaultBranchCache {
738 Pending,
739 Resolved(Option<RemoteBranchName>),
740}
741
742// Mirrors the behavior of the worktree picker's "Create new worktree" entries.
743fn create_worktree_in_workspace(
744 workspace: &Entity<Workspace>,
745 branch_target: NewWorktreeBranchTarget,
746 window: &mut Window,
747 cx: &mut App,
748) {
749 workspace.update(cx, |workspace, cx| {
750 let focused_dock = workspace.focused_dock_position(window, cx);
751 git_ui::worktree_service::handle_create_worktree(
752 workspace,
753 &CreateWorktree {
754 worktree_name: None,
755 branch_target,
756 },
757 window,
758 focused_dock,
759 cx,
760 );
761 });
762}
763
764/// The sidebar re-derives its entire entry list from scratch on every
765/// change via `update_entries` → `rebuild_contents`. Avoid adding
766/// incremental or inter-event coordination state — if something can
767/// be computed from the current world state, compute it in the rebuild.
768pub struct Sidebar {
769 multi_workspace: WeakEntity<MultiWorkspace>,
770 width: Pixels,
771 focus_handle: FocusHandle,
772 filter_editor: Entity<Editor>,
773 thread_rename_editor: Entity<Editor>,
774 list_state: ListState,
775 contents: SidebarContents,
776 /// The index of the list item that currently has the keyboard focus
777 ///
778 /// Note: This is NOT the same as the active item.
779 selection: Option<usize>,
780 /// Tracks which sidebar entry is currently active (highlighted).
781 active_entry: Option<ActiveEntry>,
782 hovered_thread_index: Option<usize>,
783 renaming_thread_id: Option<ThreadId>,
784 /// Threads in the database-backed regeneration path need their own loading
785 /// state because they do not have a live `agent::Thread` to report it.
786 regenerating_titles: HashSet<ThreadId>,
787 /// start_renaming_thread must seed current title into the title editor
788 /// so this prevents that BufferEdited event from being interpreted as user input.
789 suppress_next_rename_edit: bool,
790
791 /// Updated only in response to explicit user actions (clicking a
792 /// thread, confirming in the thread switcher, etc.) — never from
793 /// background data changes. Used to sort the thread switcher popup.
794 thread_last_accessed: HashMap<ThreadId, DateTime<Utc>>,
795 terminal_last_accessed: HashMap<TerminalId, DateTime<Utc>>,
796 thread_switcher: Option<Entity<ThreadSwitcher>>,
797 _thread_switcher_subscriptions: Vec<gpui::Subscription>,
798 pending_thread_activation: Option<agent_ui::ThreadId>,
799 /// Persists live thread statuses across rebuilds so that Running→Completed
800 /// transitions can be detected even when the group is collapsed (and
801 /// thread entries are not present in the list).
802 live_thread_statuses: HashMap<acp::SessionId, (AgentThreadStatus, ThreadId)>,
803 /// Remembers whether each draft last rendered as empty or with content so
804 /// that when a draft that was empty gains content again, we refresh
805 /// its interaction time.
806 draft_kinds: HashMap<ThreadId, DraftKind>,
807 view: SidebarView,
808 restoring_tasks: HashMap<agent_ui::ThreadId, Task<()>>,
809 recent_projects_popover_handle: PopoverMenuHandle<SidebarRecentProjects>,
810 project_header_menu_handles: HashMap<usize, PopoverMenuHandle<ContextMenu>>,
811 project_header_new_thread_menu_handles: HashMap<usize, PopoverMenuHandle<ContextMenu>>,
812 project_header_menu_ix: Option<usize>,
813 worktree_default_branches: HashMap<ProjectGroupKey, DefaultBranchCache>,
814 _subscriptions: Vec<gpui::Subscription>,
815 _draft_editor_observations: Vec<gpui::Subscription>,
816 update_task: Option<Task<()>>,
817 /// For the thread import banners, if there is just one we show "Import
818 /// Threads" but if we are showing both the external agents and other
819 /// channels import banners then we change the text to disambiguate the
820 /// buttons. This field tracks whether we were using verbose labels so they
821 /// can stay stable after dismissing one of the banners.
822 import_banners_use_verbose_labels: Option<bool>,
823 /// Display names of other release channels that have threads available to
824 /// import.
825 cross_channel_import_channels: Vec<SharedString>,
826}
827
828impl Sidebar {
829 pub fn new(
830 multi_workspace: Entity<MultiWorkspace>,
831 window: &mut Window,
832 cx: &mut Context<Self>,
833 ) -> Self {
834 let focus_handle = cx.focus_handle();
835 cx.on_focus_in(&focus_handle, window, Self::focus_in)
836 .detach();
837
838 AgentThreadWorktreeLabelFlag::watch(cx);
839
840 let filter_editor = cx.new(|cx| {
841 let mut editor = Editor::single_line(window, cx);
842 editor.set_placeholder_text("Search threads…", window, cx);
843 editor
844 });
845 let thread_rename_editor = cx.new(|cx| Editor::single_line(window, cx));
846
847 cx.subscribe_in(
848 &multi_workspace,
849 window,
850 |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
851 MultiWorkspaceEvent::ActiveWorkspaceChanged { .. } => {
852 this.sync_active_entry_from_active_workspace(cx);
853 this.replace_archived_panel_thread(window, cx);
854 this.schedule_update_entries(false, cx);
855 }
856 MultiWorkspaceEvent::WorkspaceAdded(workspace) => {
857 this.subscribe_to_workspace(workspace, window, cx);
858 this.schedule_update_entries(false, cx);
859 }
860 MultiWorkspaceEvent::WorkspaceRemoved(_)
861 | MultiWorkspaceEvent::ProjectGroupsChanged => {
862 this.schedule_update_entries(false, cx);
863 }
864 },
865 )
866 .detach();
867
868 cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
869 if let editor::EditorEvent::BufferEdited = event {
870 let query = this.filter_editor.read(cx).text(cx);
871 if !query.is_empty() {
872 this.selection.take();
873 }
874 this.schedule_update_entries(!query.is_empty(), cx);
875 }
876 })
877 .detach();
878
879 cx.subscribe_in(
880 &thread_rename_editor,
881 window,
882 |this, title_editor, event, window, cx| {
883 this.handle_thread_rename_editor_event(title_editor, event, window, cx);
884 },
885 )
886 .detach();
887
888 cx.observe(&ThreadMetadataStore::global(cx), |this, _store, cx| {
889 this.schedule_update_entries(false, cx);
890 })
891 .detach();
892
893 cx.observe(
894 &TerminalThreadMetadataStore::global(cx),
895 |this, _store, cx| {
896 this.schedule_update_entries(false, cx);
897 },
898 )
899 .detach();
900
901 let channels_with_threads = channels_with_threads(cx);
902 cx.spawn(async move |this, cx| {
903 let channels = channels_with_threads.await;
904 this.update(cx, |this, cx| {
905 this.cross_channel_import_channels = channels;
906 cx.notify();
907 })
908 .ok();
909 })
910 .detach();
911
912 let deferred_multi_workspace = multi_workspace.downgrade();
913 cx.defer_in(window, move |this, window, cx| {
914 if let Some(multi_workspace) = deferred_multi_workspace.upgrade() {
915 let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect();
916 for workspace in &workspaces {
917 this.subscribe_to_workspace(workspace, window, cx);
918 }
919 }
920 this.schedule_update_entries(false, cx);
921 });
922
923 Self {
924 multi_workspace: multi_workspace.downgrade(),
925 width: DEFAULT_WIDTH,
926 focus_handle,
927 filter_editor,
928 thread_rename_editor,
929 list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
930 contents: SidebarContents::default(),
931 selection: None,
932 active_entry: None,
933 hovered_thread_index: None,
934 renaming_thread_id: None,
935 regenerating_titles: HashSet::new(),
936 suppress_next_rename_edit: false,
937
938 thread_last_accessed: HashMap::new(),
939 terminal_last_accessed: HashMap::new(),
940 thread_switcher: None,
941 _thread_switcher_subscriptions: Vec::new(),
942 pending_thread_activation: None,
943 live_thread_statuses: HashMap::new(),
944 draft_kinds: HashMap::new(),
945 view: SidebarView::default(),
946 restoring_tasks: HashMap::new(),
947 recent_projects_popover_handle: PopoverMenuHandle::default(),
948 project_header_menu_handles: HashMap::new(),
949 project_header_new_thread_menu_handles: HashMap::new(),
950 project_header_menu_ix: None,
951 worktree_default_branches: HashMap::new(),
952 _subscriptions: Vec::new(),
953 _draft_editor_observations: Vec::new(),
954 update_task: None,
955 import_banners_use_verbose_labels: None,
956 cross_channel_import_channels: Vec::new(),
957 }
958 }
959
960 fn serialize(&mut self, cx: &mut Context<Self>) {
961 cx.emit(workspace::SidebarEvent::SerializeNeeded);
962 }
963
964 fn is_group_collapsed(&self, key: &ProjectGroupKey, cx: &App) -> bool {
965 self.multi_workspace
966 .upgrade()
967 .and_then(|mw| {
968 mw.read(cx)
969 .group_state_by_key(key)
970 .map(|state| !state.expanded)
971 })
972 .unwrap_or(false)
973 }
974
975 fn set_group_expanded(&self, key: &ProjectGroupKey, expanded: bool, cx: &mut Context<Self>) {
976 if let Some(mw) = self.multi_workspace.upgrade() {
977 mw.update(cx, |mw, cx| {
978 if let Some(state) = mw.group_state_by_key_mut(key) {
979 state.expanded = expanded;
980 }
981 mw.serialize(cx);
982 });
983 }
984 }
985
986 fn is_active_workspace(&self, workspace: &Entity<Workspace>, cx: &App) -> bool {
987 self.multi_workspace
988 .upgrade()
989 .map_or(false, |mw| mw.read(cx).workspace() == workspace)
990 }
991
992 fn subscribe_to_workspace(
993 &mut self,
994 workspace: &Entity<Workspace>,
995 window: &mut Window,
996 cx: &mut Context<Self>,
997 ) {
998 let project = workspace.read(cx).project().clone();
999 if project.read(cx).is_via_collab() {
1000 return;
1001 }
1002
1003 cx.subscribe_in(
1004 &project,
1005 window,
1006 |this, project, event, _window, cx| match event {
1007 ProjectEvent::WorktreeAdded(_)
1008 | ProjectEvent::WorktreeRemoved(_)
1009 | ProjectEvent::WorktreeOrderChanged => {
1010 this.schedule_update_entries(false, cx);
1011 }
1012 ProjectEvent::WorktreePathsChanged { old_worktree_paths } => {
1013 this.move_entry_paths(project, old_worktree_paths, cx);
1014 this.schedule_update_entries(false, cx);
1015 }
1016 _ => {}
1017 },
1018 )
1019 .detach();
1020
1021 let git_store = workspace.read(cx).project().read(cx).git_store().clone();
1022 cx.subscribe_in(
1023 &git_store,
1024 window,
1025 |this, _, event: &project::git_store::GitStoreEvent, _window, cx| {
1026 if matches!(
1027 event,
1028 project::git_store::GitStoreEvent::RepositoryUpdated(
1029 _,
1030 project::git_store::RepositoryEvent::GitWorktreeListChanged
1031 | project::git_store::RepositoryEvent::HeadChanged,
1032 _,
1033 )
1034 ) {
1035 this.schedule_update_entries(false, cx);
1036 }
1037 },
1038 )
1039 .detach();
1040
1041 cx.subscribe_in(
1042 workspace,
1043 window,
1044 move |this, workspace, event: &workspace::Event, window, cx| {
1045 if let workspace::Event::PanelAdded(view) = event {
1046 if let Ok(agent_panel) = view.clone().downcast::<AgentPanel>() {
1047 this.subscribe_to_agent_panel(workspace, &agent_panel, window, cx);
1048 this.schedule_update_entries(false, cx);
1049 }
1050 }
1051 },
1052 )
1053 .detach();
1054
1055 self.observe_docks(workspace, cx);
1056
1057 if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
1058 self.subscribe_to_agent_panel(workspace, &agent_panel, window, cx);
1059 }
1060 }
1061
1062 fn move_entry_paths(
1063 &mut self,
1064 project: &Entity<project::Project>,
1065 old_paths: &WorktreePaths,
1066 cx: &mut Context<Self>,
1067 ) {
1068 if project.read(cx).is_via_collab() {
1069 return;
1070 }
1071
1072 let new_paths = project.read(cx).worktree_paths(cx);
1073 let old_folder_paths = old_paths.folder_path_list().clone();
1074
1075 let added_pairs: Vec<_> = new_paths
1076 .ordered_pairs()
1077 .filter(|(main, folder)| {
1078 !old_paths
1079 .ordered_pairs()
1080 .any(|(old_main, old_folder)| old_main == *main && old_folder == *folder)
1081 })
1082 .map(|(m, f)| (m.clone(), f.clone()))
1083 .collect();
1084
1085 let new_folder_paths = new_paths.folder_path_list();
1086 let removed_folder_paths: Vec<PathBuf> = old_folder_paths
1087 .paths()
1088 .iter()
1089 .filter(|p| !new_folder_paths.paths().contains(p))
1090 .cloned()
1091 .collect();
1092
1093 if added_pairs.is_empty() && removed_folder_paths.is_empty() {
1094 return;
1095 }
1096
1097 let remote_connection = project.read(cx).remote_connection_options(cx);
1098 let apply_path_changes = |paths: &mut WorktreePaths| {
1099 for (main_path, folder_path) in &added_pairs {
1100 paths.add_path(main_path, folder_path);
1101 }
1102 for path in &removed_folder_paths {
1103 paths.remove_folder_path(path);
1104 }
1105 };
1106 ThreadMetadataStore::global(cx).update(cx, |store, store_cx| {
1107 store.change_worktree_paths(
1108 &old_folder_paths,
1109 remote_connection.as_ref(),
1110 &apply_path_changes,
1111 store_cx,
1112 );
1113 });
1114 TerminalThreadMetadataStore::global(cx).update(cx, |store, store_cx| {
1115 store.change_worktree_paths(
1116 &old_folder_paths,
1117 remote_connection.as_ref(),
1118 &apply_path_changes,
1119 store_cx,
1120 );
1121 });
1122 }
1123
1124 fn subscribe_to_agent_panel(
1125 &mut self,
1126 workspace: &Entity<Workspace>,
1127 agent_panel: &Entity<AgentPanel>,
1128 window: &mut Window,
1129 cx: &mut Context<Self>,
1130 ) {
1131 let workspace = workspace.downgrade();
1132 cx.subscribe_in(
1133 agent_panel,
1134 window,
1135 move |this, agent_panel, event: &AgentPanelEvent, window, cx| match event {
1136 AgentPanelEvent::ActiveViewChanged
1137 | AgentPanelEvent::ActiveViewFocused
1138 | AgentPanelEvent::EntryChanged => {
1139 this.sync_active_entry_from_panel(agent_panel, cx);
1140 this.schedule_update_entries(false, cx);
1141 }
1142 AgentPanelEvent::TerminalCloseRequested { metadata } => {
1143 if let Some(workspace) = workspace.upgrade() {
1144 let workspace = ThreadEntryWorkspace::Open(workspace);
1145 this.close_terminal(metadata, &workspace, window, cx);
1146 }
1147 }
1148 AgentPanelEvent::ThreadInteracted { thread_id } => {
1149 this.record_thread_interacted(thread_id, cx);
1150 this.schedule_update_entries(false, cx);
1151 }
1152 },
1153 )
1154 .detach();
1155 }
1156
1157 fn sync_active_entry_from_active_workspace(&mut self, cx: &App) {
1158 let panel = self
1159 .active_workspace(cx)
1160 .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx));
1161 if let Some(panel) = panel {
1162 self.sync_active_entry_from_panel(&panel, cx);
1163 }
1164 }
1165
1166 /// When switching workspaces, the active panel may still be showing
1167 /// a thread that was archived from a different workspace. In that
1168 /// case, create a fresh draft so the panel has valid content and
1169 /// `active_entry` can point at it.
1170 fn replace_archived_panel_thread(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1171 let Some(workspace) = self.active_workspace(cx) else {
1172 return;
1173 };
1174 let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
1175 return;
1176 };
1177 let Some(thread_id) = panel.read(cx).active_thread_id(cx) else {
1178 return;
1179 };
1180 let is_archived = ThreadMetadataStore::global(cx)
1181 .read(cx)
1182 .entry(thread_id)
1183 .is_some_and(|m| m.archived);
1184 if is_archived {
1185 self.create_new_thread(&workspace, window, cx);
1186 }
1187 }
1188
1189 /// Syncs `active_entry` from the agent panel's current state.
1190 /// Called from `ActiveViewChanged` — the panel has settled into its
1191 /// new view, so we can safely read it without race conditions.
1192 ///
1193 /// Also resolves `pending_thread_activation` when the panel's
1194 /// active thread matches the pending activation.
1195 fn sync_active_entry_from_panel(&mut self, agent_panel: &Entity<AgentPanel>, cx: &App) -> bool {
1196 let Some(active_workspace) = self.active_workspace(cx) else {
1197 return false;
1198 };
1199
1200 // Only sync when the event comes from the active workspace's panel.
1201 let is_active_panel = active_workspace
1202 .read(cx)
1203 .panel::<AgentPanel>(cx)
1204 .is_some_and(|p| p == *agent_panel);
1205 if !is_active_panel {
1206 return false;
1207 }
1208
1209 let panel = agent_panel.read(cx);
1210
1211 if let Some(pending_thread_id) = self.pending_thread_activation {
1212 let panel_thread_id = panel
1213 .active_conversation_view()
1214 .map(|cv| cv.read(cx).parent_id());
1215
1216 if panel_thread_id == Some(pending_thread_id) {
1217 let session_id = panel
1218 .active_agent_thread(cx)
1219 .map(|thread| thread.read(cx).session_id().clone());
1220 self.active_entry = Some(ActiveEntry::Thread {
1221 thread_id: pending_thread_id,
1222 session_id,
1223 workspace: active_workspace,
1224 });
1225 self.pending_thread_activation = None;
1226 return true;
1227 }
1228 // Pending activation not yet resolved — keep current active_entry.
1229 return false;
1230 }
1231
1232 if let Some(terminal_id) = panel.active_terminal_id() {
1233 self.active_entry = Some(ActiveEntry::Terminal {
1234 terminal_id,
1235 workspace: active_workspace,
1236 });
1237 } else if let Some(thread_id) = panel.active_thread_id(cx) {
1238 let is_archived = ThreadMetadataStore::global(cx)
1239 .read(cx)
1240 .entry(thread_id)
1241 .is_some_and(|m| m.archived);
1242 if !is_archived {
1243 let session_id = panel
1244 .active_agent_thread(cx)
1245 .map(|thread| thread.read(cx).session_id().clone());
1246 self.active_entry = Some(ActiveEntry::Thread {
1247 thread_id,
1248 session_id,
1249 workspace: active_workspace,
1250 });
1251 }
1252 }
1253
1254 false
1255 }
1256
1257 fn observe_docks(&mut self, workspace: &Entity<Workspace>, cx: &mut Context<Self>) {
1258 let docks: Vec<_> = workspace
1259 .read(cx)
1260 .all_docks()
1261 .into_iter()
1262 .cloned()
1263 .collect();
1264 let workspace = workspace.downgrade();
1265 for dock in docks {
1266 let workspace = workspace.clone();
1267 cx.observe(&dock, move |this, _dock, cx| {
1268 let Some(workspace) = workspace.upgrade() else {
1269 return;
1270 };
1271 if !this.is_active_workspace(&workspace, cx) {
1272 return;
1273 }
1274
1275 cx.notify();
1276 })
1277 .detach();
1278 }
1279 }
1280
1281 /// Opens a new workspace for a group that has no open workspaces.
1282 fn open_workspace_for_group(
1283 &mut self,
1284 project_group_key: &ProjectGroupKey,
1285 window: &mut Window,
1286 cx: &mut Context<Self>,
1287 ) {
1288 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1289 return;
1290 };
1291 let path_list = project_group_key.path_list().clone();
1292 let host = project_group_key.host();
1293 let provisional_key = Some(project_group_key.clone());
1294 let active_workspace = multi_workspace.read(cx).workspace().clone();
1295 let modal_workspace = active_workspace.clone();
1296
1297 let task = multi_workspace.update(cx, |this, cx| {
1298 this.find_or_create_workspace(
1299 path_list,
1300 host,
1301 provisional_key,
1302 |options, window, cx| connect_remote(active_workspace, options, window, cx),
1303 &[],
1304 None,
1305 OpenMode::Activate,
1306 window,
1307 cx,
1308 )
1309 });
1310
1311 cx.spawn_in(window, async move |_this, cx| {
1312 let result = task.await;
1313 remote_connection::dismiss_connection_modal(&modal_workspace, cx);
1314 result?;
1315 anyhow::Ok(())
1316 })
1317 .detach_and_log_err(cx);
1318 }
1319
1320 fn open_workspace_and_create_entry(
1321 &mut self,
1322 project_group_key: &ProjectGroupKey,
1323 target: NewEntryTarget,
1324 window: &mut Window,
1325 cx: &mut Context<Self>,
1326 ) {
1327 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1328 return;
1329 };
1330
1331 let path_list = project_group_key.path_list().clone();
1332 let host = project_group_key.host();
1333 let provisional_key = Some(project_group_key.clone());
1334 let active_workspace = multi_workspace.read(cx).workspace().clone();
1335
1336 let task = multi_workspace.update(cx, |this, cx| {
1337 this.find_or_create_workspace(
1338 path_list,
1339 host,
1340 provisional_key,
1341 |options, window, cx| connect_remote(active_workspace, options, window, cx),
1342 &[],
1343 None,
1344 OpenMode::Activate,
1345 window,
1346 cx,
1347 )
1348 });
1349
1350 cx.spawn_in(window, async move |this, cx| {
1351 let workspace = task.await?;
1352 this.update_in(cx, |this, window, cx| match target {
1353 NewEntryTarget::LastCreatedKind => this.create_new_entry(&workspace, window, cx),
1354 NewEntryTarget::Terminal => this.create_new_terminal(&workspace, window, cx),
1355 })?;
1356 anyhow::Ok(())
1357 })
1358 .detach_and_log_err(cx);
1359 }
1360
1361 /// Rebuilds the sidebar contents from current workspace and thread state.
1362 ///
1363 /// Iterates [`MultiWorkspace::project_group_keys`] to determine project
1364 /// groups, then populates thread entries from the metadata store and
1365 /// merges live thread info from active agent panels.
1366 ///
1367 /// Aim for a single forward pass over workspaces and threads plus an
1368 /// O(T log T) sort. Avoid adding extra scans over the data.
1369 ///
1370 /// Properties:
1371 ///
1372 /// - Should always show every workspace in the multiworkspace
1373 /// - If you have no threads, and two workspaces for the worktree and the main workspace, make sure at least one is shown
1374 /// - Should always show every thread, associated with each workspace in the multiworkspace
1375 /// - After every build_contents, our "active" state should exactly match the current workspace's, current agent panel's current thread.
1376 fn rebuild_contents(&mut self, cx: &App) {
1377 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
1378 return;
1379 };
1380 let mw = multi_workspace.read(cx);
1381 let workspaces: Vec<_> = mw.workspaces().cloned().collect();
1382 let active_workspace = Some(mw.workspace().clone());
1383
1384 let agent_server_store = workspaces
1385 .first()
1386 .map(|ws| ws.read(cx).project().read(cx).agent_server_store().clone());
1387
1388 let query = self.filter_editor.read(cx).text(cx);
1389
1390 let previous = mem::take(&mut self.contents);
1391
1392 let old_statuses = &self.live_thread_statuses;
1393
1394 let mut entries = Vec::new();
1395 let mut notified_threads = previous.notified_threads;
1396 let mut notified_terminals: HashSet<TerminalId> = HashSet::new();
1397 let mut new_live_statuses: HashMap<acp::SessionId, (AgentThreadStatus, ThreadId)> =
1398 HashMap::new();
1399 let mut current_session_ids: HashSet<acp::SessionId> = HashSet::new();
1400 let mut current_thread_ids: HashSet<agent_ui::ThreadId> = HashSet::new();
1401 let mut current_terminal_ids: HashSet<TerminalId> = HashSet::new();
1402 let mut project_header_indices: Vec<usize> = Vec::new();
1403 let mut seen_thread_ids: HashSet<agent_ui::ThreadId> = HashSet::new();
1404 let mut seen_terminal_ids: HashSet<TerminalId> = HashSet::new();
1405
1406 let has_open_projects = workspaces
1407 .iter()
1408 .any(|ws| !workspace_path_list(ws, cx).paths().is_empty());
1409
1410 let resolve_agent_icon = |agent_id: &AgentId| -> (IconName, Option<SharedString>) {
1411 let agent = Agent::from(agent_id.clone());
1412 let icon = match agent {
1413 Agent::NativeAgent => IconName::OmegaAgent,
1414 Agent::Custom { .. } => IconName::Terminal,
1415
1416 _ => IconName::OmegaAgent,
1417 };
1418 let icon_from_external_svg = agent_server_store
1419 .as_ref()
1420 .and_then(|store| store.read(cx).agent_icon(&agent_id));
1421 (icon, icon_from_external_svg)
1422 };
1423
1424 let groups = mw.project_groups(cx);
1425 let mut live_notified_terminal_ids: HashSet<TerminalId> = HashSet::new();
1426 for workspace in &workspaces {
1427 if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
1428 live_notified_terminal_ids.extend(
1429 agent_panel
1430 .read(cx)
1431 .terminals(cx)
1432 .into_iter()
1433 .filter_map(|terminal| terminal.has_notification.then_some(terminal.id)),
1434 );
1435 }
1436 }
1437
1438 let mut all_paths: Vec<PathBuf> = groups
1439 .iter()
1440 .flat_map(|group| group.key.path_list().paths().iter().cloned())
1441 .collect();
1442 all_paths.sort_unstable();
1443 all_paths.dedup();
1444 let path_details =
1445 util::disambiguate::compute_disambiguation_details(&all_paths, |path, detail| {
1446 project::path_suffix(path, detail)
1447 });
1448 let path_detail_map: HashMap<PathBuf, usize> =
1449 all_paths.into_iter().zip(path_details).collect();
1450
1451 let mut branch_by_path: HashMap<PathBuf, SharedString> = HashMap::new();
1452 for ws in &workspaces {
1453 let project = ws.read(cx).project().read(cx);
1454 for repo in project.repositories(cx).values() {
1455 let snapshot = repo.read(cx).snapshot();
1456 if let Some(branch) = &snapshot.branch {
1457 branch_by_path.insert(
1458 snapshot.work_directory_abs_path.to_path_buf(),
1459 SharedString::from(Arc::<str>::from(branch.name())),
1460 );
1461 }
1462 for linked_wt in snapshot.linked_worktrees() {
1463 if let Some(branch) = linked_wt.branch_name() {
1464 branch_by_path.insert(
1465 linked_wt.path.clone(),
1466 SharedString::from(Arc::<str>::from(branch)),
1467 );
1468 }
1469 }
1470 }
1471 }
1472
1473 for group in &groups {
1474 let group_key = &group.key;
1475 let group_workspaces = &group.workspaces;
1476
1477 let workspace_by_path_list: HashMap<PathList, &Entity<Workspace>> = group_workspaces
1478 .iter()
1479 .map(|ws| (workspace_path_list(ws, cx), ws))
1480 .collect();
1481 let resolve_workspace = |folder_paths: &PathList| -> ThreadEntryWorkspace {
1482 workspace_by_path_list
1483 .get(folder_paths)
1484 .map(|ws| ThreadEntryWorkspace::Open((*ws).clone()))
1485 .unwrap_or_else(|| ThreadEntryWorkspace::Closed {
1486 folder_paths: folder_paths.clone(),
1487 project_group_key: group_key.clone(),
1488 })
1489 };
1490 let linked_worktree_path_lists =
1491 linked_worktree_path_lists_for_workspaces(group_workspaces, cx);
1492 let make_terminal_entry =
1493 |metadata: TerminalThreadMetadata, workspace: ThreadEntryWorkspace| {
1494 let worktrees =
1495 worktree_info_from_thread_paths(&metadata.worktree_paths, &branch_by_path);
1496 let has_notification =
1497 live_notified_terminal_ids.contains(&metadata.terminal_id);
1498 TerminalEntry {
1499 metadata,
1500 workspace,
1501 worktrees,
1502 has_notification,
1503 highlight_positions: Vec::new(),
1504 }
1505 };
1506
1507 let mut terminals = Vec::new();
1508 let terminal_store = TerminalThreadMetadataStore::global(cx);
1509 let group_host = group_key.host();
1510 let mut push_terminal_metadata =
1511 |metadata: TerminalThreadMetadata, workspace: ThreadEntryWorkspace| {
1512 if !seen_terminal_ids.insert(metadata.terminal_id) {
1513 return;
1514 }
1515 terminals.push(make_terminal_entry(metadata, workspace));
1516 };
1517 for row in terminal_store
1518 .read(cx)
1519 .entries_for_main_worktree_path(group_key.path_list(), group_host.as_ref())
1520 .cloned()
1521 {
1522 let workspace = resolve_workspace(row.folder_paths());
1523 push_terminal_metadata(row, workspace);
1524 }
1525 for row in terminal_store
1526 .read(cx)
1527 .entries_for_path(group_key.path_list(), group_host.as_ref())
1528 .cloned()
1529 {
1530 let workspace = resolve_workspace(row.folder_paths());
1531 push_terminal_metadata(row, workspace);
1532 }
1533 for ws in group_workspaces {
1534 let ws_paths = workspace_path_list(ws, cx);
1535 if ws_paths.paths().is_empty() {
1536 continue;
1537 }
1538 for row in terminal_store
1539 .read(cx)
1540 .entries_for_path(&ws_paths, group_host.as_ref())
1541 .cloned()
1542 {
1543 push_terminal_metadata(row, ThreadEntryWorkspace::Open(ws.clone()));
1544 }
1545 }
1546 for worktree_path_list in &linked_worktree_path_lists {
1547 for row in terminal_store
1548 .read(cx)
1549 .entries_for_path(worktree_path_list, group_host.as_ref())
1550 .cloned()
1551 {
1552 push_terminal_metadata(
1553 row,
1554 ThreadEntryWorkspace::Closed {
1555 folder_paths: worktree_path_list.clone(),
1556 project_group_key: group_key.clone(),
1557 },
1558 );
1559 }
1560 }
1561 current_terminal_ids.extend(
1562 terminals
1563 .iter()
1564 .map(|terminal| terminal.metadata.terminal_id),
1565 );
1566 notified_terminals.extend(terminals.iter().filter_map(|terminal| {
1567 terminal
1568 .has_notification
1569 .then_some(terminal.metadata.terminal_id)
1570 }));
1571 if group_key.path_list().paths().is_empty() {
1572 continue;
1573 }
1574
1575 let label = group_key.display_name(&path_detail_map);
1576
1577 let is_collapsed = self.is_group_collapsed(group_key, cx);
1578 let should_load_threads = !is_collapsed || !query.is_empty();
1579
1580 let is_active = active_workspace
1581 .as_ref()
1582 .is_some_and(|active| group_workspaces.contains(active));
1583
1584 // Collect live thread infos from all workspaces in this group.
1585 let live_infos = group_workspaces
1586 .iter()
1587 .flat_map(|ws| all_thread_infos_for_workspace(ws, cx));
1588
1589 let mut threads: Vec<Arc<ThreadEntry>> = Vec::new();
1590 let mut has_running_threads = false;
1591 let mut waiting_thread_count: usize = 0;
1592 let group_host = group_key.host();
1593
1594 if should_load_threads {
1595 let thread_store = ThreadMetadataStore::global(cx);
1596
1597 let make_thread_entry =
1598 |row: ThreadMetadata, workspace: ThreadEntryWorkspace| -> Arc<ThreadEntry> {
1599 let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id);
1600 let worktrees =
1601 worktree_info_from_thread_paths(&row.worktree_paths, &branch_by_path);
1602 // Start drafts as `WithContent`; the post-processing
1603 // pass below downgrades them to `Empty` if no draft
1604 // label can be derived.
1605 let draft = row.is_draft().then_some(DraftKind::WithContent);
1606 Arc::new(ThreadEntry {
1607 metadata: row,
1608 icon,
1609 icon_from_external_svg,
1610 status: AgentThreadStatus::default(),
1611 workspace,
1612 is_live: false,
1613 is_background: false,
1614 is_title_generating: false,
1615 draft,
1616 highlight_positions: Vec::new(),
1617 worktrees,
1618 diff_stats: DiffStats::default(),
1619 })
1620 };
1621
1622 // Main code path: one query per group via main_worktree_paths.
1623 // The main_worktree_paths column is set on all new threads and
1624 // points to the group's canonical paths regardless of which
1625 // linked worktree the thread was opened in.
1626 for row in thread_store
1627 .read(cx)
1628 .entries_for_main_worktree_path(group_key.path_list(), group_host.as_ref())
1629 .cloned()
1630 {
1631 if !seen_thread_ids.insert(row.thread_id) {
1632 continue;
1633 }
1634 let workspace = resolve_workspace(row.folder_paths());
1635 threads.push(make_thread_entry(row, workspace));
1636 }
1637
1638 // Legacy threads did not have `main_worktree_paths` populated, so they
1639 // must be queried by their `folder_paths`.
1640
1641 // Load any legacy threads for the main worktrees of this project group.
1642 for row in thread_store
1643 .read(cx)
1644 .entries_for_path(group_key.path_list(), group_host.as_ref())
1645 .cloned()
1646 {
1647 if !seen_thread_ids.insert(row.thread_id) {
1648 continue;
1649 }
1650 let workspace = resolve_workspace(row.folder_paths());
1651 threads.push(make_thread_entry(row, workspace));
1652 }
1653
1654 // Also surface any thread whose `folder_paths` equals
1655 // one of this group's open workspaces' root paths.
1656 // The three lookups above can all miss when the
1657 // thread's stored `main_worktree_paths` disagree with
1658 // the group key (for example, a stale row whose main
1659 // paths equal its folder paths for a linked-worktree
1660 // workspace). The thread will be rewritten into the
1661 // correct shape the next time `handle_conversation_event`
1662 // fires, but until then the sidebar should still show
1663 // it under the group whose workspace it actually
1664 // belongs to.
1665 for ws in group_workspaces {
1666 let ws_paths = workspace_path_list(ws, cx);
1667 if ws_paths.paths().is_empty() {
1668 continue;
1669 }
1670 for row in thread_store
1671 .read(cx)
1672 .entries_for_path(&ws_paths, group_host.as_ref())
1673 .cloned()
1674 {
1675 if !seen_thread_ids.insert(row.thread_id) {
1676 continue;
1677 }
1678 threads.push(make_thread_entry(
1679 row,
1680 ThreadEntryWorkspace::Open(ws.clone()),
1681 ));
1682 }
1683 }
1684
1685 // Load any legacy threads for any single linked worktree of this project group.
1686 for worktree_path_list in &linked_worktree_path_lists {
1687 for row in thread_store
1688 .read(cx)
1689 .entries_for_path(worktree_path_list, group_host.as_ref())
1690 .cloned()
1691 {
1692 if !seen_thread_ids.insert(row.thread_id) {
1693 continue;
1694 }
1695 threads.push(make_thread_entry(
1696 row,
1697 ThreadEntryWorkspace::Closed {
1698 folder_paths: worktree_path_list.clone(),
1699 project_group_key: group_key.clone(),
1700 },
1701 ));
1702 }
1703 }
1704
1705 for thread in &mut threads {
1706 if thread.draft.is_none() {
1707 continue;
1708 }
1709 if let Some((label, kind)) = draft_display_label_for_thread_metadata(
1710 &thread.metadata,
1711 &thread.workspace,
1712 cx,
1713 ) {
1714 let thread = Arc::make_mut(thread);
1715 thread.metadata.title = Some(label);
1716 thread.draft = Some(kind);
1717 }
1718 }
1719 threads.retain(|thread| thread.draft.is_none() || thread.metadata.title.is_some());
1720
1721 // Keep empty drafts only while their thread is active; preserve
1722 // drafts with content because they hold user-typed state.
1723 let pending_activation = self.pending_thread_activation;
1724 let active_panel_thread_id = active_workspace
1725 .as_ref()
1726 .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
1727 .and_then(|panel| panel.read(cx).active_thread_id(cx));
1728 threads.retain(|thread| {
1729 if thread.draft != Some(DraftKind::Empty) {
1730 return true;
1731 }
1732 if pending_activation.is_some() {
1733 return false;
1734 }
1735 Some(thread.metadata.thread_id) == active_panel_thread_id
1736 });
1737
1738 // Build a lookup from live_infos and compute running/waiting
1739 // counts in a single pass.
1740 let mut live_info_by_session: HashMap<acp::SessionId, ActiveThreadInfo> =
1741 HashMap::new();
1742 for info in live_infos {
1743 if info.status == AgentThreadStatus::Running {
1744 has_running_threads = true;
1745 }
1746 if info.status == AgentThreadStatus::WaitingForConfirmation {
1747 waiting_thread_count += 1;
1748 }
1749 live_info_by_session.insert(info.session_id.clone(), info);
1750 }
1751
1752 // Merge live info into threads and update notification state
1753 // in a single pass.
1754 for thread in &mut threads {
1755 if let Some(session_id) = thread.metadata.session_id.clone() {
1756 if let Some(info) = live_info_by_session.get(&session_id) {
1757 let status = info.status;
1758 let thread_id = thread.metadata.thread_id;
1759 Arc::make_mut(thread).apply_active_info(info);
1760 new_live_statuses.insert(session_id, (status, thread_id));
1761 }
1762 }
1763
1764 let session_id = &thread.metadata.session_id;
1765 let is_active_thread = self.active_entry.as_ref().is_some_and(|entry| {
1766 entry.is_active_thread(&thread.metadata.thread_id)
1767 && active_workspace
1768 .as_ref()
1769 .is_some_and(|active| active == entry.workspace())
1770 });
1771
1772 if thread.status == AgentThreadStatus::Completed
1773 && !is_active_thread
1774 && session_id
1775 .as_ref()
1776 .and_then(|sid| old_statuses.get(sid))
1777 .is_some_and(|(s, _)| *s == AgentThreadStatus::Running)
1778 {
1779 notified_threads.insert(thread.metadata.thread_id);
1780 }
1781
1782 if is_active_thread && !thread.is_background {
1783 notified_threads.remove(&thread.metadata.thread_id);
1784 }
1785 }
1786
1787 threads.sort_by(|a, b| {
1788 let a_time = Self::thread_display_time(&a.metadata);
1789 let b_time = Self::thread_display_time(&b.metadata);
1790 b_time.cmp(&a_time)
1791 });
1792 } else {
1793 for info in live_infos {
1794 if info.status == AgentThreadStatus::Running {
1795 has_running_threads = true;
1796 }
1797 if info.status == AgentThreadStatus::WaitingForConfirmation {
1798 waiting_thread_count += 1;
1799 }
1800 // Resolve the thread_id for this session so we can
1801 // track its status and detect transitions even while
1802 // the group is collapsed.
1803 let thread_id = old_statuses
1804 .get(&info.session_id)
1805 .map(|(_, tid)| *tid)
1806 .or_else(|| {
1807 ThreadMetadataStore::global(cx)
1808 .read(cx)
1809 .entry_by_session(&info.session_id)
1810 .map(|m| m.thread_id)
1811 });
1812
1813 if let Some(thread_id) = thread_id {
1814 let old_status = old_statuses.get(&info.session_id).map(|(s, _)| *s);
1815 new_live_statuses.insert(info.session_id.clone(), (info.status, thread_id));
1816 if info.status == AgentThreadStatus::Completed
1817 && old_status == Some(AgentThreadStatus::Running)
1818 {
1819 notified_threads.insert(thread_id);
1820 }
1821 }
1822 }
1823
1824 if is_active
1825 && let Some(ActiveEntry::Thread { thread_id, .. }) = self.active_entry.as_ref()
1826 {
1827 notified_threads.remove(thread_id);
1828 }
1829 }
1830
1831 let has_visible_rows = !threads.is_empty() || !terminals.is_empty();
1832 let has_stored_thread_rows = !should_load_threads && !has_visible_rows && {
1833 let store = ThreadMetadataStore::global(cx).read(cx);
1834 store
1835 .entries_for_main_worktree_path(group_key.path_list(), group_host.as_ref())
1836 .any(|metadata| {
1837 let workspace = resolve_workspace(metadata.folder_paths());
1838 thread_metadata_would_render_sidebar_row(metadata, &workspace, cx)
1839 })
1840 || store
1841 .entries_for_path(group_key.path_list(), group_host.as_ref())
1842 .any(|metadata| {
1843 let workspace = resolve_workspace(metadata.folder_paths());
1844 thread_metadata_would_render_sidebar_row(metadata, &workspace, cx)
1845 })
1846 };
1847 let has_threads = has_visible_rows || has_stored_thread_rows;
1848
1849 if !query.is_empty() {
1850 let workspace_highlight_positions =
1851 fuzzy_match_positions(&query, &label).unwrap_or_default();
1852 let workspace_matched = !workspace_highlight_positions.is_empty();
1853
1854 let mut matched_threads: Vec<Arc<ThreadEntry>> = Vec::new();
1855 for mut thread in threads {
1856 let mut worktree_matched = false;
1857 {
1858 let thread = Arc::make_mut(&mut thread);
1859 let title = thread.metadata.display_title();
1860 if let Some(positions) = fuzzy_match_positions(&query, title.as_ref()) {
1861 thread.highlight_positions = positions;
1862 }
1863 for worktree in &mut thread.worktrees {
1864 let Some(name) = worktree.worktree_name.as_ref() else {
1865 continue;
1866 };
1867 if let Some(positions) = fuzzy_match_positions(&query, name) {
1868 worktree.highlight_positions = positions;
1869 worktree_matched = true;
1870 }
1871 }
1872 }
1873 if workspace_matched
1874 || !thread.highlight_positions.is_empty()
1875 || worktree_matched
1876 {
1877 matched_threads.push(thread);
1878 }
1879 }
1880
1881 let mut matched_terminals: Vec<TerminalEntry> = Vec::new();
1882 for mut terminal in terminals {
1883 let mut terminal_matched = false;
1884 let terminal_title = terminal.metadata.display_title();
1885 if let Some(positions) = fuzzy_match_positions(&query, terminal_title.as_ref())
1886 {
1887 terminal.highlight_positions = positions;
1888 terminal_matched = true;
1889 }
1890 let mut worktree_matched = false;
1891 for worktree in &mut terminal.worktrees {
1892 let Some(name) = worktree.worktree_name.as_ref() else {
1893 continue;
1894 };
1895 if let Some(positions) = fuzzy_match_positions(&query, name) {
1896 worktree.highlight_positions = positions;
1897 worktree_matched = true;
1898 }
1899 }
1900 if workspace_matched || terminal_matched || worktree_matched {
1901 matched_terminals.push(terminal);
1902 }
1903 }
1904
1905 if matched_threads.is_empty() && matched_terminals.is_empty() && !workspace_matched
1906 {
1907 continue;
1908 }
1909
1910 // Check for notifications: threads that completed while not active.
1911 let has_thread_notifications = matched_threads
1912 .iter()
1913 .any(|t| notified_threads.contains(&t.metadata.thread_id));
1914 let has_terminal_notifications = matched_terminals
1915 .iter()
1916 .any(|t| notified_terminals.contains(&t.metadata.terminal_id));
1917
1918 project_header_indices.push(entries.len());
1919 entries.push(ListEntry::ProjectHeader {
1920 key: group_key.clone(),
1921 label,
1922 highlight_positions: workspace_highlight_positions,
1923 has_running_threads,
1924 waiting_thread_count,
1925 has_notifications: has_thread_notifications || has_terminal_notifications,
1926 is_active,
1927 has_threads,
1928 });
1929
1930 Self::push_entries_by_display_time(
1931 &mut entries,
1932 matched_terminals,
1933 matched_threads,
1934 &mut current_session_ids,
1935 &mut current_thread_ids,
1936 );
1937 } else {
1938 let has_terminal_notifications = terminals
1939 .iter()
1940 .any(|t| notified_terminals.contains(&t.metadata.terminal_id));
1941
1942 // When collapsed, threads aren't loaded into `threads`, so we
1943 // query the store for thread IDs to check notifications and
1944 // to prevent the retain below from purging them.
1945 let has_thread_notifications = if threads.is_empty() && !notified_threads.is_empty()
1946 {
1947 let thread_store = ThreadMetadataStore::global(cx);
1948 let store = thread_store.read(cx);
1949 let group_thread_ids = store
1950 .entries_for_main_worktree_path(group_key.path_list(), group_host.as_ref())
1951 .chain(store.entries_for_path(group_key.path_list(), group_host.as_ref()))
1952 .map(|m| m.thread_id)
1953 .collect::<HashSet<_>>();
1954 current_thread_ids.extend(group_thread_ids.iter());
1955 group_thread_ids
1956 .iter()
1957 .any(|id| notified_threads.contains(id))
1958 } else {
1959 threads
1960 .iter()
1961 .any(|t| notified_threads.contains(&t.metadata.thread_id))
1962 };
1963
1964 project_header_indices.push(entries.len());
1965 entries.push(ListEntry::ProjectHeader {
1966 key: group_key.clone(),
1967 label,
1968 highlight_positions: Vec::new(),
1969 has_running_threads,
1970 waiting_thread_count,
1971 has_notifications: has_thread_notifications || has_terminal_notifications,
1972 is_active,
1973 has_threads,
1974 });
1975
1976 if is_collapsed {
1977 continue;
1978 }
1979
1980 Self::push_entries_by_display_time(
1981 &mut entries,
1982 terminals,
1983 threads,
1984 &mut current_session_ids,
1985 &mut current_thread_ids,
1986 );
1987 }
1988 }
1989
1990 notified_threads.retain(|id| current_thread_ids.contains(id));
1991
1992 self.thread_last_accessed
1993 .retain(|id, _| current_thread_ids.contains(id));
1994 self.terminal_last_accessed
1995 .retain(|id, _| current_terminal_ids.contains(id));
1996
1997 self.live_thread_statuses = new_live_statuses;
1998
1999 self.contents = SidebarContents {
2000 entries,
2001 notified_threads,
2002 notified_terminals,
2003 project_header_indices,
2004 has_open_projects,
2005 };
2006 }
2007
2008 fn schedule_update_entries(&mut self, select_first_after_update: bool, cx: &mut Context<Self>) {
2009 if self.update_task.is_some() && !select_first_after_update {
2010 return;
2011 }
2012
2013 self.update_task = Some(cx.spawn(async move |this, cx| {
2014 this.update(cx, |this, cx| {
2015 this.update_task = None;
2016 this.update_entries(cx);
2017 if select_first_after_update {
2018 this.select_first_entry();
2019 cx.notify();
2020 }
2021 })
2022 .ok();
2023 }));
2024 }
2025
2026 /// Rebuilds the sidebar's visible entries from already-cached state.
2027 fn update_entries(&mut self, cx: &mut Context<Self>) {
2028 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2029 return;
2030 };
2031 if !multi_workspace.read(cx).multi_workspace_enabled(cx) {
2032 return;
2033 }
2034
2035 let had_notifications = self.has_notifications(cx);
2036 let previous_shapes: Vec<EntryShape> =
2037 self.entry_shapes(multi_workspace.read(cx)).collect();
2038
2039 self.rebuild_contents(cx);
2040 self.refresh_refilled_draft_times(cx);
2041 self.refresh_draft_editor_observations(cx);
2042
2043 // Preserve measurements for unchanged entries so sticky headers do not flicker.
2044 self.apply_list_state_diff(&previous_shapes, multi_workspace.read(cx));
2045
2046 self.prefetch_worktree_default_branches(cx);
2047
2048 if had_notifications != self.has_notifications(cx) {
2049 multi_workspace.update(cx, |_, cx| {
2050 cx.notify();
2051 });
2052 }
2053
2054 cx.notify();
2055 }
2056
2057 /// Splices only the changed entry range, leaving unchanged item measurements intact.
2058 fn apply_list_state_diff(
2059 &self,
2060 previous_shapes: &[EntryShape],
2061 multi_workspace: &MultiWorkspace,
2062 ) {
2063 let mut new_iter = self.entry_shapes(multi_workspace);
2064 let mut prefix_len = 0;
2065 let leading_new = loop {
2066 match (previous_shapes.get(prefix_len), new_iter.next()) {
2067 (Some(prev), Some(next)) if *prev == next => prefix_len += 1,
2068 (None, None) => return,
2069 (_, leading) => break leading,
2070 }
2071 };
2072
2073 let new_tail: Vec<EntryShape> = leading_new.into_iter().chain(new_iter).collect();
2074 let prev_tail = &previous_shapes[prefix_len..];
2075 let suffix_len = prev_tail
2076 .iter()
2077 .rev()
2078 .zip(new_tail.iter().rev())
2079 .take_while(|(prev, next)| prev == next)
2080 .count();
2081
2082 let old_changed = prefix_len..previous_shapes.len() - suffix_len;
2083 let new_changed_count = new_tail.len() - suffix_len;
2084 self.list_state.splice(old_changed, new_changed_count);
2085 }
2086
2087 fn entry_shapes<'a>(
2088 &'a self,
2089 multi_workspace: &'a MultiWorkspace,
2090 ) -> impl Iterator<Item = EntryShape> + 'a {
2091 self.contents.entries.iter().map(move |entry| match entry {
2092 ListEntry::ProjectHeader {
2093 key, has_threads, ..
2094 } => EntryShape::ProjectHeader {
2095 key: key.clone(),
2096 has_threads: *has_threads,
2097 is_collapsed: multi_workspace
2098 .group_state_by_key(key)
2099 .map(|state| !state.expanded)
2100 .unwrap_or(false),
2101 },
2102 ListEntry::Thread(thread) => EntryShape::Thread(thread.metadata.thread_id),
2103 ListEntry::Terminal(terminal) => EntryShape::Terminal(terminal.metadata.terminal_id),
2104 })
2105 }
2106
2107 /// Detects drafts that just went from empty back to having content and
2108 /// refreshes their interaction time to now, so a re-filled draft sorts to
2109 /// the top of the list instead of falling back to its original creation time.
2110 fn refresh_refilled_draft_times(&mut self, cx: &mut Context<Self>) {
2111 let mut new_kinds: HashMap<ThreadId, DraftKind> = HashMap::new();
2112 let mut refilled: Vec<ThreadId> = Vec::new();
2113
2114 for entry in &self.contents.entries {
2115 let ListEntry::Thread(thread) = entry else {
2116 continue;
2117 };
2118 let Some(kind) = thread.draft else {
2119 continue;
2120 };
2121 let thread_id = thread.metadata.thread_id;
2122
2123 if kind == DraftKind::WithContent
2124 && self.draft_kinds.get(&thread_id) == Some(&DraftKind::Empty)
2125 {
2126 refilled.push(thread_id);
2127 }
2128 new_kinds.insert(thread_id, kind);
2129 }
2130 self.draft_kinds = new_kinds;
2131
2132 if refilled.is_empty() {
2133 return;
2134 }
2135
2136 let now = Utc::now();
2137
2138 ThreadMetadataStore::global(cx).update(cx, |store, store_cx| {
2139 for thread_id in refilled {
2140 store.update_interacted_at(&thread_id, now, store_cx);
2141 }
2142 });
2143 }
2144
2145 /// Re-establishes subscriptions to each visible draft's message editor
2146 /// so we rebuild entries (and their displayed titles) as the user types.
2147 fn refresh_draft_editor_observations(&mut self, cx: &mut Context<Self>) {
2148 self._draft_editor_observations.clear();
2149 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2150 return;
2151 };
2152
2153 let draft_conversation_views: Vec<Entity<agent_ui::ConversationView>> = multi_workspace
2154 .read(cx)
2155 .workspaces()
2156 .filter_map(|ws| ws.read(cx).panel::<AgentPanel>(cx))
2157 .flat_map(|panel| panel.read(cx).conversation_views())
2158 .collect();
2159
2160 for cv in draft_conversation_views {
2161 if let Some(thread_view) = cv.read(cx).active_thread() {
2162 let editor = thread_view.read(cx).message_editor.clone();
2163 self._draft_editor_observations.push(cx.subscribe(
2164 &editor,
2165 |this, _editor, event, cx| match event {
2166 MessageEditorEvent::Edited => this.schedule_update_entries(false, cx),
2167 _ => (),
2168 },
2169 ));
2170 }
2171 // Also subscribe to the ConversationView itself so that editor
2172 // replacements during lifecycle transitions (Loading →
2173 // Connected) re-wire the editor observation above.
2174 self._draft_editor_observations.push(cx.subscribe(
2175 &cv,
2176 |this, _cv, _event: &StateChange, cx| {
2177 this.schedule_update_entries(false, cx);
2178 },
2179 ));
2180 }
2181 }
2182
2183 fn select_first_entry(&mut self) {
2184 self.selection = self
2185 .contents
2186 .entries
2187 .iter()
2188 .position(|entry| matches!(entry, ListEntry::Thread(_) | ListEntry::Terminal(_)))
2189 .or_else(|| {
2190 if self.contents.entries.is_empty() {
2191 None
2192 } else {
2193 Some(0)
2194 }
2195 });
2196 }
2197
2198 fn render_list_entry(
2199 &mut self,
2200 ix: usize,
2201 window: &mut Window,
2202 cx: &mut Context<Self>,
2203 ) -> AnyElement {
2204 let Some(entry) = self.contents.entries.get(ix) else {
2205 return div().into_any_element();
2206 };
2207 let is_focused = self.focus_handle.is_focused(window);
2208 // is_selected means the keyboard selector is here.
2209 let is_selected = is_focused && self.selection == Some(ix);
2210
2211 let is_group_header_after_first =
2212 ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. });
2213
2214 let is_active = self
2215 .active_entry
2216 .as_ref()
2217 .is_some_and(|active| active.matches_entry(entry));
2218
2219 let rendered = match entry {
2220 ListEntry::ProjectHeader {
2221 key,
2222 label,
2223 highlight_positions,
2224 has_running_threads,
2225 waiting_thread_count,
2226 has_notifications,
2227 is_active: is_active_group,
2228 has_threads,
2229 } => {
2230 self.project_header_menu_handles.entry(ix).or_default();
2231 self.project_header_new_thread_menu_handles
2232 .entry(ix)
2233 .or_default();
2234
2235 self.render_project_header(
2236 ix,
2237 false,
2238 key,
2239 label,
2240 highlight_positions,
2241 *has_running_threads,
2242 *waiting_thread_count,
2243 *has_notifications,
2244 *is_active_group,
2245 is_selected,
2246 *has_threads,
2247 // has_active_draft,
2248 cx,
2249 )
2250 }
2251 ListEntry::Thread(thread) => self.render_thread(ix, thread, is_active, is_selected, cx),
2252 ListEntry::Terminal(terminal) => {
2253 self.render_terminal(ix, terminal, is_active, is_selected, cx)
2254 }
2255 };
2256
2257 if is_group_header_after_first {
2258 v_flex()
2259 .w_full()
2260 .border_t_1()
2261 .border_color(cx.theme().colors().border)
2262 .child(rendered)
2263 .into_any_element()
2264 } else {
2265 rendered
2266 }
2267 }
2268
2269 fn render_remote_project_icon(
2270 &self,
2271 ix: usize,
2272 host: Option<&RemoteConnectionOptions>,
2273 ) -> Option<AnyElement> {
2274 let remote_icon_per_type = match host? {
2275 RemoteConnectionOptions::Wsl(_) => IconName::Linux,
2276 RemoteConnectionOptions::Docker(_) => IconName::Box,
2277 _ => IconName::Server,
2278 };
2279
2280 Some(
2281 div()
2282 .id(format!("remote-project-icon-{}", ix))
2283 .child(
2284 Icon::new(remote_icon_per_type)
2285 .size(IconSize::XSmall)
2286 .color(Color::Muted),
2287 )
2288 .tooltip(Tooltip::text("Remote Project"))
2289 .into_any_element(),
2290 )
2291 }
2292
2293 fn render_project_header(
2294 &self,
2295 ix: usize,
2296 is_sticky: bool,
2297 key: &ProjectGroupKey,
2298 label: &SharedString,
2299 highlight_positions: &[usize],
2300 has_running_threads: bool,
2301 waiting_thread_count: usize,
2302 has_notifications: bool,
2303 is_active: bool,
2304 is_focused: bool,
2305 has_threads: bool,
2306 cx: &mut Context<Self>,
2307 ) -> AnyElement {
2308 let host = key.host();
2309
2310 let has_filter = self.has_filter_query(cx);
2311
2312 let id_prefix = if is_sticky { "sticky-" } else { "" };
2313 let id = SharedString::from(format!("{id_prefix}project-header-{ix}"));
2314 let group_name = SharedString::from(format!("{id_prefix}header-group-{ix}"));
2315
2316 let is_collapsed = self.is_group_collapsed(key, cx);
2317 let disclosure_icon = if is_collapsed {
2318 IconName::ChevronRight
2319 } else {
2320 IconName::ChevronDown
2321 };
2322
2323 let key_for_toggle = key.clone();
2324 let key_for_focus = key.clone();
2325
2326 // The fade gradient renders as a visible patch on transparent windows,
2327 // so truncate the label instead.
2328 let opaque_window =
2329 cx.theme().window_background_appearance() == WindowBackgroundAppearance::Opaque;
2330
2331 let label = if highlight_positions.is_empty() {
2332 Label::new(label.clone())
2333 .when(!is_active, |this| this.color(Color::Muted))
2334 .when(!opaque_window, |this| this.truncate())
2335 .into_any_element()
2336 } else {
2337 HighlightedLabel::new(label.clone(), highlight_positions.to_vec())
2338 .when(!is_active, |this| this.color(Color::Muted))
2339 .when(!opaque_window, |this| this.truncate())
2340 .into_any_element()
2341 };
2342
2343 let color = cx.theme().colors();
2344 let sidebar_base_bg = color
2345 .title_bar_background
2346 .blend(color.panel_background.opacity(0.25));
2347
2348 let base_bg = color.background.blend(sidebar_base_bg);
2349
2350 let hover_base = color
2351 .element_active
2352 .blend(color.element_background.opacity(0.2));
2353 let hover_solid = base_bg.blend(hover_base);
2354
2355 let group_name_for_gradient = group_name.clone();
2356 let gradient_overlay = move || {
2357 GradientFade::new(base_bg, hover_solid, hover_solid)
2358 .width(px(92.0))
2359 .right(px(-2.0))
2360 .gradient_stop(0.7)
2361 .when(!has_filter, |this| {
2362 this.group_name(group_name_for_gradient.clone())
2363 })
2364 };
2365
2366 let header = h_flex()
2367 .id(id)
2368 .group(&group_name)
2369 .when(!has_filter, |this| this.cursor_pointer())
2370 .relative()
2371 .h(Tab::content_height(cx))
2372 .w_full()
2373 .pl_2()
2374 .pr_1p5()
2375 .justify_between()
2376 .border_1()
2377 .map(|this| {
2378 if is_focused {
2379 this.border_color(color.border_focused)
2380 } else {
2381 this.border_color(gpui::transparent_black())
2382 }
2383 })
2384 .when(!has_filter, |this| this.hover(|s| s.bg(hover_solid)))
2385 .child(
2386 h_flex()
2387 .relative()
2388 .min_w_0()
2389 .w_full()
2390 .gap_1()
2391 .child(label)
2392 .when_some(
2393 self.render_remote_project_icon(ix, host.as_ref()),
2394 |this, icon| this.child(icon),
2395 )
2396 .when(is_collapsed, |this| {
2397 this.when(has_running_threads, |this| {
2398 this.child(
2399 Icon::new(IconName::LoadCircle)
2400 .size(IconSize::XSmall)
2401 .color(Color::Muted)
2402 .with_rotate_animation(2),
2403 )
2404 })
2405 .when(waiting_thread_count > 0, |this| {
2406 let tooltip_text = if waiting_thread_count == 1 {
2407 "1 thread is waiting for confirmation".to_string()
2408 } else {
2409 format!(
2410 "{waiting_thread_count} threads are waiting for confirmation",
2411 )
2412 };
2413 this.child(
2414 div()
2415 .id(format!("{id_prefix}waiting-indicator-{ix}"))
2416 .child(
2417 Icon::new(IconName::Warning)
2418 .size(IconSize::XSmall)
2419 .color(Color::Warning),
2420 )
2421 .tooltip(Tooltip::text(tooltip_text)),
2422 )
2423 })
2424 .when(
2425 has_notifications && !has_running_threads && waiting_thread_count == 0,
2426 |this| {
2427 this.child(
2428 Icon::new(IconName::Circle)
2429 .size(IconSize::Small)
2430 .color(Color::Accent),
2431 )
2432 },
2433 )
2434 })
2435 .when(!has_filter, |this| {
2436 this.child(
2437 div()
2438 .when(!is_focused, |this| this.visible_on_hover(&group_name))
2439 .child(
2440 Icon::new(disclosure_icon)
2441 .size(IconSize::Small)
2442 .color(Color::Muted),
2443 ),
2444 )
2445 }),
2446 )
2447 .children(opaque_window.then(|| gradient_overlay()))
2448 .child(
2449 h_flex()
2450 .gap_px()
2451 .pr_1p5()
2452 .children(opaque_window.then(|| gradient_overlay()))
2453 .child(self.render_new_thread_button(ix, id_prefix, key, &group_name, cx))
2454 .child(self.render_project_header_ellipsis_menu(
2455 ix,
2456 id_prefix,
2457 key,
2458 is_active,
2459 has_threads,
2460 &group_name,
2461 cx,
2462 ))
2463 .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
2464 cx.stop_propagation();
2465 }),
2466 )
2467 .on_mouse_down(gpui::MouseButton::Right, {
2468 let menu_handle = self
2469 .project_header_menu_handles
2470 .get(&ix)
2471 .cloned()
2472 .unwrap_or_default();
2473 move |_, window, cx| {
2474 cx.stop_propagation();
2475 menu_handle.toggle(window, cx);
2476 }
2477 })
2478 .on_click(
2479 cx.listener(move |this, event: &gpui::ClickEvent, window, cx| {
2480 if event.modifiers().secondary() {
2481 this.activate_or_open_workspace_for_group(&key_for_focus, window, cx);
2482 } else if !this.has_filter_query(cx) {
2483 this.toggle_collapse(&key_for_toggle, window, cx);
2484 }
2485 }),
2486 )
2487 .block_mouse_except_scroll();
2488
2489 if !is_collapsed && !has_threads {
2490 v_flex()
2491 .w_full()
2492 .child(header)
2493 .child(
2494 h_flex()
2495 .px_2()
2496 .pt_1()
2497 .pb_2()
2498 .gap(px(7.))
2499 .child(Icon::new(IconName::Circle).size(IconSize::Small).color(
2500 Color::Custom(cx.theme().colors().icon_placeholder.opacity(0.1)),
2501 ))
2502 .child(
2503 Label::new("No threads yet")
2504 .size(LabelSize::Small)
2505 .color(Color::Placeholder),
2506 ),
2507 )
2508 .into_any_element()
2509 } else {
2510 header.into_any_element()
2511 }
2512 }
2513
2514 fn render_new_thread_button(
2515 &self,
2516 ix: usize,
2517 id_prefix: &str,
2518 key: &ProjectGroupKey,
2519 group_name: &SharedString,
2520 cx: &mut Context<Self>,
2521 ) -> AnyElement {
2522 let focus_handle = self.focus_handle.clone();
2523
2524 let menu_handle = self
2525 .project_header_new_thread_menu_handles
2526 .get(&ix)
2527 .cloned()
2528 .unwrap_or_default();
2529 let is_menu_open = menu_handle.is_deployed();
2530
2531 let button = IconButton::new(
2532 SharedString::from(format!("{id_prefix}project-header-new-thread-{ix}")),
2533 IconName::Plus,
2534 )
2535 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
2536 .icon_size(IconSize::Small)
2537 .when(!is_menu_open, |this| this.visible_on_hover(group_name));
2538
2539 let open_workspaces = self
2540 .multi_workspace
2541 .upgrade()
2542 .and_then(|mw| mw.read(cx).workspaces_for_project_group(key, cx))
2543 .unwrap_or_default();
2544
2545 if open_workspaces.is_empty() {
2546 let key = key.clone();
2547 return button
2548 .tooltip(move |_, cx| {
2549 Tooltip::for_action_in("Start New Agent Thread", &NewThread, &focus_handle, cx)
2550 })
2551 .on_click(cx.listener(move |this, _, window, cx| {
2552 this.set_group_expanded(&key, true, cx);
2553 this.selection = None;
2554 if let Some(workspace) = this.workspace_for_group(&key, cx) {
2555 this.create_new_entry(&workspace, window, cx);
2556 } else {
2557 this.open_workspace_and_create_entry(
2558 &key,
2559 NewEntryTarget::LastCreatedKind,
2560 window,
2561 cx,
2562 );
2563 }
2564 }))
2565 .into_any_element();
2566 }
2567
2568 let this = cx.weak_entity();
2569 let key = key.clone();
2570
2571 PopoverMenu::new(SharedString::from(format!(
2572 "{id_prefix}project-header-new-thread-menu-{ix}"
2573 )))
2574 .with_handle(menu_handle)
2575 .trigger_with_tooltip(button, move |_, cx| {
2576 Tooltip::for_action_in("Start New Agent Thread", &NewThread, &focus_handle, cx)
2577 })
2578 .anchor(gpui::Anchor::TopLeft)
2579 .on_open(Rc::new({
2580 let this = this.clone();
2581 move |_window, cx| {
2582 this.update(cx, |_sidebar, cx| cx.notify()).ok();
2583 }
2584 }))
2585 .menu(move |window, cx| {
2586 let this = this.clone();
2587 let key = key.clone();
2588 let open_workspaces = open_workspaces.clone();
2589 let active_workspace = this
2590 .read_with(cx, |sidebar, cx| {
2591 sidebar
2592 .multi_workspace
2593 .upgrade()
2594 .map(|mw| mw.read(cx).workspace().clone())
2595 })
2596 .ok()
2597 .flatten();
2598 let workspace_labels: Vec<_> = open_workspaces
2599 .iter()
2600 .map(|workspace| workspace_menu_worktree_labels(workspace, cx))
2601 .collect();
2602
2603 Some(ContextMenu::build(
2604 window,
2605 cx,
2606 move |mut menu, _window, cx| {
2607 menu = menu.header("New Thread In…");
2608
2609 for (workspace, labels) in open_workspaces
2610 .iter()
2611 .cloned()
2612 .zip(workspace_labels.iter().cloned())
2613 {
2614 let is_active_workspace = active_workspace.as_ref() == Some(&workspace);
2615 menu = menu.custom_entry(
2616 move |_window, _cx| {
2617 h_flex()
2618 .w_full()
2619 .gap_2()
2620 .justify_between()
2621 .child(h_flex().min_w_0().gap_1().children(
2622 labels.iter().enumerate().map(|(label_ix, label)| {
2623 h_flex()
2624 .gap_1()
2625 .when(label_ix > 0, |this| {
2626 this.child(Label::new("•").alpha(0.25))
2627 })
2628 .child(label.render())
2629 .into_any_element()
2630 }),
2631 ))
2632 .when(is_active_workspace, |this| {
2633 this.child(
2634 Icon::new(IconName::Check)
2635 .size(IconSize::Small)
2636 .color(Color::Accent),
2637 )
2638 })
2639 .into_any_element()
2640 },
2641 {
2642 let this = this.clone();
2643 let key = key.clone();
2644 let workspace = workspace.clone();
2645 move |window, cx| {
2646 this.update(cx, |sidebar, cx| {
2647 sidebar.set_group_expanded(&key, true, cx);
2648 sidebar.selection = None;
2649 sidebar.create_new_entry(&workspace, window, cx);
2650 })
2651 .ok();
2652 }
2653 },
2654 );
2655 }
2656
2657 let base_workspace = active_workspace
2658 .as_ref()
2659 .filter(|workspace| open_workspaces.contains(workspace))
2660 .cloned()
2661 .or_else(|| open_workspaces.first().cloned());
2662
2663 // Only offer worktree creation when the base project can
2664 // actually create one; otherwise the submenu would expand to
2665 // nothing. Mirrors the picker's `creation_blocked_reason`.
2666 let creation_blocked = base_workspace.as_ref().is_none_or(|base_workspace| {
2667 let project = base_workspace.read(cx).project().read(cx);
2668 project.is_via_collab() || project.repositories(cx).is_empty()
2669 });
2670
2671 if let Some(base_workspace) = base_workspace.filter(|_| !creation_blocked) {
2672 menu = menu.separator().submenu("Create New Worktree…", {
2673 let this = this.clone();
2674 move |mut submenu, _window, submenu_cx| {
2675 let project = base_workspace.read(submenu_cx).project().clone();
2676 let project_ref = project.read(submenu_cx);
2677 let has_multiple_repositories =
2678 project_ref.repositories(submenu_cx).len() > 1;
2679 let current_branch =
2680 project_ref.active_repository(submenu_cx).and_then(|repo| {
2681 repo.read(submenu_cx)
2682 .branch
2683 .as_ref()
2684 .map(|branch| branch.name().to_string())
2685 });
2686 let default_branch = this
2687 .read_with(submenu_cx, |sidebar, _| {
2688 match sidebar.worktree_default_branches.get(&key) {
2689 Some(DefaultBranchCache::Resolved(branch)) => {
2690 branch.clone()
2691 }
2692 _ => None,
2693 }
2694 })
2695 .ok()
2696 .flatten();
2697
2698 let targets = worktree_create_targets(
2699 has_multiple_repositories,
2700 default_branch,
2701 current_branch.as_deref(),
2702 );
2703 for target in targets {
2704 let label = format!(
2705 "Based on {}",
2706 target.branch_label(
2707 has_multiple_repositories,
2708 current_branch.as_deref(),
2709 )
2710 );
2711 let branch_target = target.branch_target();
2712 let workspace = base_workspace.clone();
2713 submenu = submenu.entry(label, None, move |window, cx| {
2714 create_worktree_in_workspace(
2715 &workspace,
2716 branch_target.clone(),
2717 window,
2718 cx,
2719 );
2720 });
2721 }
2722
2723 submenu
2724 }
2725 });
2726 }
2727
2728 menu
2729 },
2730 ))
2731 })
2732 .anchor(gpui::Anchor::TopRight)
2733 .offset(gpui::Point {
2734 x: px(0.),
2735 y: px(1.),
2736 })
2737 .into_any_element()
2738 }
2739
2740 // Warms `worktree_default_branches` for every project group with at least one
2741 // open workspace. The git query runs off the menu path so the submenu can read
2742 // the result synchronously when it opens. Worktrees of a repository share the
2743 // same default branch, so any workspace in the group yields the same answer.
2744 fn prefetch_worktree_default_branches(&mut self, cx: &mut Context<Self>) {
2745 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
2746 return;
2747 };
2748 let keys: Vec<ProjectGroupKey> = self
2749 .contents
2750 .entries
2751 .iter()
2752 .filter_map(|entry| match entry {
2753 ListEntry::ProjectHeader { key, .. } => Some(key.clone()),
2754 _ => None,
2755 })
2756 .collect();
2757 for key in keys {
2758 if self.worktree_default_branches.contains_key(&key) {
2759 continue;
2760 }
2761 let Some(base) = multi_workspace
2762 .read(cx)
2763 .workspaces_for_project_group(&key, cx)
2764 .and_then(|workspaces| workspaces.first().cloned())
2765 else {
2766 continue;
2767 };
2768 self.prefetch_worktree_default_branch(&key, &base, cx);
2769 }
2770 }
2771
2772 fn prefetch_worktree_default_branch(
2773 &mut self,
2774 key: &ProjectGroupKey,
2775 workspace: &Entity<Workspace>,
2776 cx: &mut Context<Self>,
2777 ) {
2778 // Presence of the key means the group is already pending or resolved. The
2779 // no-repository case is deliberately not inserted so it retries on a
2780 // later rebuild once the repository has finished loading.
2781 if self.worktree_default_branches.contains_key(key) {
2782 return;
2783 }
2784 let Some(repository) = workspace.read(cx).project().read(cx).active_repository(cx) else {
2785 return;
2786 };
2787 let request = repository.update(cx, |repository, _| repository.default_branch(true));
2788 self.worktree_default_branches
2789 .insert(key.clone(), DefaultBranchCache::Pending);
2790 let key = key.clone();
2791 cx.spawn(async move |this, cx| {
2792 let default_branch = request.await.ok().and_then(Result::ok).flatten();
2793 let parsed = default_branch.as_deref().and_then(RemoteBranchName::parse);
2794 this.update(cx, |sidebar, cx| {
2795 sidebar
2796 .worktree_default_branches
2797 .insert(key, DefaultBranchCache::Resolved(parsed));
2798 cx.notify();
2799 })
2800 .ok();
2801 })
2802 .detach();
2803 }
2804
2805 fn render_project_header_ellipsis_menu(
2806 &self,
2807 ix: usize,
2808 id_prefix: &str,
2809 project_group_key: &ProjectGroupKey,
2810 is_active: bool,
2811 has_threads: bool,
2812 group_name: &SharedString,
2813 cx: &mut Context<Self>,
2814 ) -> AnyElement {
2815 let multi_workspace = self.multi_workspace.clone();
2816 let project_group_key = project_group_key.clone();
2817
2818 let show_multi_project_entries = multi_workspace
2819 .read_with(cx, |mw, _| {
2820 project_group_key.host().is_none() && mw.project_group_keys().len() >= 2
2821 })
2822 .unwrap_or(false);
2823
2824 let this = cx.weak_entity();
2825
2826 let trigger_id = SharedString::from(format!("{id_prefix}-ellipsis-menu-{ix}"));
2827 let menu_handle = self
2828 .project_header_menu_handles
2829 .get(&ix)
2830 .cloned()
2831 .unwrap_or_default();
2832 let is_menu_open = menu_handle.is_deployed();
2833
2834 PopoverMenu::new(format!("{id_prefix}project-header-menu-{ix}"))
2835 .with_handle(menu_handle)
2836 .trigger(
2837 IconButton::new(trigger_id, IconName::Ellipsis)
2838 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
2839 .icon_size(IconSize::Small)
2840 .when(!is_menu_open, |el| el.visible_on_hover(group_name)),
2841 )
2842 .on_open(Rc::new({
2843 let this = this.clone();
2844 move |_window, cx| {
2845 this.update(cx, |sidebar, cx| {
2846 sidebar.project_header_menu_ix = Some(ix);
2847 cx.notify();
2848 })
2849 .ok();
2850 }
2851 }))
2852 .menu(move |window, cx| {
2853 let multi_workspace = multi_workspace.clone();
2854 let project_group_key = project_group_key.clone();
2855 let this_for_menu = this.clone();
2856
2857 let open_workspaces = multi_workspace
2858 .read_with(cx, |multi_workspace, cx| {
2859 multi_workspace
2860 .workspaces_for_project_group(&project_group_key, cx)
2861 .unwrap_or_default()
2862 })
2863 .unwrap_or_default();
2864
2865 // Compute reorder state at menu-open time so it reflects the
2866 // most recent group ordering.
2867 let (group_index, total_groups) = multi_workspace
2868 .read_with(cx, |mw, _| {
2869 let keys = mw.project_group_keys();
2870 let index = keys.iter().position(|k| k == &project_group_key);
2871 (index, keys.len())
2872 })
2873 .unwrap_or((None, 0));
2874 let show_reorder_entries = total_groups >= 2;
2875 let can_move_up = group_index.is_some_and(|i| i > 0);
2876 let can_move_down = group_index.is_some_and(|i| i + 1 < total_groups);
2877
2878 let active_workspace = multi_workspace
2879 .read_with(cx, |multi_workspace, _cx| {
2880 multi_workspace.workspace().clone()
2881 })
2882 .ok();
2883 let workspace_labels: Vec<_> = open_workspaces
2884 .iter()
2885 .map(|workspace| workspace_menu_worktree_labels(workspace, cx))
2886 .collect();
2887 let workspace_is_active: Vec<_> = open_workspaces
2888 .iter()
2889 .map(|workspace| active_workspace.as_ref() == Some(workspace))
2890 .collect();
2891
2892 let menu =
2893 ContextMenu::build_persistent(window, cx, move |menu, _window, menu_cx| {
2894 let menu = menu.end_slot_action(Box::new(menu::SecondaryConfirm));
2895 let weak_menu = menu_cx.weak_entity();
2896
2897 let menu = menu.when(show_multi_project_entries, |this| {
2898 this.entry(
2899 "Open Project in New Window",
2900 Some(Box::new(workspace::MoveProjectToNewWindow)),
2901 {
2902 let project_group_key = project_group_key.clone();
2903 let multi_workspace = multi_workspace.clone();
2904 move |window, cx| {
2905 multi_workspace
2906 .update(cx, |multi_workspace, cx| {
2907 multi_workspace
2908 .open_project_group_in_new_window(
2909 &project_group_key,
2910 window,
2911 cx,
2912 )
2913 .detach_and_log_err(cx);
2914 })
2915 .ok();
2916 }
2917 },
2918 )
2919 });
2920
2921 let menu = menu
2922 .custom_entry(
2923 {
2924 move |_window, cx| {
2925 let action = h_flex()
2926 .opacity(0.6)
2927 .children(render_modifiers(
2928 &Modifiers::secondary_key(),
2929 PlatformStyle::platform(),
2930 None,
2931 Some(TextSize::Default.rems(cx).into()),
2932 false,
2933 ))
2934 .child(Label::new("-click").color(Color::Muted));
2935
2936 let label = if has_threads {
2937 "Focus Last Project"
2938 } else {
2939 "Focus Project"
2940 };
2941
2942 h_flex()
2943 .w_full()
2944 .justify_between()
2945 .gap_4()
2946 .child(
2947 Label::new(label)
2948 .when(is_active, |s| s.color(Color::Disabled)),
2949 )
2950 .child(action)
2951 .into_any_element()
2952 }
2953 },
2954 {
2955 let project_group_key = project_group_key.clone();
2956 let this = this_for_menu.clone();
2957 move |window, cx| {
2958 if is_active {
2959 return;
2960 }
2961 this.update(cx, |sidebar, cx| {
2962 if let Some(workspace) =
2963 sidebar.workspace_for_group(&project_group_key, cx)
2964 {
2965 sidebar.activate_workspace(&workspace, window, cx);
2966 } else {
2967 sidebar.open_workspace_for_group(
2968 &project_group_key,
2969 window,
2970 cx,
2971 );
2972 }
2973 sidebar.selection = None;
2974 sidebar.active_entry = None;
2975 })
2976 .ok();
2977 }
2978 },
2979 )
2980 .selectable(!is_active);
2981
2982 let menu = if open_workspaces.is_empty() {
2983 menu
2984 } else {
2985 let mut menu = menu.separator().header("Open Worktrees");
2986
2987 for (
2988 workspace_index,
2989 ((workspace, workspace_label), is_active_workspace),
2990 ) in open_workspaces
2991 .iter()
2992 .cloned()
2993 .zip(workspace_labels.iter().cloned())
2994 .zip(workspace_is_active.iter().copied())
2995 .enumerate()
2996 {
2997 let activate_multi_workspace = multi_workspace.clone();
2998 let close_multi_workspace = multi_workspace.clone();
2999 let activate_weak_menu = weak_menu.clone();
3000 let close_weak_menu = weak_menu.clone();
3001 let activate_workspace = workspace.clone();
3002 let close_workspace = workspace.clone();
3003
3004 menu = menu.custom_entry(
3005 move |_window, _cx| {
3006 let close_multi_workspace = close_multi_workspace.clone();
3007 let close_weak_menu = close_weak_menu.clone();
3008 let close_workspace = close_workspace.clone();
3009 let row_group_name = SharedString::from(format!(
3010 "workspace-menu-row-{workspace_index}"
3011 ));
3012
3013 h_flex()
3014 .group(&row_group_name)
3015 .w_full()
3016 .gap_2()
3017 .justify_between()
3018 .child(h_flex().min_w_0().gap_1().children(
3019 workspace_label.iter().enumerate().map(
3020 |(label_ix, label)| {
3021 h_flex()
3022 .gap_1()
3023 .when(label_ix > 0, |this| {
3024 this.child(
3025 Label::new("•").alpha(0.25),
3026 )
3027 })
3028 .child(label.render())
3029 .into_any_element()
3030 },
3031 ),
3032 ))
3033 .when(is_active_workspace, |this| {
3034 this.pr_1().child(
3035 Icon::new(IconName::Check)
3036 .size(IconSize::Small)
3037 .color(Color::Accent),
3038 )
3039 })
3040 .when(!is_active_workspace, |this| {
3041 let close_multi_workspace =
3042 close_multi_workspace.clone();
3043 let close_weak_menu = close_weak_menu.clone();
3044 let close_workspace = close_workspace.clone();
3045
3046 this.child(
3047 IconButton::new(
3048 ("close-workspace", workspace_index),
3049 IconName::Close,
3050 )
3051 .icon_size(IconSize::Small)
3052 .visible_on_hover(&row_group_name)
3053 .tooltip(Tooltip::text("Close Worktree"))
3054 .on_click(move |_, window, cx| {
3055 cx.stop_propagation();
3056 window.prevent_default();
3057 close_multi_workspace
3058 .update(cx, |multi_workspace, cx| {
3059 multi_workspace
3060 .close_workspace(
3061 &close_workspace,
3062 window,
3063 cx,
3064 )
3065 .detach_and_log_err(cx);
3066 })
3067 .ok();
3068 close_weak_menu
3069 .update(cx, |_, cx| {
3070 cx.emit(DismissEvent)
3071 })
3072 .ok();
3073 }),
3074 )
3075 })
3076 .into_any_element()
3077 },
3078 move |window, cx| {
3079 activate_multi_workspace
3080 .update(cx, |multi_workspace, cx| {
3081 multi_workspace.activate(
3082 activate_workspace.clone(),
3083 None,
3084 window,
3085 cx,
3086 );
3087 })
3088 .ok();
3089 activate_weak_menu
3090 .update(cx, |_, cx| cx.emit(DismissEvent))
3091 .ok();
3092 },
3093 );
3094 }
3095
3096 menu
3097 };
3098
3099 let menu = menu.when(show_reorder_entries, |this| {
3100 let move_up_multi_workspace = multi_workspace.clone();
3101 let move_up_key = project_group_key.clone();
3102 let move_up_weak_menu = weak_menu.clone();
3103 let move_down_multi_workspace = multi_workspace.clone();
3104 let move_down_key = project_group_key.clone();
3105 let move_down_weak_menu = weak_menu.clone();
3106
3107 this.separator()
3108 .item(
3109 ContextMenuEntry::new("Move Up")
3110 .disabled(!can_move_up)
3111 .handler(move |_window, cx| {
3112 move_up_multi_workspace
3113 .update(cx, |mw, cx| {
3114 mw.move_project_group_up(&move_up_key, cx);
3115 })
3116 .ok();
3117 move_up_weak_menu
3118 .update(cx, |_, cx| cx.emit(DismissEvent))
3119 .ok();
3120 }),
3121 )
3122 .item(
3123 ContextMenuEntry::new("Move Down")
3124 .disabled(!can_move_down)
3125 .handler(move |_window, cx| {
3126 move_down_multi_workspace
3127 .update(cx, |mw, cx| {
3128 mw.move_project_group_down(&move_down_key, cx);
3129 })
3130 .ok();
3131 move_down_weak_menu
3132 .update(cx, |_, cx| cx.emit(DismissEvent))
3133 .ok();
3134 }),
3135 )
3136 });
3137
3138 let project_group_key = project_group_key.clone();
3139 let remove_multi_workspace = multi_workspace.clone();
3140 menu.separator().entry("Remove", None, move |window, cx| {
3141 remove_multi_workspace
3142 .update(cx, |multi_workspace, cx| {
3143 multi_workspace
3144 .remove_project_group(&project_group_key, window, cx)
3145 .detach_and_log_err(cx);
3146 })
3147 .ok();
3148 weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
3149 })
3150 });
3151
3152 let this = this.clone();
3153
3154 window
3155 .subscribe(&menu, cx, move |_, _: &gpui::DismissEvent, _window, cx| {
3156 this.update(cx, |sidebar, cx| {
3157 sidebar.project_header_menu_ix = None;
3158 cx.notify();
3159 })
3160 .ok();
3161 })
3162 .detach();
3163
3164 Some(menu)
3165 })
3166 .anchor(gpui::Anchor::TopRight)
3167 .offset(gpui::Point {
3168 x: px(0.),
3169 y: px(1.),
3170 })
3171 .into_any_element()
3172 }
3173
3174 fn render_sticky_header(
3175 &self,
3176 window: &mut Window,
3177 cx: &mut Context<Self>,
3178 ) -> Option<AnyElement> {
3179 let scroll_top = self.list_state.logical_scroll_top();
3180
3181 let &header_idx = self
3182 .contents
3183 .project_header_indices
3184 .iter()
3185 .rev()
3186 .find(|&&idx| idx <= scroll_top.item_ix)?;
3187
3188 let needs_sticky = header_idx < scroll_top.item_ix
3189 || (header_idx == scroll_top.item_ix && scroll_top.offset_in_item > px(0.));
3190
3191 if !needs_sticky {
3192 return None;
3193 }
3194
3195 let ListEntry::ProjectHeader {
3196 key,
3197 label,
3198 highlight_positions,
3199 has_running_threads,
3200 waiting_thread_count,
3201 has_notifications,
3202 is_active,
3203 has_threads,
3204 } = self.contents.entries.get(header_idx)?
3205 else {
3206 return None;
3207 };
3208
3209 let is_focused = self.focus_handle.is_focused(window);
3210 let is_selected = is_focused && self.selection == Some(header_idx);
3211
3212 let header_element = self.render_project_header(
3213 header_idx,
3214 true,
3215 key,
3216 &label,
3217 &highlight_positions,
3218 *has_running_threads,
3219 *waiting_thread_count,
3220 *has_notifications,
3221 *is_active,
3222 is_selected,
3223 *has_threads,
3224 cx,
3225 );
3226
3227 let top_offset = self
3228 .contents
3229 .project_header_indices
3230 .iter()
3231 .find(|&&idx| idx > header_idx)
3232 .and_then(|&next_idx| {
3233 let bounds = self.list_state.bounds_for_item(next_idx)?;
3234 let viewport = self.list_state.viewport_bounds();
3235 let y_in_viewport = bounds.origin.y - viewport.origin.y;
3236 let header_height = bounds.size.height;
3237 (y_in_viewport < header_height).then_some(y_in_viewport - header_height)
3238 })
3239 .unwrap_or(px(0.));
3240
3241 let color = cx.theme().colors();
3242 let background = color
3243 .title_bar_background
3244 .blend(color.panel_background.opacity(0.2));
3245
3246 let element = v_flex()
3247 .absolute()
3248 .top(top_offset)
3249 .left_0()
3250 .w_full()
3251 .bg(background)
3252 .border_b_1()
3253 .border_color(color.border.opacity(0.5))
3254 .child(header_element)
3255 .shadow_sm()
3256 .into_any_element();
3257
3258 Some(element)
3259 }
3260
3261 fn toggle_collapse(
3262 &mut self,
3263 project_group_key: &ProjectGroupKey,
3264 _window: &mut Window,
3265 cx: &mut Context<Self>,
3266 ) {
3267 let is_collapsed = self.is_group_collapsed(project_group_key, cx);
3268 self.set_group_expanded(project_group_key, is_collapsed, cx);
3269 self.update_entries(cx);
3270 }
3271
3272 fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
3273 let mut dispatch_context = KeyContext::new_with_defaults();
3274 dispatch_context.add("ThreadsSidebar");
3275 dispatch_context.add("menu");
3276
3277 let is_archived_search_focused = matches!(&self.view, SidebarView::Archive(archive) if archive.read(cx).is_filter_editor_focused(window, cx));
3278
3279 let is_renaming_thread = self
3280 .thread_rename_editor
3281 .focus_handle(cx)
3282 .is_focused(window);
3283
3284 let identifier = if self.filter_editor.focus_handle(cx).is_focused(window)
3285 || is_archived_search_focused
3286 {
3287 "searching"
3288 } else if is_renaming_thread {
3289 "editing"
3290 } else {
3291 "not_searching"
3292 };
3293
3294 dispatch_context.add(identifier);
3295 dispatch_context
3296 }
3297
3298 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3299 if !self.focus_handle.is_focused(window) {
3300 return;
3301 }
3302
3303 if let SidebarView::Archive(archive) = &self.view {
3304 let has_selection = archive.read(cx).has_selection();
3305 if !has_selection {
3306 archive.update(cx, |view, cx| view.focus_filter_editor(window, cx));
3307 }
3308 } else if self.selection.is_none() {
3309 self.filter_editor.focus_handle(cx).focus(window, cx);
3310 }
3311 }
3312
3313 fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
3314 if self.renaming_thread_id.is_some() {
3315 self.finish_thread_rename(window, cx);
3316 return;
3317 }
3318
3319 if self.filter_editor.read(cx).is_focused(window) {
3320 if self.reset_filter_editor_text(window, cx) {
3321 self.selection = None;
3322 self.update_entries(cx);
3323 return;
3324 }
3325
3326 if self.selection.is_none() {
3327 self.select_first_entry();
3328 }
3329 if self.selection.is_some() {
3330 self.focus_handle.focus(window, cx);
3331 cx.notify();
3332 }
3333 return;
3334 }
3335
3336 if self.reset_filter_editor_text(window, cx) {
3337 self.update_entries(cx);
3338 } else {
3339 self.selection = None;
3340 self.filter_editor.focus_handle(cx).focus(window, cx);
3341 cx.notify();
3342 }
3343 }
3344
3345 fn focus_sidebar_filter(
3346 &mut self,
3347 _: &FocusSidebarFilter,
3348 window: &mut Window,
3349 cx: &mut Context<Self>,
3350 ) {
3351 self.selection = None;
3352 if let SidebarView::Archive(archive) = &self.view {
3353 archive.update(cx, |view, cx| {
3354 view.clear_selection();
3355 view.focus_filter_editor(window, cx);
3356 });
3357 } else {
3358 self.filter_editor.focus_handle(cx).focus(window, cx);
3359 }
3360
3361 cx.notify();
3362 }
3363
3364 fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
3365 self.filter_editor.update(cx, |editor, cx| {
3366 if editor.buffer().read(cx).len(cx).0 > 0 {
3367 editor.set_text("", window, cx);
3368 true
3369 } else {
3370 false
3371 }
3372 })
3373 }
3374
3375 fn has_filter_query(&self, cx: &App) -> bool {
3376 !self.filter_editor.read(cx).text(cx).is_empty()
3377 }
3378
3379 fn start_renaming_thread(
3380 &mut self,
3381 ix: usize,
3382 thread_id: ThreadId,
3383 title: SharedString,
3384 window: &mut Window,
3385 cx: &mut Context<Self>,
3386 ) {
3387 if self.renaming_thread_id.is_some() && self.renaming_thread_id != Some(thread_id) {
3388 self.finish_thread_rename(window, cx);
3389 }
3390
3391 self.selection = Some(ix);
3392 self.renaming_thread_id = Some(thread_id);
3393 self.suppress_next_rename_edit = true;
3394 self.list_state.scroll_to_reveal_item(ix);
3395 self.thread_rename_editor.update(cx, |editor, cx| {
3396 editor.set_text(title, window, cx);
3397 editor.select_all(&editor::actions::SelectAll, window, cx);
3398 editor.focus_handle(cx).focus(window, cx);
3399 });
3400 cx.notify();
3401 }
3402
3403 fn handle_thread_rename_editor_event(
3404 &mut self,
3405 title_editor: &Entity<Editor>,
3406 event: &editor::EditorEvent,
3407 window: &mut Window,
3408 cx: &mut Context<Self>,
3409 ) {
3410 match event {
3411 editor::EditorEvent::BufferEdited => {
3412 if self.suppress_next_rename_edit {
3413 self.suppress_next_rename_edit = false;
3414 return;
3415 }
3416 if !title_editor.read(cx).is_focused(window) {
3417 return;
3418 }
3419 let new_title = title_editor.read(cx).text(cx);
3420 if new_title.is_empty() {
3421 return;
3422 }
3423 let Some(thread_id) = self.renaming_thread_id else {
3424 return;
3425 };
3426 self.apply_thread_rename(thread_id, SharedString::from(new_title), window, cx);
3427 }
3428 editor::EditorEvent::Blurred => {
3429 self.finish_thread_rename(window, cx);
3430 }
3431 _ => {}
3432 }
3433 }
3434
3435 fn apply_thread_rename(
3436 &mut self,
3437 thread_id: ThreadId,
3438 title: SharedString,
3439 window: &mut Window,
3440 cx: &mut Context<Self>,
3441 ) {
3442 let mut found = false;
3443 if let Some(multi_workspace) = self.multi_workspace.upgrade() {
3444 let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect();
3445 for workspace in workspaces {
3446 if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
3447 if let Some(view) = agent_panel
3448 .read(cx)
3449 .conversation_view_for_id(&thread_id, cx)
3450 && let Some(thread_view) = view.read(cx).root_thread_view()
3451 {
3452 thread_view.update(cx, |thread_view, cx| {
3453 thread_view.rename(title.clone(), window, cx);
3454 });
3455 found = true;
3456 }
3457 }
3458 }
3459 }
3460
3461 if !found {
3462 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
3463 store.set_title_override(thread_id, title, cx);
3464 });
3465 }
3466 }
3467
3468 fn finish_thread_rename(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
3469 if self.renaming_thread_id.take().is_none() {
3470 return false;
3471 }
3472 self.focus_handle.focus(window, cx);
3473 self.update_entries(cx);
3474 true
3475 }
3476
3477 fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
3478 self.select_next(&SelectNext, window, cx);
3479 if self.selection.is_some() {
3480 self.focus_handle.focus(window, cx);
3481 }
3482 }
3483
3484 fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
3485 self.select_previous(&SelectPrevious, window, cx);
3486 if self.selection.is_some() {
3487 self.focus_handle.focus(window, cx);
3488 }
3489 }
3490
3491 fn editor_confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3492 if self.selection.is_none() {
3493 self.select_next(&SelectNext, window, cx);
3494 }
3495 if self.selection.is_some() {
3496 self.focus_handle.focus(window, cx);
3497 }
3498 }
3499
3500 fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
3501 let next = match self.selection {
3502 Some(ix) if ix + 1 < self.contents.entries.len() => ix + 1,
3503 Some(_) if !self.contents.entries.is_empty() => 0,
3504 None if !self.contents.entries.is_empty() => 0,
3505 _ => return,
3506 };
3507 self.selection = Some(next);
3508 self.list_state.scroll_to_reveal_item(next);
3509 cx.notify();
3510 }
3511
3512 fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
3513 match self.selection {
3514 Some(0) => {
3515 self.selection = None;
3516 self.filter_editor.focus_handle(cx).focus(window, cx);
3517 cx.notify();
3518 }
3519 Some(ix) => {
3520 self.selection = Some(ix - 1);
3521 self.list_state.scroll_to_reveal_item(ix - 1);
3522 cx.notify();
3523 }
3524 None if !self.contents.entries.is_empty() => {
3525 let last = self.contents.entries.len() - 1;
3526 self.selection = Some(last);
3527 self.list_state.scroll_to_reveal_item(last);
3528 cx.notify();
3529 }
3530 None => {}
3531 }
3532 }
3533
3534 fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
3535 if !self.contents.entries.is_empty() {
3536 self.selection = Some(0);
3537 self.list_state.scroll_to_reveal_item(0);
3538 cx.notify();
3539 }
3540 }
3541
3542 fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
3543 if let Some(last) = self.contents.entries.len().checked_sub(1) {
3544 self.selection = Some(last);
3545 self.list_state.scroll_to_reveal_item(last);
3546 cx.notify();
3547 }
3548 }
3549
3550 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
3551 if self.finish_thread_rename(window, cx) {
3552 return;
3553 }
3554
3555 let Some(ix) = self.selection else { return };
3556 let Some(entry) = self.contents.entries.get(ix) else {
3557 return;
3558 };
3559
3560 match entry {
3561 ListEntry::ProjectHeader { key, .. } => {
3562 let key = key.clone();
3563 self.toggle_collapse(&key, window, cx);
3564 }
3565 ListEntry::Thread(thread) => {
3566 let metadata = thread.metadata.clone();
3567 match &thread.workspace {
3568 ThreadEntryWorkspace::Open(workspace) => {
3569 let workspace = workspace.clone();
3570 self.activate_thread(metadata, &workspace, false, window, cx);
3571 }
3572 ThreadEntryWorkspace::Closed {
3573 folder_paths,
3574 project_group_key,
3575 } => {
3576 let folder_paths = folder_paths.clone();
3577 let project_group_key = project_group_key.clone();
3578 self.open_workspace_and_activate_thread(
3579 metadata,
3580 folder_paths,
3581 &project_group_key,
3582 window,
3583 cx,
3584 );
3585 }
3586 }
3587 }
3588 ListEntry::Terminal(terminal) => {
3589 let metadata = terminal.metadata.clone();
3590 let workspace = terminal.workspace.clone();
3591 self.activate_terminal_entry(metadata, workspace, false, window, cx);
3592 }
3593 }
3594 }
3595
3596 fn find_workspace_across_windows(
3597 &self,
3598 cx: &App,
3599 predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
3600 ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
3601 cx.windows()
3602 .into_iter()
3603 .filter_map(|window| window.downcast::<MultiWorkspace>())
3604 .find_map(|window| {
3605 let workspace = window.read(cx).ok().and_then(|multi_workspace| {
3606 multi_workspace
3607 .workspaces()
3608 .find(|workspace| predicate(workspace, cx))
3609 .cloned()
3610 })?;
3611 Some((window, workspace))
3612 })
3613 }
3614
3615 fn find_workspace_in_current_window(
3616 &self,
3617 cx: &App,
3618 predicate: impl Fn(&Entity<Workspace>, &App) -> bool,
3619 ) -> Option<Entity<Workspace>> {
3620 self.multi_workspace.upgrade().and_then(|multi_workspace| {
3621 multi_workspace
3622 .read(cx)
3623 .workspaces()
3624 .find(|workspace| predicate(workspace, cx))
3625 .cloned()
3626 })
3627 }
3628
3629 fn load_agent_thread_in_workspace(
3630 workspace: &Entity<Workspace>,
3631 metadata: &ThreadMetadata,
3632 focus: bool,
3633 window: &mut Window,
3634 cx: &mut App,
3635 ) {
3636 let load_thread = |agent_panel: Entity<AgentPanel>,
3637 metadata: &ThreadMetadata,
3638 focus: bool,
3639 window: &mut Window,
3640 cx: &mut App| {
3641 agent_panel.update(cx, |panel, cx| {
3642 panel.load_agent_thread(
3643 Agent::from(metadata.agent_id.clone()),
3644 metadata.thread_id,
3645 Some(metadata.folder_paths().clone()),
3646 metadata.title.clone(),
3647 focus,
3648 AgentThreadSource::Sidebar,
3649 window,
3650 cx,
3651 );
3652 });
3653 };
3654
3655 let mut existing_panel = None;
3656 workspace.update(cx, |workspace, cx| {
3657 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
3658 existing_panel = Some(panel);
3659 }
3660 });
3661
3662 if let Some(agent_panel) = existing_panel {
3663 load_thread(agent_panel, metadata, focus, window, cx);
3664 workspace.update(cx, |workspace, cx| {
3665 if focus {
3666 workspace.focus_panel::<AgentPanel>(window, cx);
3667 } else {
3668 workspace.reveal_panel::<AgentPanel>(window, cx);
3669 }
3670 });
3671 return;
3672 }
3673
3674 let workspace = workspace.downgrade();
3675 let metadata = metadata.clone();
3676 let mut async_window_cx = window.to_async(cx);
3677 cx.spawn(async move |_cx| {
3678 let panel = AgentPanel::load(workspace.clone(), async_window_cx.clone()).await?;
3679
3680 workspace.update_in(&mut async_window_cx, |workspace, window, cx| {
3681 let panel = workspace.panel::<AgentPanel>(cx).unwrap_or_else(|| {
3682 workspace.add_panel(panel.clone(), window, cx);
3683 panel.clone()
3684 });
3685 load_thread(panel, &metadata, focus, window, cx);
3686 if focus {
3687 workspace.focus_panel::<AgentPanel>(window, cx);
3688 } else {
3689 workspace.reveal_panel::<AgentPanel>(window, cx);
3690 }
3691 })?;
3692
3693 anyhow::Ok(())
3694 })
3695 .detach_and_log_err(cx);
3696 }
3697
3698 fn open_closed_native_thread_as_markdown(
3699 session_id: &acp::SessionId,
3700 title: Option<SharedString>,
3701 workspace: &Entity<Workspace>,
3702 window: &mut Window,
3703 cx: &mut App,
3704 ) {
3705 let thread_store = ThreadStore::global(cx);
3706 let load_task =
3707 thread_store.update(cx, |store, cx| store.load_thread(session_id.clone(), cx));
3708
3709 let thread_title = title
3710 .map(|t| t.to_string())
3711 .unwrap_or_else(|| DEFAULT_THREAD_TITLE.to_string());
3712
3713 let workspace = workspace.clone();
3714
3715 window
3716 .spawn(cx, async move |cx| {
3717 let db_thread = load_task.await?;
3718 let Some(db_thread) = db_thread else {
3719 anyhow::bail!("Thread not found in database");
3720 };
3721
3722 let markdown = db_thread.to_markdown();
3723
3724 cx.update(|window, cx| {
3725 agent_ui::open_markdown_in_workspace(
3726 thread_title,
3727 markdown,
3728 workspace,
3729 window,
3730 cx,
3731 )
3732 })?
3733 .await
3734 })
3735 .detach_and_log_err(cx);
3736 }
3737
3738 fn show_thread_title_toast(workspace: Entity<Workspace>, message: &'static str, cx: &mut App) {
3739 workspace.update(cx, |workspace, cx| {
3740 let toast = StatusToast::new(message, cx, |this, _cx| {
3741 this.icon(
3742 Icon::new(IconName::Warning)
3743 .size(IconSize::Small)
3744 .color(Color::Warning),
3745 )
3746 .dismiss_button(true)
3747 });
3748 workspace.toggle_status_toast(toast, cx);
3749 });
3750 }
3751
3752 fn show_no_thread_summary_model_toast(workspace: Entity<Workspace>, cx: &mut App) {
3753 Self::show_thread_title_toast(
3754 workspace,
3755 "No model is configured for summarizing thread titles.",
3756 cx,
3757 );
3758 }
3759
3760 fn regenerate_thread_title(
3761 &mut self,
3762 session_id: &acp::SessionId,
3763 thread_id: ThreadId,
3764 folder_paths: PathList,
3765 thread_workspace: Option<Entity<Workspace>>,
3766 cx: &mut Context<Self>,
3767 ) {
3768 if let Some(panel) = thread_workspace
3769 .as_ref()
3770 .and_then(|w| w.read(cx).panel::<AgentPanel>(cx))
3771 {
3772 match panel.update(cx, |panel, cx| panel.regenerate_thread_title(thread_id, cx)) {
3773 ThreadTitleRegenerationResult::Started
3774 | ThreadTitleRegenerationResult::AlreadyGenerating => return,
3775 ThreadTitleRegenerationResult::NoModel => {
3776 if let Some(workspace) = self.active_workspace(cx) {
3777 Self::show_no_thread_summary_model_toast(workspace, cx);
3778 }
3779 return;
3780 }
3781 ThreadTitleRegenerationResult::NotOpen => {}
3782 }
3783 }
3784
3785 let Some(configured_model) =
3786 LanguageModelRegistry::read_global(cx).thread_summary_model(cx)
3787 else {
3788 if let Some(workspace) = self.active_workspace(cx) {
3789 Self::show_no_thread_summary_model_toast(workspace, cx);
3790 }
3791 return;
3792 };
3793
3794 if !self.regenerating_titles.insert(thread_id) {
3795 return;
3796 }
3797
3798 let model = configured_model.model;
3799 let temperature = AgentSettings::temperature_for_model(&model, cx);
3800
3801 let thread_store = ThreadStore::global(cx);
3802 let load_task =
3803 thread_store.update(cx, |store, cx| store.load_thread(session_id.clone(), cx));
3804 let session_id = session_id.clone();
3805
3806 cx.notify();
3807
3808 cx.spawn(async move |this, cx| {
3809 let result: anyhow::Result<SharedString> = async {
3810 let Some(db_thread) = load_task.await? else {
3811 anyhow::bail!("Thread not found in database");
3812 };
3813
3814 let request = agent::build_thread_title_request(&db_thread.messages, temperature);
3815 let title =
3816 SharedString::from(agent::stream_thread_title(model, request, cx).await?);
3817
3818 let Some(mut db_thread) = thread_store
3819 .update(cx, |store, cx| store.load_thread(session_id.clone(), cx))
3820 .await?
3821 else {
3822 anyhow::bail!("Thread not found in database");
3823 };
3824 db_thread.title = title.clone();
3825
3826 thread_store
3827 .update(cx, |store, cx| {
3828 store.save_thread(session_id, db_thread, folder_paths, cx)
3829 })
3830 .await?;
3831
3832 anyhow::Ok(title)
3833 }
3834 .await;
3835
3836 this.update(cx, |this, cx| {
3837 this.regenerating_titles.remove(&thread_id);
3838 match &result {
3839 Ok(title) => {
3840 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
3841 store.set_generated_title(thread_id, title.clone(), cx);
3842 });
3843 }
3844 Err(_) => {
3845 if let Some(workspace) = this.active_workspace(cx) {
3846 Self::show_thread_title_toast(
3847 workspace,
3848 "Failed to regenerate thread title.",
3849 cx,
3850 );
3851 }
3852 }
3853 }
3854 cx.notify();
3855 })
3856 .ok();
3857
3858 result.map(|_| ())
3859 })
3860 .detach_and_log_err(cx);
3861 }
3862
3863 fn is_thread_active_in_workspace(
3864 &self,
3865 thread_id: &ThreadId,
3866 workspace: &Entity<Workspace>,
3867 cx: &App,
3868 ) -> bool {
3869 self.active_workspace(cx).as_ref() == Some(workspace)
3870 && self.active_entry.as_ref().is_some_and(|entry| {
3871 entry.is_active_thread(thread_id) && entry.workspace() == workspace
3872 })
3873 }
3874
3875 fn activate_thread_locally(
3876 &mut self,
3877 metadata: &ThreadMetadata,
3878 workspace: &Entity<Workspace>,
3879 retain: bool,
3880 window: &mut Window,
3881 cx: &mut Context<Self>,
3882 ) {
3883 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
3884 return;
3885 };
3886
3887 if self.is_thread_active_in_workspace(&metadata.thread_id, workspace, cx) {
3888 workspace.update(cx, |workspace, cx| {
3889 workspace.focus_panel::<AgentPanel>(window, cx);
3890 });
3891 return;
3892 }
3893
3894 // Set active_entry eagerly so the sidebar highlight updates
3895 // immediately, rather than waiting for a deferred AgentPanel
3896 // event which can race with ActiveWorkspaceChanged clearing it.
3897 self.active_entry = Some(ActiveEntry::Thread {
3898 thread_id: metadata.thread_id,
3899 session_id: metadata.session_id.clone(),
3900 workspace: workspace.clone(),
3901 });
3902 self.record_thread_access(&metadata.thread_id);
3903 self.pending_thread_activation = Some(metadata.thread_id);
3904
3905 multi_workspace.update(cx, |multi_workspace, cx| {
3906 multi_workspace.activate(workspace.clone(), None, window, cx);
3907 if retain {
3908 multi_workspace.retain_active_workspace(cx);
3909 }
3910 });
3911
3912 Self::load_agent_thread_in_workspace(workspace, metadata, true, window, cx);
3913
3914 self.update_entries(cx);
3915 }
3916
3917 fn activate_thread_in_other_window(
3918 &self,
3919 metadata: ThreadMetadata,
3920 workspace: Entity<Workspace>,
3921 target_window: WindowHandle<MultiWorkspace>,
3922 cx: &mut Context<Self>,
3923 ) {
3924 let target_session_id = metadata.session_id.clone();
3925 let metadata_thread_id = metadata.thread_id;
3926 let workspace_for_entry = workspace.clone();
3927
3928 let activated = target_window
3929 .update(cx, |multi_workspace, window, cx| {
3930 window.activate_window();
3931 multi_workspace.activate(workspace.clone(), None, window, cx);
3932 Self::load_agent_thread_in_workspace(&workspace, &metadata, true, window, cx);
3933 })
3934 .log_err()
3935 .is_some();
3936
3937 if activated {
3938 if let Some(target_sidebar) = target_window
3939 .read(cx)
3940 .ok()
3941 .and_then(|multi_workspace| {
3942 multi_workspace.sidebar().map(|sidebar| sidebar.to_any())
3943 })
3944 .and_then(|sidebar| sidebar.downcast::<Self>().ok())
3945 {
3946 target_sidebar.update(cx, |sidebar, cx| {
3947 sidebar.pending_thread_activation = Some(metadata_thread_id);
3948 sidebar.active_entry = Some(ActiveEntry::Thread {
3949 thread_id: metadata_thread_id,
3950 session_id: target_session_id.clone(),
3951 workspace: workspace_for_entry.clone(),
3952 });
3953 sidebar.record_thread_access(&metadata_thread_id);
3954 sidebar.update_entries(cx);
3955 });
3956 }
3957 }
3958 }
3959
3960 fn activate_thread(
3961 &mut self,
3962 metadata: ThreadMetadata,
3963 workspace: &Entity<Workspace>,
3964 retain: bool,
3965 window: &mut Window,
3966 cx: &mut Context<Self>,
3967 ) {
3968 if self
3969 .find_workspace_in_current_window(cx, |candidate, _| candidate == workspace)
3970 .is_some()
3971 {
3972 self.activate_thread_locally(&metadata, &workspace, retain, window, cx);
3973 return;
3974 }
3975
3976 let Some((target_window, workspace)) =
3977 self.find_workspace_across_windows(cx, |candidate, _| candidate == workspace)
3978 else {
3979 return;
3980 };
3981
3982 self.activate_thread_in_other_window(metadata, workspace, target_window, cx);
3983 }
3984
3985 fn open_workspace_and_activate_thread(
3986 &mut self,
3987 metadata: ThreadMetadata,
3988 folder_paths: PathList,
3989 project_group_key: &ProjectGroupKey,
3990 window: &mut Window,
3991 cx: &mut Context<Self>,
3992 ) {
3993 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
3994 return;
3995 };
3996
3997 let pending_thread_id = metadata.thread_id;
3998 // Mark the pending thread activation so rebuild_contents
3999 // preserves the Thread active_entry during loading and
4000 // reconciliation cannot synthesize an empty fallback draft.
4001 self.pending_thread_activation = Some(pending_thread_id);
4002
4003 let host = project_group_key.host();
4004 let provisional_key = Some(project_group_key.clone());
4005 let active_workspace = multi_workspace.read(cx).workspace().clone();
4006 let modal_workspace = active_workspace.clone();
4007
4008 let open_task = multi_workspace.update(cx, |this, cx| {
4009 this.find_or_create_workspace(
4010 folder_paths,
4011 host,
4012 provisional_key,
4013 |options, window, cx| connect_remote(active_workspace, options, window, cx),
4014 &[],
4015 None,
4016 OpenMode::Activate,
4017 window,
4018 cx,
4019 )
4020 });
4021
4022 cx.spawn_in(window, async move |this, cx| {
4023 let result = open_task.await;
4024 // Dismiss the modal as soon as the open attempt completes so
4025 // failures or cancellations do not leave a stale connection modal behind.
4026 remote_connection::dismiss_connection_modal(&modal_workspace, cx);
4027
4028 if result.is_err() {
4029 this.update(cx, |this, _cx| {
4030 if this.pending_thread_activation == Some(pending_thread_id) {
4031 this.pending_thread_activation = None;
4032 }
4033 })
4034 .ok();
4035 }
4036
4037 let workspace = result?;
4038 this.update_in(cx, |this, window, cx| {
4039 this.activate_thread(metadata, &workspace, false, window, cx);
4040 })?;
4041 anyhow::Ok(())
4042 })
4043 .detach_and_log_err(cx);
4044 }
4045
4046 fn find_current_workspace_for_path_list(
4047 &self,
4048 path_list: &PathList,
4049 remote_connection: Option<&RemoteConnectionOptions>,
4050 cx: &App,
4051 ) -> Option<Entity<Workspace>> {
4052 self.find_workspace_in_current_window(cx, |workspace, cx| {
4053 workspace_path_list(workspace, cx).paths() == path_list.paths()
4054 && same_remote_connection_identity(
4055 workspace
4056 .read(cx)
4057 .project()
4058 .read(cx)
4059 .remote_connection_options(cx)
4060 .as_ref(),
4061 remote_connection,
4062 )
4063 })
4064 }
4065
4066 fn find_open_workspace_for_path_list(
4067 &self,
4068 path_list: &PathList,
4069 remote_connection: Option<&RemoteConnectionOptions>,
4070 cx: &App,
4071 ) -> Option<(WindowHandle<MultiWorkspace>, Entity<Workspace>)> {
4072 self.find_workspace_across_windows(cx, |workspace, cx| {
4073 workspace_path_list(workspace, cx).paths() == path_list.paths()
4074 && same_remote_connection_identity(
4075 workspace
4076 .read(cx)
4077 .project()
4078 .read(cx)
4079 .remote_connection_options(cx)
4080 .as_ref(),
4081 remote_connection,
4082 )
4083 })
4084 }
4085
4086 fn open_thread_from_archive(
4087 &mut self,
4088 metadata: ThreadMetadata,
4089 window: &mut Window,
4090 cx: &mut Context<Self>,
4091 ) {
4092 let thread_id = metadata.thread_id;
4093 let weak_archive_view = match &self.view {
4094 SidebarView::Archive(view) => Some(view.downgrade()),
4095 _ => None,
4096 };
4097
4098 if metadata.folder_paths().paths().is_empty() {
4099 ThreadMetadataStore::global(cx).update(cx, |store, cx| store.unarchive(thread_id, cx));
4100
4101 let active_workspace = self
4102 .multi_workspace
4103 .upgrade()
4104 .map(|w| w.read(cx).workspace().clone());
4105
4106 if let Some(workspace) = active_workspace {
4107 self.activate_thread_locally(&metadata, &workspace, false, window, cx);
4108 } else {
4109 let path_list = metadata.folder_paths().clone();
4110 if let Some((target_window, workspace)) = self.find_open_workspace_for_path_list(
4111 &path_list,
4112 metadata.remote_connection.as_ref(),
4113 cx,
4114 ) {
4115 self.activate_thread_in_other_window(metadata, workspace, target_window, cx);
4116 } else {
4117 let key = ProjectGroupKey::from_worktree_paths(
4118 &metadata.worktree_paths,
4119 metadata.remote_connection.clone(),
4120 );
4121 self.open_workspace_and_activate_thread(metadata, path_list, &key, window, cx);
4122 }
4123 }
4124 self.show_thread_list(window, cx);
4125 return;
4126 }
4127
4128 let store = ThreadMetadataStore::global(cx);
4129 let task = if metadata.archived {
4130 store
4131 .read(cx)
4132 .get_archived_worktrees_for_thread(thread_id, cx)
4133 } else {
4134 Task::ready(Ok(Vec::new()))
4135 };
4136 let path_list = metadata.folder_paths().clone();
4137
4138 let restore_task = cx.spawn_in(window, async move |this, cx| {
4139 let result: anyhow::Result<()> = async {
4140 let archived_worktrees = task.await?;
4141
4142 if archived_worktrees.is_empty() {
4143 this.update_in(cx, |this, window, cx| {
4144 this.restoring_tasks.remove(&thread_id);
4145 if metadata.archived {
4146 ThreadMetadataStore::global(cx)
4147 .update(cx, |store, cx| store.unarchive(thread_id, cx));
4148 }
4149
4150 if let Some(workspace) = this.find_current_workspace_for_path_list(
4151 &path_list,
4152 metadata.remote_connection.as_ref(),
4153 cx,
4154 ) {
4155 this.activate_thread_locally(&metadata, &workspace, false, window, cx);
4156 } else if let Some((target_window, workspace)) = this
4157 .find_open_workspace_for_path_list(
4158 &path_list,
4159 metadata.remote_connection.as_ref(),
4160 cx,
4161 )
4162 {
4163 this.activate_thread_in_other_window(
4164 metadata,
4165 workspace,
4166 target_window,
4167 cx,
4168 );
4169 } else {
4170 let key = ProjectGroupKey::from_worktree_paths(
4171 &metadata.worktree_paths,
4172 metadata.remote_connection.clone(),
4173 );
4174 this.open_workspace_and_activate_thread(
4175 metadata, path_list, &key, window, cx,
4176 );
4177 }
4178 this.show_thread_list(window, cx);
4179 })?;
4180 return anyhow::Ok(());
4181 }
4182
4183 let mut path_replacements: Vec<(PathBuf, PathBuf)> = Vec::new();
4184 for row in &archived_worktrees {
4185 match thread_worktree_archive::restore_worktree_via_git(
4186 row,
4187 metadata.remote_connection.as_ref(),
4188 &mut *cx,
4189 )
4190 .await
4191 {
4192 Ok(restored_path) => {
4193 thread_worktree_archive::cleanup_archived_worktree_record(
4194 row,
4195 metadata.remote_connection.as_ref(),
4196 &mut *cx,
4197 )
4198 .await;
4199 path_replacements.push((row.worktree_path.clone(), restored_path));
4200 }
4201 Err(error) => {
4202 log::error!("Failed to restore worktree: {error:#}");
4203 this.update_in(cx, |this, _window, cx| {
4204 this.restoring_tasks.remove(&thread_id);
4205 if let Some(weak_archive_view) = &weak_archive_view {
4206 weak_archive_view
4207 .update(cx, |view, cx| {
4208 view.clear_restoring(&thread_id, cx);
4209 })
4210 .ok();
4211 }
4212
4213 if let Some(multi_workspace) = this.multi_workspace.upgrade() {
4214 let workspace = multi_workspace.read(cx).workspace().clone();
4215 workspace.update(cx, |workspace, cx| {
4216 struct RestoreWorktreeErrorToast;
4217 workspace.show_toast(
4218 Toast::new(
4219 NotificationId::unique::<RestoreWorktreeErrorToast>(
4220 ),
4221 format!("Failed to restore worktree: {error:#}"),
4222 )
4223 .autohide(),
4224 cx,
4225 );
4226 });
4227 }
4228 })
4229 .ok();
4230 return anyhow::Ok(());
4231 }
4232 }
4233 }
4234
4235 if !path_replacements.is_empty() {
4236 cx.update(|_window, cx| {
4237 store.update(cx, |store, cx| {
4238 store.update_restored_worktree_paths(thread_id, &path_replacements, cx);
4239 });
4240 })?;
4241
4242 let updated_metadata =
4243 cx.update(|_window, cx| store.read(cx).entry(thread_id).cloned())?;
4244
4245 if let Some(updated_metadata) = updated_metadata {
4246 let new_paths = updated_metadata.folder_paths().clone();
4247 let key = ProjectGroupKey::from_worktree_paths(
4248 &updated_metadata.worktree_paths,
4249 updated_metadata.remote_connection.clone(),
4250 );
4251
4252 cx.update(|_window, cx| {
4253 store.update(cx, |store, cx| {
4254 store.unarchive(updated_metadata.thread_id, cx);
4255 });
4256 })?;
4257
4258 this.update_in(cx, |this, window, cx| {
4259 this.restoring_tasks.remove(&thread_id);
4260 this.open_workspace_and_activate_thread(
4261 updated_metadata,
4262 new_paths,
4263 &key,
4264 window,
4265 cx,
4266 );
4267 this.show_thread_list(window, cx);
4268 })?;
4269 }
4270 }
4271
4272 anyhow::Ok(())
4273 }
4274 .await;
4275 if let Err(error) = result {
4276 log::error!("{error:#}");
4277 }
4278 });
4279 self.restoring_tasks.insert(thread_id, restore_task);
4280 }
4281
4282 fn expand_selected_entry(
4283 &mut self,
4284 _: &SelectChild,
4285 _window: &mut Window,
4286 cx: &mut Context<Self>,
4287 ) {
4288 let Some(ix) = self.selection else { return };
4289
4290 match self.contents.entries.get(ix) {
4291 Some(ListEntry::ProjectHeader { key, .. }) => {
4292 let key = key.clone();
4293 if self.is_group_collapsed(&key, cx) {
4294 self.set_group_expanded(&key, true, cx);
4295 self.update_entries(cx);
4296 } else if ix + 1 < self.contents.entries.len() {
4297 self.selection = Some(ix + 1);
4298 self.list_state.scroll_to_reveal_item(ix + 1);
4299 cx.notify();
4300 }
4301 }
4302 _ => {}
4303 }
4304 }
4305
4306 fn collapse_selected_entry(
4307 &mut self,
4308 _: &SelectParent,
4309 _window: &mut Window,
4310 cx: &mut Context<Self>,
4311 ) {
4312 let Some(ix) = self.selection else { return };
4313
4314 match self.contents.entries.get(ix) {
4315 Some(ListEntry::ProjectHeader { key, .. }) => {
4316 let key = key.clone();
4317 if !self.is_group_collapsed(&key, cx) {
4318 self.set_group_expanded(&key, false, cx);
4319 self.update_entries(cx);
4320 }
4321 }
4322 Some(ListEntry::Thread(_) | ListEntry::Terminal(_)) => {
4323 for i in (0..ix).rev() {
4324 if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(i)
4325 {
4326 let key = key.clone();
4327 self.selection = Some(i);
4328 self.set_group_expanded(&key, false, cx);
4329 self.update_entries(cx);
4330 break;
4331 }
4332 }
4333 }
4334 None => {}
4335 }
4336 }
4337
4338 fn toggle_selected_fold(
4339 &mut self,
4340 _: &editor::actions::ToggleFold,
4341 _window: &mut Window,
4342 cx: &mut Context<Self>,
4343 ) {
4344 let Some(ix) = self.selection else { return };
4345
4346 // Find the group header for the current selection.
4347 let header_ix = match self.contents.entries.get(ix) {
4348 Some(ListEntry::ProjectHeader { .. }) => Some(ix),
4349 Some(ListEntry::Thread(_) | ListEntry::Terminal(_)) => (0..ix).rev().find(|&i| {
4350 matches!(
4351 self.contents.entries.get(i),
4352 Some(ListEntry::ProjectHeader { .. })
4353 )
4354 }),
4355 None => None,
4356 };
4357
4358 if let Some(header_ix) = header_ix {
4359 if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(header_ix)
4360 {
4361 let key = key.clone();
4362 if self.is_group_collapsed(&key, cx) {
4363 self.set_group_expanded(&key, true, cx);
4364 } else {
4365 self.selection = Some(header_ix);
4366 self.set_group_expanded(&key, false, cx);
4367 }
4368 self.update_entries(cx);
4369 }
4370 }
4371 }
4372
4373 fn fold_all(
4374 &mut self,
4375 _: &editor::actions::FoldAll,
4376 _window: &mut Window,
4377 cx: &mut Context<Self>,
4378 ) {
4379 if let Some(mw) = self.multi_workspace.upgrade() {
4380 mw.update(cx, |mw, _cx| {
4381 mw.set_all_groups_expanded(false);
4382 });
4383 }
4384 self.update_entries(cx);
4385 }
4386
4387 fn unfold_all(
4388 &mut self,
4389 _: &editor::actions::UnfoldAll,
4390 _window: &mut Window,
4391 cx: &mut Context<Self>,
4392 ) {
4393 if let Some(mw) = self.multi_workspace.upgrade() {
4394 mw.update(cx, |mw, _cx| {
4395 mw.set_all_groups_expanded(true);
4396 });
4397 }
4398 self.update_entries(cx);
4399 }
4400
4401 fn stop_thread(&mut self, thread_id: &agent_ui::ThreadId, cx: &mut Context<Self>) {
4402 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
4403 return;
4404 };
4405
4406 let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect();
4407 for workspace in workspaces {
4408 if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
4409 let cancelled =
4410 agent_panel.update(cx, |panel, cx| panel.cancel_thread(thread_id, cx));
4411 if cancelled {
4412 return;
4413 }
4414 }
4415 }
4416 }
4417
4418 /// Find the neighbor thread in the sidebar (by display position).
4419 /// Look below first, then above, for the nearest thread that isn't
4420 /// the one being archived. We capture both the neighbor's metadata
4421 /// (for activation) and its workspace paths (for the workspace
4422 /// removal fallback).
4423 fn neighboring_activatable_entry(&self, current_position: usize) -> Option<ActivatableEntry> {
4424 let after = self
4425 .contents
4426 .entries
4427 .get(current_position.checked_add(1)?..)?;
4428 let before = self.contents.entries.get(..current_position)?;
4429 after
4430 .iter()
4431 .chain(before.iter().rev())
4432 .find_map(ActivatableEntry::from_list_entry)
4433 }
4434
4435 fn activate_entry(
4436 &mut self,
4437 entry: &ActivatableEntry,
4438 window: &mut Window,
4439 cx: &mut Context<Self>,
4440 ) -> bool {
4441 match entry {
4442 ActivatableEntry::Thread { metadata, .. } => {
4443 let Some(workspace) = self.multi_workspace.upgrade().and_then(|multi_workspace| {
4444 multi_workspace
4445 .read(cx)
4446 .workspace_for_paths(metadata.folder_paths(), None, cx)
4447 }) else {
4448 return false;
4449 };
4450
4451 self.active_entry = Some(ActiveEntry::Thread {
4452 thread_id: metadata.thread_id,
4453 session_id: metadata.session_id.clone(),
4454 workspace: workspace.clone(),
4455 });
4456 self.activate_workspace(&workspace, window, cx);
4457 Self::load_agent_thread_in_workspace(&workspace, metadata, true, window, cx);
4458 true
4459 }
4460 ActivatableEntry::Terminal {
4461 metadata,
4462 workspace,
4463 } => {
4464 self.activate_terminal_entry(
4465 metadata.clone(),
4466 workspace.clone(),
4467 false,
4468 window,
4469 cx,
4470 );
4471 true
4472 }
4473 }
4474 }
4475
4476 fn activate_terminal_entry(
4477 &mut self,
4478 metadata: TerminalThreadMetadata,
4479 workspace: ThreadEntryWorkspace,
4480 retain: bool,
4481 window: &mut Window,
4482 cx: &mut Context<Self>,
4483 ) {
4484 match workspace {
4485 ThreadEntryWorkspace::Open(workspace) => {
4486 self.activate_terminal_in_workspace(&workspace, metadata, retain, window, cx);
4487 }
4488 ThreadEntryWorkspace::Closed {
4489 folder_paths,
4490 project_group_key,
4491 } => {
4492 self.open_workspace_and_activate_terminal(
4493 metadata,
4494 folder_paths,
4495 &project_group_key,
4496 window,
4497 cx,
4498 );
4499 }
4500 }
4501 }
4502
4503 fn load_agent_terminal_in_workspace(
4504 workspace: &Entity<Workspace>,
4505 metadata: &TerminalThreadMetadata,
4506 focus: bool,
4507 window: &mut Window,
4508 cx: &mut App,
4509 ) {
4510 let restore_terminal = |agent_panel: Entity<AgentPanel>,
4511 metadata: &TerminalThreadMetadata,
4512 focus: bool,
4513 workspace: Option<&Workspace>,
4514 window: &mut Window,
4515 cx: &mut App| {
4516 agent_panel.update(cx, |panel, cx| {
4517 panel.restore_terminal(
4518 metadata.clone(),
4519 focus,
4520 AgentThreadSource::Sidebar,
4521 workspace,
4522 window,
4523 cx,
4524 );
4525 });
4526 };
4527
4528 let mut existing_panel = None;
4529 workspace.update(cx, |workspace, cx| {
4530 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
4531 existing_panel = Some(panel);
4532 }
4533 });
4534
4535 if let Some(agent_panel) = existing_panel {
4536 restore_terminal(agent_panel, metadata, focus, None, window, cx);
4537 workspace.update(cx, |workspace, cx| {
4538 if focus {
4539 workspace.focus_panel::<AgentPanel>(window, cx);
4540 } else {
4541 workspace.reveal_panel::<AgentPanel>(window, cx);
4542 }
4543 });
4544 return;
4545 }
4546
4547 let workspace = workspace.downgrade();
4548 let metadata = metadata.clone();
4549 let mut async_window_cx = window.to_async(cx);
4550 cx.spawn(async move |_cx| {
4551 let panel = AgentPanel::load(workspace.clone(), async_window_cx.clone()).await?;
4552
4553 workspace.update_in(&mut async_window_cx, |workspace, window, cx| {
4554 let panel = workspace.panel::<AgentPanel>(cx).unwrap_or_else(|| {
4555 workspace.add_panel(panel.clone(), window, cx);
4556 panel.clone()
4557 });
4558 restore_terminal(panel, &metadata, focus, Some(workspace), window, cx);
4559 if focus {
4560 workspace.focus_panel::<AgentPanel>(window, cx);
4561 } else {
4562 workspace.reveal_panel::<AgentPanel>(window, cx);
4563 }
4564 })?;
4565
4566 anyhow::Ok(())
4567 })
4568 .detach_and_log_err(cx);
4569 }
4570
4571 fn activate_terminal_in_workspace(
4572 &mut self,
4573 workspace: &Entity<Workspace>,
4574 metadata: TerminalThreadMetadata,
4575 retain: bool,
4576 window: &mut Window,
4577 cx: &mut Context<Self>,
4578 ) {
4579 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
4580 return;
4581 };
4582
4583 let terminal_id = metadata.terminal_id;
4584 self.record_terminal_access(terminal_id);
4585 self.active_entry = Some(ActiveEntry::Terminal {
4586 terminal_id,
4587 workspace: workspace.clone(),
4588 });
4589
4590 multi_workspace.update(cx, |multi_workspace, cx| {
4591 multi_workspace.activate(workspace.clone(), None, window, cx);
4592 if retain {
4593 multi_workspace.retain_active_workspace(cx);
4594 }
4595 });
4596
4597 Self::load_agent_terminal_in_workspace(workspace, &metadata, true, window, cx);
4598
4599 self.update_entries(cx);
4600 }
4601
4602 fn open_workspace_and_activate_terminal(
4603 &mut self,
4604 metadata: TerminalThreadMetadata,
4605 folder_paths: PathList,
4606 project_group_key: &ProjectGroupKey,
4607 window: &mut Window,
4608 cx: &mut Context<Self>,
4609 ) {
4610 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
4611 return;
4612 };
4613
4614 let host = project_group_key.host();
4615 let provisional_key = Some(project_group_key.clone());
4616 let active_workspace = multi_workspace.read(cx).workspace().clone();
4617 let modal_workspace = active_workspace.clone();
4618
4619 let open_task = multi_workspace.update(cx, |this, cx| {
4620 this.find_or_create_workspace(
4621 folder_paths,
4622 host,
4623 provisional_key,
4624 |options, window, cx| connect_remote(active_workspace, options, window, cx),
4625 &[],
4626 None,
4627 OpenMode::Activate,
4628 window,
4629 cx,
4630 )
4631 });
4632
4633 cx.spawn_in(window, async move |this, cx| {
4634 let result = open_task.await;
4635 remote_connection::dismiss_connection_modal(&modal_workspace, cx);
4636 let workspace = result?;
4637 this.update_in(cx, |this, window, cx| {
4638 this.activate_terminal_in_workspace(&workspace, metadata, false, window, cx);
4639 })?;
4640 anyhow::Ok(())
4641 })
4642 .detach_and_log_err(cx);
4643 }
4644
4645 fn should_load_closed_workspace_for_archive(
4646 &self,
4647 folder_paths: &PathList,
4648 project_group_key: &ProjectGroupKey,
4649 remote_connection: Option<&RemoteConnectionOptions>,
4650 except_thread_id: Option<ThreadId>,
4651 except_terminal_id: Option<TerminalId>,
4652 cx: &App,
4653 ) -> bool {
4654 if folder_paths.is_empty() || folder_paths == project_group_key.path_list() {
4655 return false;
4656 }
4657
4658 let archive_workspaces = self.archive_workspaces(cx);
4659 let thread_store = ThreadMetadataStore::global(cx);
4660 let thread_store = thread_store.read(cx);
4661 if folder_paths.ordered_paths().any(|path| {
4662 Self::path_is_referenced_by_unarchived_threads_for_archive(
4663 &thread_store,
4664 except_thread_id,
4665 path,
4666 remote_connection,
4667 &archive_workspaces,
4668 cx,
4669 )
4670 }) {
4671 return false;
4672 }
4673
4674 TerminalThreadMetadataStore::try_global(cx).is_none_or(|terminal_store| {
4675 let terminal_store = terminal_store.read(cx);
4676 !folder_paths.ordered_paths().any(|path| {
4677 terminal_store.path_is_referenced_by_terminal(
4678 except_terminal_id,
4679 path,
4680 remote_connection,
4681 )
4682 })
4683 })
4684 }
4685
4686 fn path_is_referenced_by_unarchived_threads_for_archive(
4687 thread_store: &ThreadMetadataStore,
4688 except_thread_id: Option<ThreadId>,
4689 path: &Path,
4690 remote_connection: Option<&RemoteConnectionOptions>,
4691 archive_workspaces: &[Entity<Workspace>],
4692 cx: &App,
4693 ) -> bool {
4694 thread_store.path_is_referenced_by_unarchived_threads_matching(
4695 except_thread_id,
4696 path,
4697 remote_connection,
4698 |thread| Self::thread_blocks_worktree_archive(thread, archive_workspaces, cx),
4699 )
4700 }
4701
4702 fn archive_workspaces(&self, cx: &App) -> Vec<Entity<Workspace>> {
4703 let multi_workspace = self.multi_workspace.upgrade();
4704 thread_worktree_archive::workspaces_for_archive(multi_workspace.as_ref(), cx)
4705 }
4706
4707 fn count_threads_blocking_worktree_archive(
4708 &self,
4709 path_list: &PathList,
4710 remote_connection: Option<&RemoteConnectionOptions>,
4711 except_thread_id: Option<ThreadId>,
4712 cx: &App,
4713 ) -> usize {
4714 let archive_workspaces = self.archive_workspaces(cx);
4715 ThreadMetadataStore::global(cx)
4716 .read(cx)
4717 .entries_for_path(path_list, remote_connection)
4718 .filter(|thread| Some(thread.thread_id) != except_thread_id)
4719 .filter(|thread| Self::thread_blocks_worktree_archive(thread, &archive_workspaces, cx))
4720 .count()
4721 }
4722
4723 fn roots_to_archive_for_paths(
4724 &self,
4725 folder_paths: &PathList,
4726 remote_connection: Option<&RemoteConnectionOptions>,
4727 except_thread_id: Option<ThreadId>,
4728 except_terminal_id: Option<TerminalId>,
4729 cx: &App,
4730 ) -> Vec<thread_worktree_archive::RootPlan> {
4731 let workspaces = self.archive_workspaces(cx);
4732 folder_paths
4733 .ordered_paths()
4734 .filter_map(|path| {
4735 thread_worktree_archive::build_root_plan(path, remote_connection, &workspaces, cx)
4736 })
4737 .filter(|plan| {
4738 let store = ThreadMetadataStore::global(cx);
4739 let store = store.read(cx);
4740 !Self::path_is_referenced_by_unarchived_threads_for_archive(
4741 &store,
4742 except_thread_id,
4743 plan.root_path.as_path(),
4744 remote_connection,
4745 &workspaces,
4746 cx,
4747 )
4748 })
4749 .filter(|root| {
4750 TerminalThreadMetadataStore::try_global(cx).is_none_or(|terminal_store| {
4751 !terminal_store.read(cx).path_is_referenced_by_terminal(
4752 except_terminal_id,
4753 root.root_path.as_path(),
4754 remote_connection,
4755 )
4756 })
4757 })
4758 .collect()
4759 }
4760
4761 fn linked_worktree_workspace_to_remove(
4762 &self,
4763 folder_paths: &PathList,
4764 remote_connection: Option<&RemoteConnectionOptions>,
4765 except_thread_id: Option<ThreadId>,
4766 except_terminal_id: Option<TerminalId>,
4767 roots_to_archive: &[thread_worktree_archive::RootPlan],
4768 cx: &App,
4769 ) -> Option<Entity<Workspace>> {
4770 if folder_paths.is_empty() {
4771 return None;
4772 }
4773
4774 let remaining = self.count_threads_blocking_worktree_archive(
4775 folder_paths,
4776 remote_connection,
4777 except_thread_id,
4778 cx,
4779 );
4780
4781 if remaining > 0 {
4782 return None;
4783 }
4784
4785 let multi_workspace = self.multi_workspace.upgrade()?;
4786 let workspace =
4787 multi_workspace
4788 .read(cx)
4789 .workspace_for_paths(folder_paths, remote_connection, cx)?;
4790
4791 if workspace_has_terminal_metadata_except(&workspace, except_terminal_id, cx) {
4792 return None;
4793 }
4794
4795 if !roots_to_archive.is_empty() {
4796 let archive_paths: HashSet<&Path> = roots_to_archive
4797 .iter()
4798 .map(|root| root.root_path.as_path())
4799 .collect();
4800 let project = workspace.read(cx).project().clone();
4801 let visible_worktree_paths = project
4802 .read(cx)
4803 .visible_worktrees(cx)
4804 .map(|worktree| worktree.read(cx).abs_path())
4805 .collect::<Vec<_>>();
4806 return (!visible_worktree_paths.is_empty()
4807 && visible_worktree_paths
4808 .iter()
4809 .all(|path| archive_paths.contains(path.as_ref())))
4810 .then_some(workspace);
4811 }
4812
4813 let group_key = workspace.read(cx).project_group_key(cx);
4814 (group_key.path_list() != folder_paths).then_some(workspace)
4815 }
4816
4817 fn delete_empty_drafts_for_archive_roots(
4818 &self,
4819 roots: &[thread_worktree_archive::RootPlan],
4820 cx: &mut Context<Self>,
4821 ) {
4822 self.delete_empty_drafts_for_archive_targets(
4823 roots
4824 .iter()
4825 .map(|root| (root.root_path.as_path(), root.remote_connection.as_ref())),
4826 cx,
4827 );
4828 }
4829
4830 fn delete_empty_drafts_for_archive_paths(
4831 &self,
4832 paths: &PathList,
4833 remote_connection: Option<&RemoteConnectionOptions>,
4834 cx: &mut Context<Self>,
4835 ) {
4836 self.delete_empty_drafts_for_archive_targets(
4837 paths
4838 .ordered_paths()
4839 .map(|path| (path.as_path(), remote_connection)),
4840 cx,
4841 );
4842 }
4843
4844 fn delete_empty_drafts_for_archive_targets<'a>(
4845 &self,
4846 targets: impl IntoIterator<Item = (&'a Path, Option<&'a RemoteConnectionOptions>)>,
4847 cx: &mut Context<Self>,
4848 ) {
4849 let targets = targets.into_iter().collect::<Vec<_>>();
4850 if targets.is_empty() {
4851 return;
4852 }
4853
4854 let archive_workspaces = self.archive_workspaces(cx);
4855 let draft_thread_ids = ThreadMetadataStore::global(cx)
4856 .read(cx)
4857 .unarchived_draft_ids_matching(|thread| {
4858 targets.iter().any(|(path, remote_connection)| {
4859 thread.matches_remote_connection(*remote_connection)
4860 && thread.references_folder_path(path)
4861 }) && !Self::thread_blocks_worktree_archive(thread, &archive_workspaces, cx)
4862 });
4863 if draft_thread_ids.is_empty() {
4864 return;
4865 }
4866
4867 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
4868 store.delete_all(draft_thread_ids, cx);
4869 });
4870 }
4871
4872 fn thread_blocks_worktree_archive(
4873 thread: &ThreadMetadata,
4874 archive_workspaces: &[Entity<Workspace>],
4875 cx: &App,
4876 ) -> bool {
4877 if !thread.is_draft() {
4878 return true;
4879 }
4880
4881 agent_ui::draft_prompt_store::draft_has_user_content(
4882 thread.thread_id,
4883 archive_workspaces,
4884 cx,
4885 )
4886 }
4887
4888 async fn wait_for_archive_workspace_metadata(
4889 workspace: &Entity<Workspace>,
4890 cx: &mut gpui::AsyncApp,
4891 ) {
4892 let scans_complete =
4893 workspace.read_with(cx, |workspace, cx| workspace.worktree_scans_complete(cx));
4894 scans_complete.await;
4895
4896 let project = workspace.read_with(cx, |workspace, _| workspace.project().clone());
4897 let barriers = project.update(cx, |project, cx| {
4898 let repositories = project
4899 .repositories(cx)
4900 .values()
4901 .cloned()
4902 .collect::<Vec<_>>();
4903 repositories
4904 .into_iter()
4905 .map(|repository| repository.update(cx, |repository, _| repository.barrier()))
4906 .collect::<Vec<_>>()
4907 });
4908 for barrier in barriers {
4909 let result: anyhow::Result<()> = barrier.await.map_err(|_| {
4910 anyhow::anyhow!("git repository barrier canceled while archiving worktree")
4911 });
4912 result.log_err();
4913 }
4914 }
4915
4916 fn open_workspace_for_archive(
4917 &mut self,
4918 folder_paths: PathList,
4919 project_group_key: ProjectGroupKey,
4920 window: &mut Window,
4921 cx: &mut Context<Self>,
4922 ) -> Option<(Task<anyhow::Result<Entity<Workspace>>>, Entity<Workspace>)> {
4923 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
4924 return None;
4925 };
4926
4927 let host = project_group_key.host();
4928 let active_workspace = multi_workspace.read(cx).workspace().clone();
4929 let modal_workspace = active_workspace.clone();
4930
4931 let open_task = multi_workspace.update(cx, |this, cx| {
4932 this.find_or_create_workspace(
4933 folder_paths,
4934 host,
4935 Some(project_group_key),
4936 |options, window, cx| connect_remote(active_workspace, options, window, cx),
4937 &[],
4938 None,
4939 OpenMode::Add,
4940 window,
4941 cx,
4942 )
4943 });
4944
4945 Some((open_task, modal_workspace))
4946 }
4947
4948 fn open_workspace_and_archive_thread(
4949 &mut self,
4950 session_id: acp::SessionId,
4951 folder_paths: PathList,
4952 project_group_key: ProjectGroupKey,
4953 window: &mut Window,
4954 cx: &mut Context<Self>,
4955 ) {
4956 let Some((open_task, modal_workspace)) =
4957 self.open_workspace_for_archive(folder_paths, project_group_key, window, cx)
4958 else {
4959 return;
4960 };
4961
4962 cx.spawn_in(window, async move |this, cx| {
4963 let result = open_task.await;
4964 remote_connection::dismiss_connection_modal(&modal_workspace, cx);
4965 let workspace = result?;
4966 Self::wait_for_archive_workspace_metadata(&workspace, cx).await;
4967
4968 this.update_in(cx, |this, window, cx| {
4969 this.update_entries(cx);
4970 this.archive_thread(&session_id, window, cx);
4971 })?;
4972 anyhow::Ok(())
4973 })
4974 .detach_and_log_err(cx);
4975 }
4976
4977 fn open_workspace_and_close_terminal(
4978 &mut self,
4979 metadata: TerminalThreadMetadata,
4980 folder_paths: PathList,
4981 project_group_key: ProjectGroupKey,
4982 window: &mut Window,
4983 cx: &mut Context<Self>,
4984 ) {
4985 let Some((open_task, modal_workspace)) =
4986 self.open_workspace_for_archive(folder_paths, project_group_key, window, cx)
4987 else {
4988 return;
4989 };
4990
4991 cx.spawn_in(window, async move |this, cx| {
4992 let result = open_task.await;
4993 remote_connection::dismiss_connection_modal(&modal_workspace, cx);
4994 let workspace = result?;
4995 Self::wait_for_archive_workspace_metadata(&workspace, cx).await;
4996
4997 this.update_in(cx, |this, window, cx| {
4998 let workspace = ThreadEntryWorkspace::Open(workspace);
4999 this.close_terminal(&metadata, &workspace, window, cx);
5000 })?;
5001 anyhow::Ok(())
5002 })
5003 .detach_and_log_err(cx);
5004 }
5005
5006 fn close_terminal(
5007 &mut self,
5008 metadata: &TerminalThreadMetadata,
5009 workspace: &ThreadEntryWorkspace,
5010 window: &mut Window,
5011 cx: &mut Context<Self>,
5012 ) {
5013 if let ThreadEntryWorkspace::Closed {
5014 folder_paths,
5015 project_group_key,
5016 } = workspace
5017 && self.should_load_closed_workspace_for_archive(
5018 folder_paths,
5019 project_group_key,
5020 metadata.remote_connection.as_ref(),
5021 None,
5022 Some(metadata.terminal_id),
5023 cx,
5024 )
5025 {
5026 self.open_workspace_and_close_terminal(
5027 metadata.clone(),
5028 folder_paths.clone(),
5029 project_group_key.clone(),
5030 window,
5031 cx,
5032 );
5033 return;
5034 }
5035
5036 let terminal_id = metadata.terminal_id;
5037 let is_active = self
5038 .active_entry
5039 .as_ref()
5040 .is_some_and(|entry| entry.is_active_terminal(terminal_id));
5041 let neighbor = self
5042 .contents
5043 .entries
5044 .iter()
5045 .position(|entry| {
5046 matches!(
5047 entry,
5048 ListEntry::Terminal(terminal)
5049 if terminal.metadata.terminal_id == terminal_id
5050 )
5051 })
5052 .and_then(|position| self.neighboring_activatable_entry(position));
5053
5054 let terminal_folder_paths = metadata.folder_paths().clone();
5055 let roots_to_archive = self.roots_to_archive_for_paths(
5056 metadata.folder_paths(),
5057 metadata.remote_connection.as_ref(),
5058 None,
5059 Some(terminal_id),
5060 cx,
5061 );
5062
5063 let workspace_to_remove = self.linked_worktree_workspace_to_remove(
5064 &terminal_folder_paths,
5065 metadata.remote_connection.as_ref(),
5066 None,
5067 Some(terminal_id),
5068 &roots_to_archive,
5069 cx,
5070 );
5071
5072 let mut workspaces_to_remove: Vec<Entity<Workspace>> =
5073 workspace_to_remove.into_iter().collect();
5074 let close_item_tasks = self.close_items_for_archived_worktrees(
5075 &roots_to_archive,
5076 &mut workspaces_to_remove,
5077 window,
5078 cx,
5079 );
5080
5081 if !workspaces_to_remove.is_empty() {
5082 let multi_workspace = self.multi_workspace.upgrade().unwrap();
5083 let terminal_workspace_removed = matches!(
5084 workspace,
5085 ThreadEntryWorkspace::Open(workspace) if workspaces_to_remove.contains(workspace)
5086 );
5087 let (fallback_paths, project_group_key) = neighbor
5088 .as_ref()
5089 .map(|neighbor| neighbor.project_location(cx))
5090 .unwrap_or_else(|| {
5091 workspaces_to_remove
5092 .first()
5093 .map(|workspace| {
5094 let key = workspace.read(cx).project_group_key(cx);
5095 (key.path_list().clone(), key)
5096 })
5097 .unwrap_or_default()
5098 });
5099
5100 let excluded = workspaces_to_remove.clone();
5101 let remove_task = multi_workspace.update(cx, |multi_workspace, cx| {
5102 multi_workspace.remove(
5103 workspaces_to_remove,
5104 move |this, window, cx| {
5105 let active_workspace = this.workspace().clone();
5106 this.find_or_create_workspace(
5107 fallback_paths,
5108 project_group_key.host(),
5109 Some(project_group_key),
5110 |options, window, cx| {
5111 connect_remote(active_workspace, options, window, cx)
5112 },
5113 &excluded,
5114 None,
5115 OpenMode::Activate,
5116 window,
5117 cx,
5118 )
5119 },
5120 window,
5121 cx,
5122 )
5123 });
5124
5125 let metadata = metadata.clone();
5126 let workspace = workspace.clone();
5127 cx.spawn_in(window, async move |this, cx| {
5128 if !remove_task.await? {
5129 return anyhow::Ok(());
5130 }
5131
5132 for task in close_item_tasks {
5133 let result: anyhow::Result<()> = task.await;
5134 result.log_err();
5135 }
5136
5137 this.update_in(cx, |this, window, cx| {
5138 if terminal_workspace_removed {
5139 this.delete_empty_drafts_for_archive_paths(
5140 metadata.folder_paths(),
5141 metadata.remote_connection.as_ref(),
5142 cx,
5143 );
5144 }
5145 // If the terminal's workspace has already been removed,
5146 // don't synthesize a fallback draft in the detached
5147 // AgentPanel.
5148 this.close_terminal_entry(
5149 &metadata,
5150 &workspace,
5151 is_active,
5152 neighbor.as_ref(),
5153 !terminal_workspace_removed,
5154 roots_to_archive,
5155 window,
5156 cx,
5157 );
5158 })?;
5159 anyhow::Ok(())
5160 })
5161 .detach_and_log_err(cx);
5162 } else if !close_item_tasks.is_empty() {
5163 let metadata = metadata.clone();
5164 let workspace = workspace.clone();
5165 cx.spawn_in(window, async move |this, cx| {
5166 for task in close_item_tasks {
5167 let result: anyhow::Result<()> = task.await;
5168 result.log_err();
5169 }
5170
5171 this.update_in(cx, |this, window, cx| {
5172 this.close_terminal_entry(
5173 &metadata,
5174 &workspace,
5175 is_active,
5176 neighbor.as_ref(),
5177 true,
5178 roots_to_archive,
5179 window,
5180 cx,
5181 );
5182 })?;
5183 anyhow::Ok(())
5184 })
5185 .detach_and_log_err(cx);
5186 } else {
5187 self.close_terminal_entry(
5188 metadata,
5189 workspace,
5190 is_active,
5191 neighbor.as_ref(),
5192 true,
5193 roots_to_archive,
5194 window,
5195 cx,
5196 );
5197 }
5198 }
5199
5200 fn close_terminal_entry(
5201 &mut self,
5202 metadata: &TerminalThreadMetadata,
5203 workspace: &ThreadEntryWorkspace,
5204 is_active: bool,
5205 neighbor: Option<&ActivatableEntry>,
5206 activate_panel_draft: bool,
5207 roots_to_archive: Vec<thread_worktree_archive::RootPlan>,
5208 window: &mut Window,
5209 cx: &mut Context<Self>,
5210 ) {
5211 let terminal_id = metadata.terminal_id;
5212 let defer_draft_activation = activate_panel_draft && is_active && neighbor.is_some();
5213
5214 // Closing from the sidebar must not steal focus, since the row's
5215 // workspace may not be the active workspace.
5216 if let ThreadEntryWorkspace::Open(workspace) = workspace {
5217 workspace.update(cx, |workspace, cx| {
5218 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
5219 panel.update(cx, |panel, cx| {
5220 if defer_draft_activation || !activate_panel_draft {
5221 panel.close_terminal_without_activating_draft(terminal_id, window, cx);
5222 } else {
5223 panel.close_terminal(terminal_id, window, cx);
5224 }
5225 });
5226 }
5227 });
5228 }
5229 if let Some(store) = TerminalThreadMetadataStore::try_global(cx) {
5230 store.update(cx, |store, cx| {
5231 store.delete(terminal_id, cx);
5232 });
5233 }
5234
5235 self.start_detached_archive_worktree_task(roots_to_archive, cx);
5236
5237 if is_active {
5238 self.active_entry = None;
5239 if neighbor
5240 .as_ref()
5241 .is_some_and(|neighbor| self.activate_entry(neighbor, window, cx))
5242 {
5243 return;
5244 }
5245 if defer_draft_activation && let ThreadEntryWorkspace::Open(workspace) = workspace {
5246 workspace.update(cx, |workspace, cx| {
5247 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
5248 panel.update(cx, |panel, cx| {
5249 panel.activate_draft(false, AgentThreadSource::AgentPanel, window, cx);
5250 });
5251 }
5252 });
5253 }
5254 self.sync_active_entry_from_active_workspace(cx);
5255 }
5256 self.update_entries(cx);
5257 }
5258
5259 fn close_items_for_archived_worktrees(
5260 &self,
5261 roots_to_archive: &[thread_worktree_archive::RootPlan],
5262 workspaces_to_remove: &mut Vec<Entity<Workspace>>,
5263 window: &mut Window,
5264 cx: &mut Context<Self>,
5265 ) -> Vec<Task<anyhow::Result<()>>> {
5266 if roots_to_archive.is_empty() {
5267 return Vec::new();
5268 }
5269
5270 let archive_paths: HashSet<&Path> = roots_to_archive
5271 .iter()
5272 .map(|root| root.root_path.as_path())
5273 .collect();
5274
5275 let mut mixed_workspaces: Vec<(Entity<Workspace>, Vec<WorktreeId>)> = Vec::new();
5276
5277 if let Some(multi_workspace) = self.multi_workspace.upgrade() {
5278 let all_workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect();
5279
5280 for workspace in all_workspaces {
5281 if workspaces_to_remove.contains(&workspace) {
5282 continue;
5283 }
5284
5285 let project = workspace.read(cx).project().read(cx);
5286 let visible_worktrees: Vec<_> = project
5287 .visible_worktrees(cx)
5288 .map(|worktree| (worktree.read(cx).id(), worktree.read(cx).abs_path()))
5289 .collect();
5290
5291 let archived_worktree_ids: Vec<WorktreeId> = visible_worktrees
5292 .iter()
5293 .filter(|(_, path)| archive_paths.contains(path.as_ref()))
5294 .map(|(id, _)| *id)
5295 .collect();
5296
5297 if archived_worktree_ids.is_empty() {
5298 continue;
5299 }
5300
5301 if visible_worktrees.len() == archived_worktree_ids.len() {
5302 workspaces_to_remove.push(workspace);
5303 } else {
5304 mixed_workspaces.push((workspace, archived_worktree_ids));
5305 }
5306 }
5307 }
5308
5309 let mut close_item_tasks = Vec::new();
5310 for (workspace, archived_worktree_ids) in &mixed_workspaces {
5311 let panes: Vec<_> = workspace.read(cx).panes().to_vec();
5312 for pane in panes {
5313 let items_to_close: Vec<EntityId> = pane
5314 .read(cx)
5315 .items()
5316 .filter(|item| {
5317 item.project_path(cx)
5318 .is_some_and(|pp| archived_worktree_ids.contains(&pp.worktree_id))
5319 })
5320 .map(|item| item.item_id())
5321 .collect();
5322
5323 if !items_to_close.is_empty() {
5324 let task = pane.update(cx, |pane, cx| {
5325 pane.close_items(window, cx, SaveIntent::Close, &|item_id| {
5326 items_to_close.contains(&item_id)
5327 })
5328 });
5329 close_item_tasks.push(task);
5330 }
5331 }
5332 }
5333
5334 close_item_tasks
5335 }
5336
5337 fn archive_thread(
5338 &mut self,
5339 session_id: &acp::SessionId,
5340 window: &mut Window,
5341 cx: &mut Context<Self>,
5342 ) {
5343 let store = ThreadMetadataStore::global(cx);
5344 let metadata = store.read(cx).entry_by_session(session_id).cloned();
5345 let metadata_thread_id = metadata.as_ref().map(|metadata| metadata.thread_id);
5346 let thread_entry = self.contents.entries.iter().find_map(|entry| match entry {
5347 ListEntry::Thread(thread) => metadata_thread_id
5348 .map_or_else(
5349 || thread.metadata.session_id.as_ref() == Some(session_id),
5350 |thread_id| thread.metadata.thread_id == thread_id,
5351 )
5352 .then(|| thread.clone()),
5353 _ => None,
5354 });
5355 let thread_id = metadata_thread_id.or_else(|| {
5356 thread_entry
5357 .as_ref()
5358 .map(|thread| thread.metadata.thread_id)
5359 });
5360 let active_workspace = thread_id.and_then(|thread_id| {
5361 self.active_entry.as_ref().and_then(|entry| {
5362 if entry.is_active_thread(&thread_id) {
5363 Some(entry.workspace().clone())
5364 } else {
5365 None
5366 }
5367 })
5368 });
5369 let thread_folder_paths = metadata
5370 .as_ref()
5371 .map(|metadata| metadata.folder_paths().clone())
5372 .or_else(|| {
5373 thread_entry
5374 .as_ref()
5375 .map(|thread| thread.metadata.folder_paths().clone())
5376 })
5377 .or_else(|| {
5378 active_workspace
5379 .as_ref()
5380 .map(|workspace| PathList::new(&workspace.read(cx).root_paths(cx)))
5381 });
5382 let thread_entry_workspace = thread_entry.map(|thread| thread.workspace.clone());
5383
5384 if let (
5385 Some(metadata),
5386 Some(ThreadEntryWorkspace::Closed {
5387 folder_paths,
5388 project_group_key,
5389 }),
5390 ) = (metadata.as_ref(), thread_entry_workspace)
5391 && self.should_load_closed_workspace_for_archive(
5392 &folder_paths,
5393 &project_group_key,
5394 metadata.remote_connection.as_ref(),
5395 Some(metadata.thread_id),
5396 None,
5397 cx,
5398 )
5399 {
5400 self.open_workspace_and_archive_thread(
5401 session_id.clone(),
5402 folder_paths,
5403 project_group_key,
5404 window,
5405 cx,
5406 );
5407 return;
5408 }
5409
5410 // Compute which linked worktree roots should be archived from disk if
5411 // this thread is archived. This must happen before we remove any
5412 // workspace from the MultiWorkspace, because `build_root_plan` needs
5413 // the currently open workspaces in order to find the affected projects
5414 // and repository handles for each linked worktree.
5415 let roots_to_archive = metadata
5416 .as_ref()
5417 .map(|metadata| {
5418 self.roots_to_archive_for_paths(
5419 metadata.folder_paths(),
5420 metadata.remote_connection.as_ref(),
5421 thread_id,
5422 None,
5423 cx,
5424 )
5425 })
5426 .unwrap_or_default();
5427
5428 let current_pos = self.contents.entries.iter().position(|entry| match entry {
5429 ListEntry::Thread(thread) => thread_id.map_or_else(
5430 || thread.metadata.session_id.as_ref() == Some(session_id),
5431 |tid| thread.metadata.thread_id == tid,
5432 ),
5433 _ => false,
5434 });
5435 let neighbor =
5436 current_pos.and_then(|position| self.neighboring_activatable_entry(position));
5437
5438 // Check if archiving this thread would leave its worktree workspace
5439 // with no threads, requiring workspace removal.
5440 let workspace_to_remove = thread_folder_paths.as_ref().and_then(|folder_paths| {
5441 let thread_remote_connection =
5442 metadata.as_ref().and_then(|m| m.remote_connection.as_ref());
5443 self.linked_worktree_workspace_to_remove(
5444 folder_paths,
5445 thread_remote_connection,
5446 thread_id,
5447 None,
5448 &roots_to_archive,
5449 cx,
5450 )
5451 });
5452
5453 // Also find workspaces for root plans that aren't covered by
5454 // workspace_to_remove. For workspaces that exclusively contain
5455 // worktrees being archived, remove the whole workspace. For
5456 // "mixed" workspaces (containing both archived and non-archived
5457 // worktrees), close only the editor items referencing the
5458 // archived worktrees so their Entity<Worktree> handles are
5459 // dropped without destroying the user's workspace layout.
5460 let mut workspaces_to_remove: Vec<Entity<Workspace>> =
5461 workspace_to_remove.into_iter().collect();
5462 let close_item_tasks = self.close_items_for_archived_worktrees(
5463 &roots_to_archive,
5464 &mut workspaces_to_remove,
5465 window,
5466 cx,
5467 );
5468
5469 if !workspaces_to_remove.is_empty() {
5470 let multi_workspace = self.multi_workspace.upgrade().unwrap();
5471 let session_id = session_id.clone();
5472
5473 let (fallback_paths, project_group_key) = neighbor
5474 .as_ref()
5475 .map(|neighbor| neighbor.project_location(cx))
5476 .unwrap_or_else(|| {
5477 workspaces_to_remove
5478 .first()
5479 .map(|workspace| {
5480 let key = workspace.read(cx).project_group_key(cx);
5481 (key.path_list().clone(), key)
5482 })
5483 .unwrap_or_default()
5484 });
5485
5486 let excluded = workspaces_to_remove.clone();
5487 let remove_task = multi_workspace.update(cx, |mw, cx| {
5488 mw.remove(
5489 workspaces_to_remove,
5490 move |this, window, cx| {
5491 let active_workspace = this.workspace().clone();
5492 this.find_or_create_workspace(
5493 fallback_paths,
5494 project_group_key.host(),
5495 Some(project_group_key),
5496 |options, window, cx| {
5497 connect_remote(active_workspace, options, window, cx)
5498 },
5499 &excluded,
5500 None,
5501 OpenMode::Activate,
5502 window,
5503 cx,
5504 )
5505 },
5506 window,
5507 cx,
5508 )
5509 });
5510
5511 let thread_folder_paths = thread_folder_paths.clone();
5512 let thread_remote_connection = metadata
5513 .as_ref()
5514 .and_then(|metadata| metadata.remote_connection.clone());
5515 cx.spawn_in(window, async move |this, cx| {
5516 if !remove_task.await? {
5517 return anyhow::Ok(());
5518 }
5519
5520 for task in close_item_tasks {
5521 let result: anyhow::Result<()> = task.await;
5522 result.log_err();
5523 }
5524
5525 this.update_in(cx, |this, window, cx| {
5526 if let Some(thread_folder_paths) = thread_folder_paths.as_ref() {
5527 this.delete_empty_drafts_for_archive_paths(
5528 thread_folder_paths,
5529 thread_remote_connection.as_ref(),
5530 cx,
5531 );
5532 }
5533 let in_flight = thread_id.and_then(|tid| {
5534 this.start_archive_worktree_task(tid, roots_to_archive, cx)
5535 });
5536 this.archive_and_activate(
5537 &session_id,
5538 thread_id,
5539 neighbor.as_ref(),
5540 thread_folder_paths.as_ref(),
5541 thread_remote_connection.as_ref(),
5542 in_flight,
5543 window,
5544 cx,
5545 );
5546 })?;
5547 anyhow::Ok(())
5548 })
5549 .detach_and_log_err(cx);
5550 } else if !close_item_tasks.is_empty() {
5551 let session_id = session_id.clone();
5552 let thread_folder_paths = thread_folder_paths.clone();
5553 let thread_remote_connection = metadata
5554 .as_ref()
5555 .and_then(|metadata| metadata.remote_connection.clone());
5556 cx.spawn_in(window, async move |this, cx| {
5557 for task in close_item_tasks {
5558 let result: anyhow::Result<()> = task.await;
5559 result.log_err();
5560 }
5561
5562 this.update_in(cx, |this, window, cx| {
5563 let in_flight = thread_id.and_then(|tid| {
5564 this.start_archive_worktree_task(tid, roots_to_archive, cx)
5565 });
5566 this.archive_and_activate(
5567 &session_id,
5568 thread_id,
5569 neighbor.as_ref(),
5570 thread_folder_paths.as_ref(),
5571 thread_remote_connection.as_ref(),
5572 in_flight,
5573 window,
5574 cx,
5575 );
5576 })?;
5577 anyhow::Ok(())
5578 })
5579 .detach_and_log_err(cx);
5580 } else {
5581 let in_flight = thread_id
5582 .and_then(|tid| self.start_archive_worktree_task(tid, roots_to_archive, cx));
5583 self.archive_and_activate(
5584 session_id,
5585 thread_id,
5586 neighbor.as_ref(),
5587 thread_folder_paths.as_ref(),
5588 metadata
5589 .as_ref()
5590 .and_then(|metadata| metadata.remote_connection.as_ref()),
5591 in_flight,
5592 window,
5593 cx,
5594 );
5595 }
5596 }
5597
5598 /// Archive a thread and activate the nearest neighbor or a draft.
5599 ///
5600 /// IMPORTANT: when activating a neighbor or creating a fallback draft,
5601 /// this method also activates the target workspace in the MultiWorkspace.
5602 /// This is critical because `rebuild_contents` derives the active
5603 /// workspace from `mw.workspace()`. If the linked worktree workspace is
5604 /// still active after archiving its last thread, `rebuild_contents` sees
5605 /// the threadless linked worktree as active and emits a spurious
5606 /// "+ New Thread" entry with the worktree chip — keeping the worktree
5607 /// alive and preventing disk cleanup.
5608 ///
5609 /// When `in_flight_archive` is present, it is the background task that
5610 /// persists the linked worktree's git state and deletes it from disk.
5611 /// We attach it to the metadata store at the same time we mark the thread
5612 /// archived so failures can automatically unarchive the thread and user-
5613 /// initiated unarchive can cancel the task.
5614 fn archive_and_activate(
5615 &mut self,
5616 _session_id: &acp::SessionId,
5617 thread_id: Option<agent_ui::ThreadId>,
5618 neighbor: Option<&ActivatableEntry>,
5619 thread_folder_paths: Option<&PathList>,
5620 thread_remote_connection: Option<&RemoteConnectionOptions>,
5621 in_flight_archive: Option<(Task<()>, async_channel::Sender<()>)>,
5622 window: &mut Window,
5623 cx: &mut Context<Self>,
5624 ) {
5625 if let Some(thread_id) = thread_id {
5626 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
5627 store.archive(thread_id, in_flight_archive, cx);
5628 });
5629 }
5630
5631 let is_active = self
5632 .active_entry
5633 .as_ref()
5634 .is_some_and(|entry| thread_id.is_some_and(|tid| entry.is_active_thread(&tid)));
5635
5636 if is_active {
5637 self.active_entry = None;
5638 }
5639
5640 if !is_active {
5641 // The user is looking at a different thread/draft. Clear the
5642 // archived thread from its workspace's panel so that switching
5643 // to that workspace later doesn't show a stale thread.
5644 if let Some(folder_paths) = thread_folder_paths {
5645 if let Some(workspace) = self.multi_workspace.upgrade().and_then(|mw| {
5646 mw.read(cx)
5647 .workspace_for_paths(folder_paths, thread_remote_connection, cx)
5648 }) {
5649 if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
5650 let panel_shows_archived = panel
5651 .read(cx)
5652 .active_conversation_view()
5653 .map(|cv| cv.read(cx).parent_id())
5654 .is_some_and(|live_thread_id| {
5655 thread_id.is_some_and(|id| id == live_thread_id)
5656 });
5657 if panel_shows_archived {
5658 panel.update(cx, |panel, cx| {
5659 panel.clear_base_view(window, cx);
5660 });
5661 }
5662 }
5663 }
5664 }
5665 return;
5666 }
5667
5668 if neighbor.is_some_and(|neighbor| self.activate_entry(neighbor, window, cx)) {
5669 return;
5670 }
5671
5672 // No neighbor or its workspace isn't open — just clear the
5673 // panel so the group is left empty.
5674 if let Some(folder_paths) = thread_folder_paths {
5675 let workspace = self.multi_workspace.upgrade().and_then(|mw| {
5676 mw.read(cx)
5677 .workspace_for_paths(folder_paths, thread_remote_connection, cx)
5678 });
5679 if let Some(workspace) = workspace {
5680 if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
5681 panel.update(cx, |panel, cx| {
5682 panel.clear_base_view(window, cx);
5683 });
5684 }
5685 }
5686 }
5687 }
5688
5689 fn start_archive_worktree_task(
5690 &self,
5691 thread_id: ThreadId,
5692 roots: Vec<thread_worktree_archive::RootPlan>,
5693 cx: &mut Context<Self>,
5694 ) -> Option<(Task<()>, async_channel::Sender<()>)> {
5695 if roots.is_empty() {
5696 return None;
5697 }
5698
5699 self.delete_empty_drafts_for_archive_roots(&roots, cx);
5700
5701 let (cancel_tx, cancel_rx) = async_channel::bounded::<()>(1);
5702 let task = cx.spawn(async move |_this, cx| {
5703 match Self::archive_worktree_roots(roots, cancel_rx, cx).await {
5704 Ok(ArchiveWorktreeOutcome::Success) => {
5705 cx.update(|cx| {
5706 ThreadMetadataStore::global(cx).update(cx, |store, _cx| {
5707 store.cleanup_completed_archive(thread_id);
5708 });
5709 });
5710 }
5711 Ok(ArchiveWorktreeOutcome::Cancelled) => {}
5712 Err(error) => {
5713 log::error!("Failed to archive worktree: {error:#}");
5714 cx.update(|cx| {
5715 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
5716 store.unarchive(thread_id, cx);
5717 });
5718 });
5719 }
5720 }
5721 });
5722
5723 Some((task, cancel_tx))
5724 }
5725
5726 fn start_detached_archive_worktree_task(
5727 &self,
5728 roots: Vec<thread_worktree_archive::RootPlan>,
5729 cx: &mut Context<Self>,
5730 ) {
5731 if roots.is_empty() {
5732 return;
5733 }
5734
5735 self.delete_empty_drafts_for_archive_roots(&roots, cx);
5736
5737 let (cancel_tx, cancel_rx) = async_channel::bounded::<()>(1);
5738 cx.spawn(async move |_this, cx| {
5739 let outcome = Self::archive_worktree_roots(roots, cancel_rx, cx).await;
5740 drop(cancel_tx);
5741 match outcome {
5742 Ok(ArchiveWorktreeOutcome::Success | ArchiveWorktreeOutcome::Cancelled) => {}
5743 Err(error) => {
5744 log::error!("Failed to archive worktree after closing sidebar item: {error:#}");
5745 }
5746 }
5747 })
5748 .detach();
5749 }
5750
5751 async fn archive_worktree_roots(
5752 roots: Vec<thread_worktree_archive::RootPlan>,
5753 cancel_rx: async_channel::Receiver<()>,
5754 cx: &mut gpui::AsyncApp,
5755 ) -> anyhow::Result<ArchiveWorktreeOutcome> {
5756 let mut completed_persists: Vec<(i64, thread_worktree_archive::RootPlan)> = Vec::new();
5757
5758 for root in &roots {
5759 if cancel_rx.is_closed() {
5760 for &(id, ref completed_root) in completed_persists.iter().rev() {
5761 thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
5762 }
5763 return Ok(ArchiveWorktreeOutcome::Cancelled);
5764 }
5765
5766 match thread_worktree_archive::persist_worktree_state(root, cx).await {
5767 Ok(id) => {
5768 completed_persists.push((id, root.clone()));
5769 }
5770 Err(error) => {
5771 for &(id, ref completed_root) in completed_persists.iter().rev() {
5772 thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
5773 }
5774 return Err(error);
5775 }
5776 }
5777
5778 if cancel_rx.is_closed() {
5779 for &(id, ref completed_root) in completed_persists.iter().rev() {
5780 thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
5781 }
5782 return Ok(ArchiveWorktreeOutcome::Cancelled);
5783 }
5784
5785 if let Err(error) = thread_worktree_archive::remove_root(root.clone(), cx).await {
5786 if let Some(&(id, ref completed_root)) = completed_persists.last() {
5787 if completed_root.root_path == root.root_path {
5788 thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
5789 completed_persists.pop();
5790 }
5791 }
5792 for &(id, ref completed_root) in completed_persists.iter().rev() {
5793 thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
5794 }
5795 return Err(error);
5796 }
5797 }
5798
5799 Ok(ArchiveWorktreeOutcome::Success)
5800 }
5801
5802 fn activate_workspace(
5803 &self,
5804 workspace: &Entity<Workspace>,
5805 window: &mut Window,
5806 cx: &mut Context<Self>,
5807 ) {
5808 if let Some(multi_workspace) = self.multi_workspace.upgrade() {
5809 multi_workspace.update(cx, |mw, cx| {
5810 mw.activate(workspace.clone(), None, window, cx);
5811 });
5812 }
5813 }
5814
5815 fn archive_selected_thread(
5816 &mut self,
5817 _: &ArchiveSelectedThread,
5818 window: &mut Window,
5819 cx: &mut Context<Self>,
5820 ) {
5821 let Some(ix) = self.selection else {
5822 return;
5823 };
5824 match self.contents.entries.get(ix) {
5825 Some(ListEntry::Thread(thread)) => {
5826 match thread.status {
5827 AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation => {
5828 return;
5829 }
5830 AgentThreadStatus::Completed | AgentThreadStatus::Error => {}
5831 }
5832 if thread.draft.is_some() {
5833 let workspace = thread.workspace.clone();
5834 let draft_id = thread.metadata.thread_id;
5835 self.remove_draft(draft_id, &workspace, window, cx);
5836 } else if let Some(session_id) = thread.metadata.session_id.clone() {
5837 self.archive_thread(&session_id, window, cx);
5838 }
5839 }
5840 Some(ListEntry::Terminal(terminal)) => {
5841 let metadata = terminal.metadata.clone();
5842 let workspace = terminal.workspace.clone();
5843 self.close_terminal(&metadata, &workspace, window, cx);
5844 }
5845 _ => {}
5846 }
5847 }
5848
5849 fn rename_selected_thread(
5850 &mut self,
5851 _: &RenameSelectedThread,
5852 window: &mut Window,
5853 cx: &mut Context<Self>,
5854 ) {
5855 let Some(ix) = self.selection else {
5856 return;
5857 };
5858 let Some(ListEntry::Thread(thread)) = self.contents.entries.get(ix) else {
5859 return;
5860 };
5861 let thread_id = thread.metadata.thread_id;
5862 let title = thread.metadata.display_title();
5863 self.start_renaming_thread(ix, thread_id, title, window, cx);
5864 }
5865
5866 fn record_thread_access(&mut self, id: &ThreadId) {
5867 self.thread_last_accessed.insert(*id, Utc::now());
5868 }
5869
5870 fn record_terminal_access(&mut self, id: TerminalId) {
5871 self.terminal_last_accessed.insert(id, Utc::now());
5872 }
5873
5874 fn record_thread_interacted(&mut self, thread_id: &agent_ui::ThreadId, cx: &mut App) {
5875 let store = ThreadMetadataStore::global(cx);
5876 store.update(cx, |store, cx| {
5877 store.update_interacted_at(thread_id, Utc::now(), cx);
5878 })
5879 }
5880
5881 fn thread_display_time(metadata: &ThreadMetadata) -> DateTime<Utc> {
5882 metadata.interacted_at.unwrap_or(metadata.updated_at)
5883 }
5884
5885 fn push_entries_by_display_time(
5886 entries: &mut Vec<ListEntry>,
5887 terminals: Vec<TerminalEntry>,
5888 threads: Vec<Arc<ThreadEntry>>,
5889 current_session_ids: &mut HashSet<acp::SessionId>,
5890 current_thread_ids: &mut HashSet<agent_ui::ThreadId>,
5891 ) {
5892 fn display_time(entry: &ListEntry) -> DateTime<Utc> {
5893 match entry {
5894 ListEntry::Thread(thread) if thread.draft == Some(DraftKind::Empty) => {
5895 DateTime::<Utc>::MAX_UTC
5896 }
5897 ListEntry::Thread(thread) => Sidebar::thread_display_time(&thread.metadata),
5898 ListEntry::Terminal(terminal) => terminal.metadata.created_at,
5899 ListEntry::ProjectHeader { .. } => unreachable!(),
5900 }
5901 }
5902
5903 let row_entries = terminals
5904 .into_iter()
5905 .map(ListEntry::Terminal)
5906 .chain(threads.into_iter().map(ListEntry::Thread))
5907 .sorted_by_key(|right| std::cmp::Reverse(display_time(right)));
5908
5909 for entry in row_entries {
5910 if let ListEntry::Thread(thread) = &entry {
5911 if let Some(session_id) = &thread.metadata.session_id {
5912 current_session_ids.insert(session_id.clone());
5913 }
5914 current_thread_ids.insert(thread.metadata.thread_id);
5915 }
5916 entries.push(entry);
5917 }
5918 }
5919
5920 /// The sort order used by the ctrl-tab switcher
5921 fn switcher_entry_cmp(
5922 &self,
5923 left: &ThreadSwitcherEntry,
5924 right: &ThreadSwitcherEntry,
5925 ) -> Ordering {
5926 let sort_time = |entry: &ThreadSwitcherEntry| match entry {
5927 ThreadSwitcherEntry::Thread(entry) => self
5928 .thread_last_accessed
5929 .get(&entry.metadata.thread_id)
5930 .copied()
5931 .or(entry.metadata.interacted_at)
5932 .unwrap_or(entry.metadata.updated_at),
5933 ThreadSwitcherEntry::Terminal(entry) => self
5934 .terminal_last_accessed
5935 .get(&entry.metadata.terminal_id)
5936 .copied()
5937 .unwrap_or(entry.metadata.created_at),
5938 };
5939
5940 // .reverse() = most recent first
5941 sort_time(left).cmp(&sort_time(right)).reverse()
5942 }
5943
5944 fn mru_entries_for_switcher(&self, cx: &App) -> Vec<ThreadSwitcherEntry> {
5945 let mut current_header_label: Option<SharedString> = None;
5946 let mut current_header_key: Option<ProjectGroupKey> = None;
5947 let mut entries: Vec<ThreadSwitcherEntry> = self
5948 .contents
5949 .entries
5950 .iter()
5951 .filter_map(|entry| match entry {
5952 ListEntry::ProjectHeader { label, key, .. } => {
5953 current_header_label = Some(label.clone());
5954 current_header_key = Some(key.clone());
5955 None
5956 }
5957 ListEntry::Thread(thread) => {
5958 if thread.draft == Some(DraftKind::Empty) {
5959 return None;
5960 }
5961 let workspace = match &thread.workspace {
5962 ThreadEntryWorkspace::Open(workspace) => Some(workspace.clone()),
5963 ThreadEntryWorkspace::Closed { .. } => {
5964 current_header_key.as_ref().and_then(|key| {
5965 self.multi_workspace.upgrade().and_then(|mw| {
5966 mw.read(cx).workspace_for_paths(
5967 key.path_list(),
5968 key.host().as_ref(),
5969 cx,
5970 )
5971 })
5972 })
5973 }
5974 }?;
5975 let notified = self.contents.is_thread_notified(&thread.metadata.thread_id);
5976 let timestamp: SharedString =
5977 format_history_entry_timestamp(Self::thread_display_time(&thread.metadata))
5978 .into();
5979 Some(ThreadSwitcherEntry::Thread(ThreadSwitcherThreadEntry {
5980 title: thread.metadata.display_title(),
5981 icon: thread.icon,
5982 icon_from_external_svg: thread.icon_from_external_svg.clone(),
5983 status: thread.status,
5984 metadata: thread.metadata.clone(),
5985 workspace,
5986 project_name: current_header_label.clone(),
5987 worktrees: thread
5988 .worktrees
5989 .iter()
5990 .cloned()
5991 .map(|mut wt| {
5992 wt.highlight_positions = Vec::new();
5993 wt
5994 })
5995 .collect(),
5996 diff_stats: thread.diff_stats,
5997 is_draft: thread.draft.is_some(),
5998 is_title_generating: thread.is_title_generating,
5999 notified,
6000 timestamp,
6001 }))
6002 }
6003 ListEntry::Terminal(terminal) => {
6004 let timestamp: SharedString =
6005 format_history_entry_timestamp(terminal.metadata.created_at).into();
6006 Some(ThreadSwitcherEntry::Terminal(ThreadSwitcherTerminalEntry {
6007 metadata: terminal.metadata.clone(),
6008 workspace: terminal.workspace.clone(),
6009 project_name: current_header_label.clone(),
6010 worktrees: terminal
6011 .worktrees
6012 .iter()
6013 .cloned()
6014 .map(|mut wt| {
6015 wt.highlight_positions = Vec::new();
6016 wt
6017 })
6018 .collect(),
6019 notified: self
6020 .contents
6021 .is_terminal_notified(terminal.metadata.terminal_id),
6022 timestamp,
6023 }))
6024 }
6025 })
6026 .collect();
6027
6028 entries.sort_by(|a, b| self.switcher_entry_cmp(a, b));
6029
6030 entries
6031 }
6032
6033 fn dismiss_thread_switcher(&mut self, cx: &mut Context<Self>) {
6034 self.thread_switcher = None;
6035 self._thread_switcher_subscriptions.clear();
6036 if let Some(mw) = self.multi_workspace.upgrade() {
6037 mw.update(cx, |mw, cx| {
6038 mw.set_sidebar_overlay(None, cx);
6039 });
6040 }
6041 }
6042
6043 fn on_toggle_thread_switcher(
6044 &mut self,
6045 action: &ToggleThreadSwitcher,
6046 window: &mut Window,
6047 cx: &mut Context<Self>,
6048 ) {
6049 self.toggle_thread_switcher_impl(action.select_last, window, cx);
6050 }
6051
6052 fn preview_switcher_selection(
6053 &mut self,
6054 selection: &ThreadSwitcherSelection,
6055 window: &mut Window,
6056 cx: &mut Context<Self>,
6057 ) {
6058 match selection {
6059 ThreadSwitcherSelection::Thread {
6060 metadata,
6061 workspace,
6062 } => {
6063 if let Some(multi_workspace) = self.multi_workspace.upgrade() {
6064 multi_workspace.update(cx, |multi_workspace, cx| {
6065 multi_workspace.activate(workspace.clone(), None, window, cx);
6066 });
6067 }
6068 self.active_entry = Some(ActiveEntry::Thread {
6069 thread_id: metadata.thread_id,
6070 session_id: metadata.session_id.clone(),
6071 workspace: workspace.clone(),
6072 });
6073 self.update_entries(cx);
6074 Self::load_agent_thread_in_workspace(workspace, metadata, false, window, cx);
6075 }
6076 ThreadSwitcherSelection::Terminal {
6077 metadata,
6078 workspace,
6079 } => {
6080 if let ThreadEntryWorkspace::Open(workspace) = workspace {
6081 if let Some(multi_workspace) = self.multi_workspace.upgrade() {
6082 multi_workspace.update(cx, |multi_workspace, cx| {
6083 multi_workspace.activate(workspace.clone(), None, window, cx);
6084 });
6085 }
6086 self.active_entry = Some(ActiveEntry::Terminal {
6087 terminal_id: metadata.terminal_id,
6088 workspace: workspace.clone(),
6089 });
6090 self.update_entries(cx);
6091 Self::load_agent_terminal_in_workspace(workspace, metadata, false, window, cx);
6092 }
6093 }
6094 }
6095 }
6096
6097 fn confirm_switcher_selection(
6098 &mut self,
6099 selection: &ThreadSwitcherSelection,
6100 window: &mut Window,
6101 cx: &mut Context<Self>,
6102 ) {
6103 match selection {
6104 ThreadSwitcherSelection::Thread {
6105 metadata,
6106 workspace,
6107 } => {
6108 if let Some(multi_workspace) = self.multi_workspace.upgrade() {
6109 multi_workspace.update(cx, |multi_workspace, cx| {
6110 multi_workspace.activate(workspace.clone(), None, window, cx);
6111 multi_workspace.retain_active_workspace(cx);
6112 });
6113 }
6114 self.record_thread_access(&metadata.thread_id);
6115 self.active_entry = Some(ActiveEntry::Thread {
6116 thread_id: metadata.thread_id,
6117 session_id: metadata.session_id.clone(),
6118 workspace: workspace.clone(),
6119 });
6120 self.update_entries(cx);
6121 self.dismiss_thread_switcher(cx);
6122 Self::load_agent_thread_in_workspace(workspace, metadata, true, window, cx);
6123 }
6124 ThreadSwitcherSelection::Terminal {
6125 metadata,
6126 workspace,
6127 } => {
6128 self.dismiss_thread_switcher(cx);
6129 self.activate_terminal_entry(metadata.clone(), workspace.clone(), true, window, cx);
6130 }
6131 }
6132 }
6133
6134 fn toggle_thread_switcher_impl(
6135 &mut self,
6136 select_last: bool,
6137 window: &mut Window,
6138 cx: &mut Context<Self>,
6139 ) {
6140 if let Some(thread_switcher) = &self.thread_switcher {
6141 thread_switcher.update(cx, |switcher, cx| {
6142 if select_last {
6143 switcher.select_last(cx);
6144 } else {
6145 switcher.cycle_selection(cx);
6146 }
6147 });
6148 return;
6149 }
6150
6151 let entries = self.mru_entries_for_switcher(cx);
6152 if entries.len() < 2 {
6153 return;
6154 }
6155
6156 let weak_multi_workspace = self.multi_workspace.clone();
6157
6158 // Snapshot the active entry (thread or terminal) so dismissal can
6159 // restore it.
6160 let original_active_entry = self.active_entry.clone();
6161 let original_metadata = match &original_active_entry {
6162 Some(ActiveEntry::Thread { thread_id, .. }) => {
6163 entries.iter().find_map(|entry| match entry {
6164 ThreadSwitcherEntry::Thread(entry)
6165 if *thread_id == entry.metadata.thread_id =>
6166 {
6167 Some(entry.metadata.clone())
6168 }
6169 _ => None,
6170 })
6171 }
6172 _ => None,
6173 };
6174 let original_workspace = self
6175 .multi_workspace
6176 .upgrade()
6177 .map(|mw| mw.read(cx).workspace().clone());
6178
6179 let thread_switcher = cx.new(|cx| ThreadSwitcher::new(entries, select_last, window, cx));
6180
6181 let mut subscriptions = Vec::new();
6182
6183 subscriptions.push(cx.subscribe_in(&thread_switcher, window, {
6184 let thread_switcher = thread_switcher.clone();
6185 move |this, _emitter, event: &ThreadSwitcherEvent, window, cx| match event {
6186 ThreadSwitcherEvent::Preview(selection) => {
6187 this.preview_switcher_selection(selection, window, cx);
6188 let focus = thread_switcher.focus_handle(cx);
6189 window.focus(&focus, cx);
6190 }
6191 ThreadSwitcherEvent::Confirmed(selection) => {
6192 this.confirm_switcher_selection(selection, window, cx);
6193 }
6194 ThreadSwitcherEvent::Dismissed => {
6195 if let Some(mw) = weak_multi_workspace.upgrade() {
6196 if let Some(original_ws) = &original_workspace {
6197 mw.update(cx, |mw, cx| {
6198 mw.activate(original_ws.clone(), None, window, cx);
6199 });
6200 }
6201 }
6202 match &original_active_entry {
6203 Some(ActiveEntry::Thread { .. }) => {
6204 if let (Some(metadata), Some(original_ws)) =
6205 (&original_metadata, &original_workspace)
6206 {
6207 this.active_entry = Some(ActiveEntry::Thread {
6208 thread_id: metadata.thread_id,
6209 session_id: metadata.session_id.clone(),
6210 workspace: original_ws.clone(),
6211 });
6212 this.update_entries(cx);
6213 Self::load_agent_thread_in_workspace(
6214 original_ws,
6215 metadata,
6216 false,
6217 window,
6218 cx,
6219 );
6220 }
6221 }
6222 Some(ActiveEntry::Terminal {
6223 terminal_id,
6224 workspace,
6225 }) => {
6226 let terminal_id = *terminal_id;
6227 let workspace = workspace.clone();
6228 this.active_entry = Some(ActiveEntry::Terminal {
6229 terminal_id,
6230 workspace: workspace.clone(),
6231 });
6232 this.update_entries(cx);
6233 workspace.update(cx, |workspace, cx| {
6234 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
6235 panel.update(cx, |panel, cx| {
6236 panel.activate_terminal(terminal_id, false, window, cx);
6237 });
6238 }
6239 });
6240 }
6241 None => {}
6242 }
6243 this.dismiss_thread_switcher(cx);
6244 }
6245 }
6246 }));
6247
6248 subscriptions.push(cx.subscribe_in(
6249 &thread_switcher,
6250 window,
6251 |this, _emitter, _event: &gpui::DismissEvent, _window, cx| {
6252 this.dismiss_thread_switcher(cx);
6253 },
6254 ));
6255
6256 let focus = thread_switcher.focus_handle(cx);
6257 let overlay_view = gpui::AnyView::from(thread_switcher.clone());
6258
6259 // Replay the initial preview that was emitted during construction
6260 // before subscriptions were wired up.
6261 let initial_preview = thread_switcher
6262 .read(cx)
6263 .selected_entry()
6264 .map(ThreadSwitcherEntry::selection);
6265
6266 self.thread_switcher = Some(thread_switcher);
6267 self._thread_switcher_subscriptions = subscriptions;
6268 if let Some(mw) = self.multi_workspace.upgrade() {
6269 mw.update(cx, |mw, cx| {
6270 mw.set_sidebar_overlay(Some(overlay_view), cx);
6271 });
6272 }
6273
6274 if let Some(selection) = initial_preview {
6275 self.preview_switcher_selection(&selection, window, cx);
6276 }
6277
6278 window.focus(&focus, cx);
6279 }
6280
6281 fn render_thread(
6282 &self,
6283 ix: usize,
6284 thread: &ThreadEntry,
6285 is_active: bool,
6286 is_focused: bool,
6287 cx: &mut Context<Self>,
6288 ) -> AnyElement {
6289 let has_notification = self.contents.is_thread_notified(&thread.metadata.thread_id);
6290
6291 let title: SharedString = thread.metadata.display_title();
6292 let metadata = thread.metadata.clone();
6293 let thread_workspace = thread.workspace.clone();
6294
6295 let is_hovered = self.hovered_thread_index == Some(ix);
6296 let is_selected = is_active;
6297 let is_draft = thread.draft.is_some();
6298 let is_empty_draft = thread.draft == Some(DraftKind::Empty);
6299 let is_running = matches!(
6300 thread.status,
6301 AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
6302 );
6303 let is_renaming = self.renaming_thread_id == Some(thread.metadata.thread_id);
6304
6305 let thread_id_for_actions = thread.metadata.thread_id;
6306 let session_id_for_delete = thread.metadata.session_id.clone();
6307 let focus_handle = self.focus_handle.clone();
6308 let title_editor = self.thread_rename_editor.clone();
6309
6310 let id = SharedString::from(format!("thread-entry-{}", ix));
6311
6312 let color = cx.theme().colors();
6313 let sidebar_bg = color
6314 .title_bar_background
6315 .blend(color.panel_background.opacity(0.25));
6316
6317 let timestamp: SharedString = if is_empty_draft {
6318 SharedString::default()
6319 } else {
6320 format_history_entry_timestamp(Self::thread_display_time(&thread.metadata)).into()
6321 };
6322
6323 let is_remote = thread.workspace.is_remote(cx);
6324
6325 let worktrees = apply_worktree_label_mode(
6326 thread.worktrees.clone(),
6327 cx.flag_value::<AgentThreadWorktreeLabelFlag>(),
6328 );
6329
6330 let (icon, icon_svg) = if is_draft {
6331 (IconName::Circle, None)
6332 } else {
6333 (thread.icon, thread.icon_from_external_svg.clone())
6334 };
6335
6336 let title_generating = thread.is_title_generating
6337 || self
6338 .regenerating_titles
6339 .contains(&thread.metadata.thread_id);
6340
6341 let thread_item = ThreadItem::new(id, title.clone())
6342 .base_bg(sidebar_bg)
6343 .icon(icon)
6344 .when(is_draft, |this| {
6345 this.icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.2)))
6346 })
6347 .status(thread.status)
6348 .is_remote(is_remote)
6349 .when_some(icon_svg, |this, svg| {
6350 this.custom_icon_from_external_svg(svg)
6351 })
6352 .worktrees(worktrees)
6353 .timestamp(timestamp)
6354 .highlight_positions(thread.highlight_positions.to_vec())
6355 .title_generating(title_generating)
6356 .notified(has_notification)
6357 .when(thread.diff_stats.lines_added > 0, |this| {
6358 this.added(thread.diff_stats.lines_added as usize)
6359 })
6360 .when(thread.diff_stats.lines_removed > 0, |this| {
6361 this.removed(thread.diff_stats.lines_removed as usize)
6362 })
6363 .selected(is_selected)
6364 .focused(is_focused)
6365 .hovered(is_hovered)
6366 .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| {
6367 if *is_hovered {
6368 this.hovered_thread_index = Some(ix);
6369 } else if this.hovered_thread_index == Some(ix) {
6370 this.hovered_thread_index = None;
6371 }
6372 cx.notify();
6373 }))
6374 .when(is_renaming, |this| {
6375 this.is_truncated(false).title_slot(
6376 div()
6377 .h_full()
6378 .min_w_0()
6379 .flex_1()
6380 .capture_action(cx.listener(
6381 |this, _: &editor::actions::Newline, window, cx| {
6382 this.finish_thread_rename(window, cx);
6383 },
6384 ))
6385 .on_action(cx.listener(|this, _: &Confirm, window, cx| {
6386 this.finish_thread_rename(window, cx);
6387 }))
6388 .on_action(
6389 cx.listener(|this, _: &editor::actions::Cancel, window, cx| {
6390 this.finish_thread_rename(window, cx);
6391 }),
6392 )
6393 .child(title_editor),
6394 )
6395 })
6396 .when(is_hovered && !is_renaming, |this| {
6397 let rename_button = IconButton::new(("rename-thread", ix), IconName::Pencil)
6398 .icon_size(IconSize::Small)
6399 .tooltip({
6400 let focus_handle = focus_handle.clone();
6401 move |_window, cx| {
6402 Tooltip::for_action_in(
6403 "Rename Thread",
6404 &RenameSelectedThread,
6405 &focus_handle,
6406 cx,
6407 )
6408 }
6409 })
6410 .on_click({
6411 let title = title.clone();
6412 cx.listener(move |this, _, window, cx| {
6413 this.start_renaming_thread(
6414 ix,
6415 thread_id_for_actions,
6416 title.clone(),
6417 window,
6418 cx,
6419 );
6420 })
6421 });
6422
6423 let contextual_action: Option<AnyElement> = if is_running {
6424 Some(
6425 IconButton::new("stop-thread", IconName::Stop)
6426 .icon_size(IconSize::Small)
6427 .icon_color(Color::Error)
6428 .style(ButtonStyle::Tinted(TintColor::Error))
6429 .tooltip(Tooltip::text("Stop Generation"))
6430 .on_click(cx.listener(move |this, _, _window, cx| {
6431 this.stop_thread(&thread_id_for_actions, cx);
6432 }))
6433 .into_any_element(),
6434 )
6435 } else {
6436 match thread.draft {
6437 Some(DraftKind::Empty) => None,
6438 Some(DraftKind::WithContent) => Some(
6439 IconButton::new("discard_thread", IconName::Close)
6440 .icon_size(IconSize::Small)
6441 .tooltip(Tooltip::text("Discard Draft"))
6442 .on_click({
6443 let thread_workspace = thread_workspace.clone();
6444 cx.listener(move |this, _, window, cx| {
6445 this.remove_draft(
6446 thread_id_for_actions,
6447 &thread_workspace,
6448 window,
6449 cx,
6450 );
6451 })
6452 })
6453 .into_any_element(),
6454 ),
6455 None => Some(
6456 IconButton::new("archive-thread", IconName::Archive)
6457 .icon_size(IconSize::Small)
6458 .tooltip({
6459 let focus_handle = focus_handle.clone();
6460 move |_window, cx| {
6461 Tooltip::for_action_in(
6462 "Archive Thread",
6463 &ArchiveSelectedThread,
6464 &focus_handle,
6465 cx,
6466 )
6467 }
6468 })
6469 .on_click({
6470 let session_id = session_id_for_delete.clone();
6471 cx.listener(move |this, _, window, cx| {
6472 if let Some(ref session_id) = session_id {
6473 this.archive_thread(session_id, window, cx);
6474 }
6475 })
6476 })
6477 .into_any_element(),
6478 ),
6479 }
6480 };
6481
6482 this.action_slot(
6483 h_flex()
6484 .gap_0p5()
6485 .child(rename_button)
6486 .when_some(contextual_action, |this, action| this.child(action)),
6487 )
6488 })
6489 .on_click({
6490 let thread_workspace = thread_workspace.clone();
6491 cx.listener(move |this, _, window, cx| {
6492 this.selection = None;
6493 match &thread_workspace {
6494 ThreadEntryWorkspace::Open(workspace) => {
6495 this.activate_thread(metadata.clone(), workspace, false, window, cx);
6496 }
6497 ThreadEntryWorkspace::Closed {
6498 folder_paths,
6499 project_group_key,
6500 } => {
6501 this.open_workspace_and_activate_thread(
6502 metadata.clone(),
6503 folder_paths.clone(),
6504 project_group_key,
6505 window,
6506 cx,
6507 );
6508 }
6509 }
6510 })
6511 });
6512
6513 if is_draft || thread.metadata.session_id.is_none() {
6514 return thread_item.into_any_element();
6515 }
6516
6517 let Some(session_id) = thread.metadata.session_id.clone() else {
6518 return thread_item.into_any_element();
6519 };
6520
6521 let context_menu_id = SharedString::from(format!("thread-context-menu-{}", ix));
6522 let sidebar = cx.weak_entity();
6523
6524 let active_workspace = self.active_workspace(cx);
6525 let thread_workspace = match &thread_workspace {
6526 ThreadEntryWorkspace::Open(workspace) => Some(workspace.clone()),
6527 ThreadEntryWorkspace::Closed { .. } => None,
6528 };
6529
6530 let is_zed_thread = thread.metadata.agent_id.as_ref() == OMEGA_AGENT_ID.as_ref();
6531 let can_open_as_markdown = thread.is_live || is_zed_thread;
6532 let folder_paths = thread.metadata.folder_paths().clone();
6533
6534 right_click_menu(context_menu_id)
6535 .trigger(move |_, _, _| thread_item)
6536 .menu({
6537 let thread_id = thread.metadata.thread_id;
6538 let markdown_title = Some(thread.metadata.display_title());
6539 let rename_title = title;
6540 move |_window, cx| {
6541 let session_id = session_id.clone();
6542 let sidebar = sidebar.clone();
6543 let active_workspace = active_workspace.clone();
6544 let thread_workspace = thread_workspace.clone();
6545 let markdown_title = markdown_title.clone();
6546 let rename_title = rename_title.clone();
6547 let folder_paths = folder_paths.clone();
6548 ContextMenu::build(_window, cx, move |mut menu, _window, _cx| {
6549 menu = menu.entry("Rename Title", None, {
6550 let sidebar = sidebar.clone();
6551 let rename_title = rename_title.clone();
6552 move |window, cx| {
6553 sidebar
6554 .update(cx, |sidebar, cx| {
6555 sidebar.start_renaming_thread(
6556 ix,
6557 thread_id,
6558 rename_title.clone(),
6559 window,
6560 cx,
6561 );
6562 })
6563 .ok();
6564 }
6565 });
6566
6567 if is_zed_thread {
6568 menu = menu.entry("Regenerate Thread Title", None, {
6569 let session_id = session_id.clone();
6570 let sidebar = sidebar.clone();
6571 let thread_workspace = thread_workspace.clone();
6572 let folder_paths = folder_paths.clone();
6573 move |_window, cx| {
6574 sidebar
6575 .update(cx, |sidebar, cx| {
6576 sidebar.regenerate_thread_title(
6577 &session_id,
6578 thread_id,
6579 folder_paths.clone(),
6580 thread_workspace.clone(),
6581 cx,
6582 );
6583 })
6584 .ok();
6585 }
6586 });
6587 }
6588
6589 if can_open_as_markdown {
6590 menu = menu.entry("Open Thread as Markdown", None, {
6591 let session_id = session_id.clone();
6592 let markdown_title = markdown_title.clone();
6593 let thread_workspace = thread_workspace.clone();
6594 move |window, cx| {
6595 if let Some(thread_workspace) = thread_workspace.as_ref()
6596 && let Some(panel) =
6597 thread_workspace.read(cx).panel::<AgentPanel>(cx)
6598 {
6599 let opened = panel.update(cx, |panel, cx| {
6600 panel.open_thread_as_markdown(
6601 thread_id,
6602 thread_workspace.clone(),
6603 window,
6604 cx,
6605 )
6606 });
6607 if opened {
6608 return;
6609 }
6610 }
6611
6612 if is_zed_thread
6613 && let Some(active_workspace) = &active_workspace
6614 {
6615 Self::open_closed_native_thread_as_markdown(
6616 &session_id,
6617 markdown_title.clone(),
6618 active_workspace,
6619 window,
6620 cx,
6621 );
6622 }
6623 }
6624 });
6625 }
6626
6627 menu.separator().entry("Archive Thread", None, {
6628 let session_id = session_id.clone();
6629 move |window, cx| {
6630 sidebar
6631 .update(cx, |sidebar, cx| {
6632 sidebar.archive_thread(&session_id, window, cx);
6633 })
6634 .ok();
6635 }
6636 })
6637 })
6638 }
6639 })
6640 .into_any_element()
6641 }
6642
6643 fn render_terminal(
6644 &self,
6645 ix: usize,
6646 terminal: &TerminalEntry,
6647 is_active: bool,
6648 is_focused: bool,
6649 cx: &mut Context<Self>,
6650 ) -> AnyElement {
6651 let id = ElementId::from(format!("terminal-{}", terminal.metadata.terminal_id));
6652 let timestamp = format_history_entry_timestamp(terminal.metadata.created_at);
6653 let is_hovered = self.hovered_thread_index == Some(ix);
6654 let color = cx.theme().colors();
6655 let sidebar_bg = color
6656 .title_bar_background
6657 .blend(color.panel_background.opacity(0.25));
6658 let metadata = terminal.metadata.clone();
6659 let workspace = terminal.workspace.clone();
6660 let focus_handle = self.focus_handle.clone();
6661 let worktrees = apply_worktree_label_mode(
6662 terminal.worktrees.clone(),
6663 cx.flag_value::<AgentThreadWorktreeLabelFlag>(),
6664 );
6665 let is_remote = terminal.workspace.is_remote(cx);
6666
6667 let display_title = terminal.metadata.display_title();
6668 let (icon_char, title, highlight_positions) =
6669 match split_leading_icon_char(&display_title, &terminal.highlight_positions) {
6670 Some((icon_char, title, positions)) => (Some(icon_char), title, positions),
6671 None => (None, display_title, terminal.highlight_positions.clone()),
6672 };
6673
6674 ThreadItem::new(id, title)
6675 .base_bg(sidebar_bg)
6676 .icon(IconName::Terminal)
6677 .when_some(icon_char, |this, icon_char| this.icon_char(icon_char))
6678 .is_remote(is_remote)
6679 .worktrees(worktrees)
6680 .timestamp(timestamp)
6681 .notified(terminal.has_notification)
6682 .highlight_positions(highlight_positions)
6683 .selected(is_active)
6684 .focused(is_focused)
6685 .hovered(is_hovered)
6686 .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| {
6687 if *is_hovered {
6688 this.hovered_thread_index = Some(ix);
6689 } else if this.hovered_thread_index == Some(ix) {
6690 this.hovered_thread_index = None;
6691 }
6692 cx.notify();
6693 }))
6694 .when(is_hovered, |this| {
6695 this.action_slot(
6696 IconButton::new("close-terminal", IconName::Close)
6697 .icon_size(IconSize::Small)
6698 .icon_color(Color::Muted)
6699 .tooltip({
6700 let focus_handle = focus_handle.clone();
6701 move |_window, cx| {
6702 Tooltip::for_action_in(
6703 "Close Terminal",
6704 &ArchiveSelectedThread,
6705 &focus_handle,
6706 cx,
6707 )
6708 }
6709 })
6710 .on_click(cx.listener(move |this, _, window, cx| {
6711 this.close_terminal(&metadata, &workspace, window, cx);
6712 })),
6713 )
6714 })
6715 .on_click(cx.listener({
6716 let metadata = terminal.metadata.clone();
6717 let workspace = terminal.workspace.clone();
6718 move |this, _, window, cx| {
6719 this.activate_terminal_entry(
6720 metadata.clone(),
6721 workspace.clone(),
6722 false,
6723 window,
6724 cx,
6725 );
6726 }
6727 }))
6728 .into_any_element()
6729 }
6730
6731 fn render_filter_input(&self, cx: &mut Context<Self>) -> impl IntoElement {
6732 div()
6733 .min_w_0()
6734 .flex_1()
6735 .capture_action(
6736 cx.listener(|this, _: &editor::actions::Newline, window, cx| {
6737 this.editor_confirm(window, cx);
6738 }),
6739 )
6740 .child(self.filter_editor.clone())
6741 }
6742
6743 fn render_recent_projects_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
6744 let multi_workspace = self.multi_workspace.upgrade();
6745
6746 let workspace = multi_workspace
6747 .as_ref()
6748 .map(|mw| mw.read(cx).workspace().downgrade());
6749
6750 let focus_handle = workspace
6751 .as_ref()
6752 .and_then(|ws| ws.upgrade())
6753 .map(|w| w.read(cx).focus_handle(cx))
6754 .unwrap_or_else(|| cx.focus_handle());
6755
6756 let window_project_groups: Vec<ProjectGroupKey> = multi_workspace
6757 .as_ref()
6758 .map(|mw| mw.read(cx).project_group_keys())
6759 .unwrap_or_default();
6760
6761 let popover_handle = self.recent_projects_popover_handle.clone();
6762
6763 PopoverMenu::new("sidebar-recent-projects-menu")
6764 .with_handle(popover_handle)
6765 .menu(move |window, cx| {
6766 workspace.as_ref().map(|ws| {
6767 SidebarRecentProjects::popover(
6768 ws.clone(),
6769 window_project_groups.clone(),
6770 focus_handle.clone(),
6771 window,
6772 cx,
6773 )
6774 })
6775 })
6776 .trigger_with_tooltip(
6777 IconButton::new("open-project", IconName::FolderAdd)
6778 .icon_size(IconSize::Small)
6779 .selected_style(ButtonStyle::Tinted(TintColor::Accent)),
6780 |_window, cx| Tooltip::for_action("Add Project", &OpenRecent::default(), cx),
6781 )
6782 .offset(gpui::Point {
6783 x: px(-2.0),
6784 y: px(-2.0),
6785 })
6786 .anchor(gpui::Anchor::BottomRight)
6787 }
6788
6789 fn new_thread_in_group(
6790 &mut self,
6791 _: &NewThreadInGroup,
6792 window: &mut Window,
6793 cx: &mut Context<Self>,
6794 ) {
6795 if let Some(key) = self.selected_group_key() {
6796 self.set_group_expanded(&key, true, cx);
6797 self.selection = None;
6798 if let Some(workspace) = self.workspace_for_group(&key, cx) {
6799 self.create_new_entry(&workspace, window, cx);
6800 } else {
6801 self.open_workspace_and_create_entry(
6802 &key,
6803 NewEntryTarget::LastCreatedKind,
6804 window,
6805 cx,
6806 );
6807 }
6808 } else if let Some(workspace) = self.active_workspace(cx) {
6809 self.create_new_entry(&workspace, window, cx);
6810 }
6811 }
6812
6813 fn new_terminal_thread(
6814 &mut self,
6815 _: &NewTerminalThread,
6816 window: &mut Window,
6817 cx: &mut Context<Self>,
6818 ) {
6819 cx.stop_propagation();
6820
6821 if let Some(key) = self.selected_group_key() {
6822 self.set_group_expanded(&key, true, cx);
6823 self.selection = None;
6824 if let Some(workspace) = self.workspace_for_group(&key, cx) {
6825 self.create_new_terminal(&workspace, window, cx);
6826 } else {
6827 self.open_workspace_and_create_entry(&key, NewEntryTarget::Terminal, window, cx);
6828 }
6829 } else if let Some(workspace) = self.active_workspace(cx) {
6830 self.create_new_terminal(&workspace, window, cx);
6831 }
6832 }
6833
6834 /// Closed linked-worktree drafts need an open workspace so archive root
6835 /// planning can inspect repositories before deleting the worktree.
6836 fn open_workspace_and_remove_draft(
6837 &mut self,
6838 draft_id: ThreadId,
6839 folder_paths: PathList,
6840 project_group_key: ProjectGroupKey,
6841 window: &mut Window,
6842 cx: &mut Context<Self>,
6843 ) {
6844 let Some((open_task, modal_workspace)) =
6845 self.open_workspace_for_archive(folder_paths, project_group_key, window, cx)
6846 else {
6847 return;
6848 };
6849
6850 cx.spawn_in(window, async move |this, cx| {
6851 let result = open_task.await;
6852 remote_connection::dismiss_connection_modal(&modal_workspace, cx);
6853 let workspace = result?;
6854 Self::wait_for_archive_workspace_metadata(&workspace, cx).await;
6855
6856 this.update_in(cx, |this, window, cx| {
6857 let workspace = ThreadEntryWorkspace::Open(workspace);
6858 this.remove_draft(draft_id, &workspace, window, cx);
6859 })?;
6860 anyhow::Ok(())
6861 })
6862 .detach_and_log_err(cx);
6863 }
6864
6865 fn remove_draft(
6866 &mut self,
6867 draft_id: ThreadId,
6868 workspace: &ThreadEntryWorkspace,
6869 window: &mut Window,
6870 cx: &mut Context<Self>,
6871 ) {
6872 let metadata = ThreadMetadataStore::global(cx)
6873 .read(cx)
6874 .entry(draft_id)
6875 .cloned();
6876
6877 if let ThreadEntryWorkspace::Closed {
6878 folder_paths,
6879 project_group_key,
6880 } = workspace
6881 && self.should_load_closed_workspace_for_archive(
6882 folder_paths,
6883 project_group_key,
6884 metadata
6885 .as_ref()
6886 .and_then(|metadata| metadata.remote_connection.as_ref()),
6887 Some(draft_id),
6888 None,
6889 cx,
6890 )
6891 {
6892 self.open_workspace_and_remove_draft(
6893 draft_id,
6894 folder_paths.clone(),
6895 project_group_key.clone(),
6896 window,
6897 cx,
6898 );
6899 return;
6900 }
6901
6902 let draft_folder_paths = metadata
6903 .as_ref()
6904 .map(|metadata| metadata.folder_paths().clone())
6905 .or_else(|| match workspace {
6906 ThreadEntryWorkspace::Open(workspace) => {
6907 Some(PathList::new(&workspace.read(cx).root_paths(cx)))
6908 }
6909 ThreadEntryWorkspace::Closed { folder_paths, .. } => Some(folder_paths.clone()),
6910 });
6911 let draft_remote_connection = metadata
6912 .as_ref()
6913 .and_then(|metadata| metadata.remote_connection.clone());
6914 let roots_to_archive = metadata
6915 .as_ref()
6916 .map(|metadata| {
6917 self.roots_to_archive_for_paths(
6918 metadata.folder_paths(),
6919 metadata.remote_connection.as_ref(),
6920 Some(draft_id),
6921 None,
6922 cx,
6923 )
6924 })
6925 .unwrap_or_default();
6926
6927 let was_active = self
6928 .active_entry
6929 .as_ref()
6930 .is_some_and(|entry| entry.is_active_thread(&draft_id));
6931 let neighbor = self
6932 .contents
6933 .entries
6934 .iter()
6935 .position(|entry| {
6936 matches!(
6937 entry,
6938 ListEntry::Thread(thread) if thread.metadata.thread_id == draft_id
6939 )
6940 })
6941 .and_then(|position| self.neighboring_activatable_entry(position));
6942
6943 let workspace_to_remove = draft_folder_paths.as_ref().and_then(|folder_paths| {
6944 self.linked_worktree_workspace_to_remove(
6945 folder_paths,
6946 draft_remote_connection.as_ref(),
6947 Some(draft_id),
6948 None,
6949 &roots_to_archive,
6950 cx,
6951 )
6952 });
6953 let mut workspaces_to_remove: Vec<Entity<Workspace>> =
6954 workspace_to_remove.into_iter().collect();
6955 let close_item_tasks = self.close_items_for_archived_worktrees(
6956 &roots_to_archive,
6957 &mut workspaces_to_remove,
6958 window,
6959 cx,
6960 );
6961
6962 if !workspaces_to_remove.is_empty() {
6963 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
6964 return;
6965 };
6966 let draft_workspace_removed = matches!(
6967 workspace,
6968 ThreadEntryWorkspace::Open(workspace) if workspaces_to_remove.contains(workspace)
6969 );
6970 let (fallback_paths, project_group_key) = neighbor
6971 .as_ref()
6972 .map(|neighbor| neighbor.project_location(cx))
6973 .unwrap_or_else(|| {
6974 workspaces_to_remove
6975 .first()
6976 .map(|workspace| {
6977 let key = workspace.read(cx).project_group_key(cx);
6978 (key.path_list().clone(), key)
6979 })
6980 .unwrap_or_default()
6981 });
6982
6983 let excluded = workspaces_to_remove.clone();
6984 let remove_task = multi_workspace.update(cx, |multi_workspace, cx| {
6985 multi_workspace.remove(
6986 workspaces_to_remove,
6987 move |this, window, cx| {
6988 let active_workspace = this.workspace().clone();
6989 this.find_or_create_workspace(
6990 fallback_paths,
6991 project_group_key.host(),
6992 Some(project_group_key),
6993 |options, window, cx| {
6994 connect_remote(active_workspace, options, window, cx)
6995 },
6996 &excluded,
6997 None,
6998 OpenMode::Activate,
6999 window,
7000 cx,
7001 )
7002 },
7003 window,
7004 cx,
7005 )
7006 });
7007
7008 let workspace = workspace.clone();
7009 cx.spawn_in(window, async move |this, cx| {
7010 if !remove_task.await? {
7011 return anyhow::Ok(());
7012 }
7013
7014 for task in close_item_tasks {
7015 let result: anyhow::Result<()> = task.await;
7016 result.log_err();
7017 }
7018
7019 this.update_in(cx, |this, window, cx| {
7020 if draft_workspace_removed {
7021 if let Some(draft_folder_paths) = draft_folder_paths.as_ref() {
7022 this.delete_empty_drafts_for_archive_paths(
7023 draft_folder_paths,
7024 draft_remote_connection.as_ref(),
7025 cx,
7026 );
7027 }
7028 }
7029 this.remove_draft_entry(
7030 draft_id,
7031 &workspace,
7032 was_active,
7033 neighbor.as_ref(),
7034 !draft_workspace_removed,
7035 roots_to_archive,
7036 window,
7037 cx,
7038 );
7039 })?;
7040 anyhow::Ok(())
7041 })
7042 .detach_and_log_err(cx);
7043 } else if !close_item_tasks.is_empty() {
7044 let workspace = workspace.clone();
7045 cx.spawn_in(window, async move |this, cx| {
7046 for task in close_item_tasks {
7047 let result: anyhow::Result<()> = task.await;
7048 result.log_err();
7049 }
7050
7051 this.update_in(cx, |this, window, cx| {
7052 this.remove_draft_entry(
7053 draft_id,
7054 &workspace,
7055 was_active,
7056 neighbor.as_ref(),
7057 true,
7058 roots_to_archive,
7059 window,
7060 cx,
7061 );
7062 })?;
7063 anyhow::Ok(())
7064 })
7065 .detach_and_log_err(cx);
7066 } else {
7067 self.remove_draft_entry(
7068 draft_id,
7069 workspace,
7070 was_active,
7071 neighbor.as_ref(),
7072 true,
7073 roots_to_archive,
7074 window,
7075 cx,
7076 );
7077 }
7078 }
7079
7080 fn remove_draft_entry(
7081 &mut self,
7082 draft_id: ThreadId,
7083 workspace: &ThreadEntryWorkspace,
7084 was_active: bool,
7085 neighbor: Option<&ActivatableEntry>,
7086 activate_panel_draft: bool,
7087 roots_to_archive: Vec<thread_worktree_archive::RootPlan>,
7088 window: &mut Window,
7089 cx: &mut Context<Self>,
7090 ) {
7091 // Fallback to a neighbor thread when the discarded
7092 // draft was the active entry.
7093 let activate_panel_draft = activate_panel_draft && !(was_active && neighbor.is_some());
7094
7095 let removed_from_panel = if let ThreadEntryWorkspace::Open(workspace) = workspace {
7096 workspace.update(cx, |workspace, cx| {
7097 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
7098 panel.update(cx, |panel, cx| {
7099 if activate_panel_draft {
7100 panel.remove_thread(draft_id, window, cx);
7101 } else {
7102 panel.remove_thread_without_activating_draft(draft_id, window, cx);
7103 }
7104 });
7105 true
7106 } else {
7107 false
7108 }
7109 })
7110 } else {
7111 false
7112 };
7113
7114 if !removed_from_panel {
7115 ThreadMetadataStore::global(cx).update(cx, |store, cx| {
7116 store.delete(draft_id, cx);
7117 });
7118 }
7119
7120 self.start_detached_archive_worktree_task(roots_to_archive, cx);
7121
7122 if was_active {
7123 self.active_entry = None;
7124 if !activate_panel_draft {
7125 if neighbor
7126 .as_ref()
7127 .is_some_and(|neighbor| self.activate_entry(neighbor, window, cx))
7128 {
7129 return;
7130 }
7131 self.sync_active_entry_from_active_workspace(cx);
7132 }
7133 }
7134
7135 self.update_entries(cx);
7136 }
7137
7138 fn create_new_entry(
7139 &mut self,
7140 workspace: &Entity<Workspace>,
7141 window: &mut Window,
7142 cx: &mut Context<Self>,
7143 ) {
7144 if workspace_path_list(workspace, cx).paths().is_empty() {
7145 return;
7146 }
7147
7148 if self.should_create_terminal_for_workspace(workspace, cx) {
7149 self.create_new_terminal(workspace, window, cx);
7150 } else {
7151 self.create_new_thread(workspace, window, cx);
7152 }
7153 }
7154
7155 fn should_create_terminal_for_workspace(
7156 &self,
7157 workspace: &Entity<Workspace>,
7158 cx: &App,
7159 ) -> bool {
7160 workspace
7161 .read(cx)
7162 .panel::<AgentPanel>(cx)
7163 .is_some_and(|panel| panel.read(cx).should_create_terminal_for_new_entry(cx))
7164 }
7165
7166 fn create_new_thread(
7167 &mut self,
7168 workspace: &Entity<Workspace>,
7169 window: &mut Window,
7170 cx: &mut Context<Self>,
7171 ) {
7172 if workspace_path_list(workspace, cx).paths().is_empty() {
7173 return;
7174 }
7175
7176 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
7177 return;
7178 };
7179
7180 multi_workspace.update(cx, |multi_workspace, cx| {
7181 multi_workspace.activate(workspace.clone(), None, window, cx);
7182 });
7183
7184 let draft_id = workspace.update(cx, |workspace, cx| {
7185 let panel = workspace.panel::<AgentPanel>(cx)?;
7186 let draft_id = panel.update(cx, |panel, cx| {
7187 panel.activate_new_thread(true, AgentThreadSource::Sidebar, window, cx);
7188 panel.active_thread_id(cx)
7189 });
7190 workspace.focus_panel::<AgentPanel>(window, cx);
7191 draft_id
7192 });
7193
7194 if let Some(draft_id) = draft_id {
7195 self.active_entry = Some(ActiveEntry::Thread {
7196 thread_id: draft_id,
7197 session_id: None,
7198 workspace: workspace.clone(),
7199 });
7200 }
7201 }
7202
7203 fn create_new_terminal(
7204 &mut self,
7205 workspace: &Entity<Workspace>,
7206 window: &mut Window,
7207 cx: &mut Context<Self>,
7208 ) {
7209 if workspace_path_list(workspace, cx).paths().is_empty() {
7210 return;
7211 }
7212
7213 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
7214 return;
7215 };
7216
7217 multi_workspace.update(cx, |multi_workspace, cx| {
7218 multi_workspace.activate(workspace.clone(), None, window, cx);
7219 });
7220
7221 workspace.update(cx, |workspace, cx| {
7222 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
7223 panel.update(cx, |panel, cx| {
7224 panel.new_terminal(Some(workspace), AgentThreadSource::Sidebar, window, cx);
7225 });
7226 }
7227 workspace.focus_panel::<AgentPanel>(window, cx);
7228 });
7229 }
7230
7231 fn selected_group_key(&self) -> Option<ProjectGroupKey> {
7232 let ix = self.selection?;
7233 match self.contents.entries.get(ix) {
7234 Some(ListEntry::ProjectHeader { key, .. }) => Some(key.clone()),
7235 Some(ListEntry::Thread(_) | ListEntry::Terminal(_)) => {
7236 (0..ix)
7237 .rev()
7238 .find_map(|i| match self.contents.entries.get(i) {
7239 Some(ListEntry::ProjectHeader { key, .. }) => Some(key.clone()),
7240 _ => None,
7241 })
7242 }
7243 _ => None,
7244 }
7245 }
7246
7247 fn workspace_for_group(&self, key: &ProjectGroupKey, cx: &App) -> Option<Entity<Workspace>> {
7248 let mw = self.multi_workspace.upgrade()?;
7249 let mw = mw.read(cx);
7250 let active = mw.workspace().clone();
7251 let active_key = active.read(cx).project_group_key(cx);
7252 if active_key == *key {
7253 Some(active)
7254 } else {
7255 mw.workspace_for_paths(key.path_list(), key.host().as_ref(), cx)
7256 }
7257 }
7258
7259 pub(crate) fn activate_or_open_workspace_for_group(
7260 &mut self,
7261 key: &ProjectGroupKey,
7262 window: &mut Window,
7263 cx: &mut Context<Self>,
7264 ) {
7265 let workspace = self
7266 .multi_workspace
7267 .upgrade()
7268 .and_then(|mw| mw.read(cx).last_active_workspace_for_group(key, cx))
7269 .or_else(|| self.workspace_for_group(key, cx));
7270 if let Some(workspace) = workspace {
7271 if self.is_active_workspace(&workspace, cx) {
7272 return;
7273 }
7274 self.activate_workspace(&workspace, window, cx);
7275 } else {
7276 self.open_workspace_for_group(key, window, cx);
7277 }
7278 self.selection = None;
7279 self.active_entry = None;
7280 }
7281
7282 fn active_project_group_key(&self, cx: &App) -> Option<ProjectGroupKey> {
7283 let multi_workspace = self.multi_workspace.upgrade()?;
7284 let multi_workspace = multi_workspace.read(cx);
7285 Some(multi_workspace.project_group_key_for_workspace(multi_workspace.workspace(), cx))
7286 }
7287
7288 fn active_project_header_position(&self, cx: &App) -> Option<usize> {
7289 let active_key = self.active_project_group_key(cx)?;
7290 self.contents
7291 .project_header_indices
7292 .iter()
7293 .position(|&entry_ix| {
7294 matches!(
7295 &self.contents.entries[entry_ix],
7296 ListEntry::ProjectHeader { key, .. } if *key == active_key
7297 )
7298 })
7299 }
7300
7301 fn cycle_project_impl(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
7302 let Some(multi_workspace) = self.multi_workspace.upgrade() else {
7303 return;
7304 };
7305
7306 let header_count = self.contents.project_header_indices.len();
7307 if header_count == 0 {
7308 return;
7309 }
7310
7311 let current_pos = self.active_project_header_position(cx);
7312
7313 let next_pos = match current_pos {
7314 Some(pos) => {
7315 if forward {
7316 (pos + 1) % header_count
7317 } else {
7318 (pos + header_count - 1) % header_count
7319 }
7320 }
7321 None => 0,
7322 };
7323
7324 let header_entry_ix = self.contents.project_header_indices[next_pos];
7325 let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(header_entry_ix)
7326 else {
7327 return;
7328 };
7329 let key = key.clone();
7330
7331 // Uncollapse the target group so that threads become visible.
7332 self.set_group_expanded(&key, true, cx);
7333
7334 if let Some(workspace) = self.multi_workspace.upgrade().and_then(|mw| {
7335 mw.read(cx)
7336 .workspace_for_paths(key.path_list(), key.host().as_ref(), cx)
7337 }) {
7338 multi_workspace.update(cx, |multi_workspace, cx| {
7339 multi_workspace.activate(workspace, None, window, cx);
7340 multi_workspace.retain_active_workspace(cx);
7341 });
7342 } else {
7343 self.open_workspace_for_group(&key, window, cx);
7344 }
7345 }
7346
7347 fn on_next_project(&mut self, _: &NextProject, window: &mut Window, cx: &mut Context<Self>) {
7348 self.cycle_project_impl(true, window, cx);
7349 }
7350
7351 fn on_previous_project(
7352 &mut self,
7353 _: &PreviousProject,
7354 window: &mut Window,
7355 cx: &mut Context<Self>,
7356 ) {
7357 self.cycle_project_impl(false, window, cx);
7358 }
7359
7360 fn cycle_thread_impl(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
7361 let thread_indices: Vec<usize> = self
7362 .contents
7363 .entries
7364 .iter()
7365 .enumerate()
7366 .filter_map(|(ix, entry)| match entry {
7367 ListEntry::Thread(_) | ListEntry::Terminal(_) => Some(ix),
7368 _ => None,
7369 })
7370 .collect();
7371
7372 if thread_indices.is_empty() {
7373 return;
7374 }
7375
7376 let current_thread_pos = self.active_entry.as_ref().and_then(|active| {
7377 thread_indices
7378 .iter()
7379 .position(|&ix| active.matches_entry(&self.contents.entries[ix]))
7380 });
7381
7382 let next_pos = match current_thread_pos {
7383 Some(pos) => {
7384 let count = thread_indices.len();
7385 if forward {
7386 (pos + 1) % count
7387 } else {
7388 (pos + count - 1) % count
7389 }
7390 }
7391 None => 0,
7392 };
7393
7394 let entry_ix = thread_indices[next_pos];
7395 match &self.contents.entries[entry_ix] {
7396 ListEntry::Thread(thread) => {
7397 let metadata = thread.metadata.clone();
7398 match &thread.workspace {
7399 ThreadEntryWorkspace::Open(workspace) => {
7400 let workspace = workspace.clone();
7401 self.activate_thread(metadata, &workspace, true, window, cx);
7402 }
7403 ThreadEntryWorkspace::Closed {
7404 folder_paths,
7405 project_group_key,
7406 } => {
7407 let folder_paths = folder_paths.clone();
7408 let project_group_key = project_group_key.clone();
7409 self.open_workspace_and_activate_thread(
7410 metadata,
7411 folder_paths,
7412 &project_group_key,
7413 window,
7414 cx,
7415 );
7416 }
7417 }
7418 }
7419 ListEntry::Terminal(terminal) => {
7420 let metadata = terminal.metadata.clone();
7421 let workspace = terminal.workspace.clone();
7422 self.activate_terminal_entry(metadata, workspace, true, window, cx);
7423 }
7424 ListEntry::ProjectHeader { .. } => {}
7425 }
7426 }
7427
7428 fn on_next_thread(&mut self, _: &NextThread, window: &mut Window, cx: &mut Context<Self>) {
7429 self.cycle_thread_impl(true, window, cx);
7430 }
7431
7432 fn on_previous_thread(
7433 &mut self,
7434 _: &PreviousThread,
7435 window: &mut Window,
7436 cx: &mut Context<Self>,
7437 ) {
7438 self.cycle_thread_impl(false, window, cx);
7439 }
7440
7441 fn render_no_results(&self, cx: &mut Context<Self>) -> impl IntoElement {
7442 let has_query = self.has_filter_query(cx);
7443 let message = if has_query {
7444 "No threads match your search."
7445 } else {
7446 "No threads yet"
7447 };
7448
7449 v_flex()
7450 .id("sidebar-no-results")
7451 .p_4()
7452 .size_full()
7453 .items_center()
7454 .justify_center()
7455 .child(
7456 Label::new(message)
7457 .size(LabelSize::Small)
7458 .color(Color::Muted),
7459 )
7460 }
7461
7462 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
7463 ProjectEmptyState::new(
7464 "Threads Sidebar",
7465 self.focus_handle(cx),
7466 KeyBinding::for_action(&workspace::Open::default(), cx),
7467 )
7468 .on_open_project(|_, window, cx| {
7469 let side = match AgentSettings::get_global(cx).sidebar_side() {
7470 SidebarSide::Left => "left",
7471 SidebarSide::Right => "right",
7472 };
7473 telemetry::event!("Sidebar Add Project Clicked", side = side);
7474 window.dispatch_action(
7475 Open {
7476 create_new_window: Some(false),
7477 }
7478 .boxed_clone(),
7479 cx,
7480 );
7481 })
7482 .on_clone_repo(|_, window, cx| {
7483 window.dispatch_action(git::Clone.boxed_clone(), cx);
7484 })
7485 }
7486
7487 fn render_sidebar_header(
7488 &self,
7489 no_open_projects: bool,
7490 window: &Window,
7491 cx: &mut Context<Self>,
7492 ) -> impl IntoElement {
7493 let has_query = self.has_filter_query(cx);
7494 let sidebar_on_left = self.side(cx) == SidebarSide::Left;
7495 let sidebar_on_right = self.side(cx) == SidebarSide::Right;
7496 let not_fullscreen = !window.is_fullscreen();
7497 let traffic_lights = cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left;
7498 let left_window_controls = !cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left;
7499 let right_window_controls =
7500 !cfg!(target_os = "macos") && not_fullscreen && sidebar_on_right;
7501 let header_height = platform_title_bar_height(window);
7502
7503 h_flex()
7504 .h(header_height)
7505 .mt_px()
7506 .pb_px()
7507 .when(left_window_controls, |this| {
7508 this.children(Self::render_left_window_controls(window, cx))
7509 })
7510 .map(|this| {
7511 if traffic_lights {
7512 this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
7513 } else if !left_window_controls {
7514 this.pl_1p5()
7515 } else {
7516 this
7517 }
7518 })
7519 .when(!right_window_controls, |this| this.pr_1p5())
7520 .gap_1()
7521 .when(!no_open_projects, |this| {
7522 this.border_b_1()
7523 .border_color(cx.theme().colors().border)
7524 .when(traffic_lights, |this| {
7525 this.child(Divider::vertical().color(ui::DividerColor::Border))
7526 })
7527 .child(
7528 div().ml_1().child(
7529 Icon::new(IconName::MagnifyingGlass)
7530 .size(IconSize::Small)
7531 .color(Color::Muted),
7532 ),
7533 )
7534 .child(self.render_filter_input(cx))
7535 .child(
7536 h_flex()
7537 .gap_1()
7538 .when(
7539 self.selection.is_some()
7540 && !self.filter_editor.focus_handle(cx).is_focused(window),
7541 |this| this.child(KeyBinding::for_action(&FocusSidebarFilter, cx)),
7542 )
7543 .when(has_query, |this| {
7544 this.child(
7545 IconButton::new("clear_filter", IconName::Close)
7546 .icon_size(IconSize::Small)
7547 .tooltip(Tooltip::text("Clear Search"))
7548 .on_click(cx.listener(|this, _, window, cx| {
7549 this.reset_filter_editor_text(window, cx);
7550 this.update_entries(cx);
7551 })),
7552 )
7553 }),
7554 )
7555 })
7556 .when(right_window_controls, |this| {
7557 this.children(Self::render_right_window_controls(window, cx))
7558 })
7559 }
7560
7561 fn render_left_window_controls(window: &Window, cx: &mut App) -> Option<AnyElement> {
7562 platform_title_bar::render_left_window_controls(
7563 cx.button_layout(),
7564 Box::new(CloseWindow),
7565 window,
7566 )
7567 }
7568
7569 fn render_right_window_controls(window: &Window, cx: &mut App) -> Option<AnyElement> {
7570 platform_title_bar::render_right_window_controls(
7571 cx.button_layout(),
7572 Box::new(CloseWindow),
7573 window,
7574 )
7575 }
7576
7577 fn render_sidebar_toggle_button(&self, _cx: &mut Context<Self>) -> impl IntoElement {
7578 let on_right = AgentSettings::get_global(_cx).sidebar_side() == SidebarSide::Right;
7579
7580 sidebar_side_context_menu("sidebar-toggle-menu", _cx)
7581 .anchor(if on_right {
7582 gpui::Anchor::BottomRight
7583 } else {
7584 gpui::Anchor::BottomLeft
7585 })
7586 .attach(if on_right {
7587 gpui::Anchor::TopRight
7588 } else {
7589 gpui::Anchor::TopLeft
7590 })
7591 .trigger(move |_is_active, _window, _cx| {
7592 let icon = if on_right {
7593 IconName::ThreadsSidebarRightOpen
7594 } else {
7595 IconName::ThreadsSidebarLeftOpen
7596 };
7597 IconButton::new("sidebar-close-toggle", icon)
7598 .icon_size(IconSize::Small)
7599 .tooltip(Tooltip::element(move |_window, cx| {
7600 v_flex()
7601 .gap_1()
7602 .child(
7603 h_flex()
7604 .gap_2()
7605 .justify_between()
7606 .child(Label::new("Toggle Sidebar"))
7607 .child(KeyBinding::for_action(&ToggleWorkspaceSidebar, cx)),
7608 )
7609 .child(
7610 h_flex()
7611 .pt_1()
7612 .gap_2()
7613 .border_t_1()
7614 .border_color(cx.theme().colors().border_variant)
7615 .justify_between()
7616 .child(Label::new("Focus Sidebar"))
7617 .child(KeyBinding::for_action(&FocusWorkspaceSidebar, cx)),
7618 )
7619 .into_any_element()
7620 }))
7621 .on_click(|_, window, cx| {
7622 if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
7623 multi_workspace.update(cx, |multi_workspace, cx| {
7624 multi_workspace.close_sidebar(window, cx);
7625 });
7626 }
7627 })
7628 })
7629 }
7630
7631 fn render_sidebar_bottom_bar(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
7632 let is_archive = matches!(self.view, SidebarView::Archive(..));
7633 let on_right = self.side(cx) == SidebarSide::Right;
7634
7635 h_flex()
7636 .p_1()
7637 .gap_1()
7638 .when(on_right, |this| this.flex_row_reverse())
7639 .border_t_1()
7640 .border_color(cx.theme().colors().border)
7641 .child(self.render_sidebar_toggle_button(cx))
7642 .child(
7643 IconButton::new("history", IconName::Clock)
7644 .icon_size(IconSize::Small)
7645 .toggle_state(is_archive)
7646 .tooltip(move |_, cx| {
7647 let label = if is_archive {
7648 "Hide Thread History"
7649 } else {
7650 "Show Thread History"
7651 };
7652 Tooltip::for_action(label, &ToggleThreadHistory, cx)
7653 })
7654 .on_click(cx.listener(|this, _, window, cx| {
7655 this.toggle_archive(&ToggleThreadHistory, window, cx);
7656 })),
7657 )
7658 .child(div().flex_1())
7659 .child(self.render_recent_projects_button(cx))
7660 }
7661
7662 fn active_workspace(&self, cx: &App) -> Option<Entity<Workspace>> {
7663 self.multi_workspace
7664 .upgrade()
7665 .map(|w| w.read(cx).workspace().clone())
7666 }
7667
7668 fn show_thread_import_modal(
7669 &mut self,
7670 source: &'static str,
7671 window: &mut Window,
7672 cx: &mut Context<Self>,
7673 ) {
7674 telemetry::event!(
7675 "Agent Threads Import Clicked",
7676 source = source,
7677 side = match self.side(cx) {
7678 SidebarSide::Left => "left",
7679 SidebarSide::Right => "right",
7680 }
7681 );
7682
7683 let Some(active_workspace) = self.active_workspace(cx) else {
7684 return;
7685 };
7686
7687 let Some(agent_registry_store) = AgentRegistryStore::try_global(cx) else {
7688 return;
7689 };
7690
7691 let agent_server_store = active_workspace
7692 .read(cx)
7693 .project()
7694 .read(cx)
7695 .agent_server_store()
7696 .clone();
7697
7698 let workspace_handle = active_workspace.downgrade();
7699 let multi_workspace = self.multi_workspace.clone();
7700
7701 active_workspace.update(cx, |workspace, cx| {
7702 workspace.toggle_modal(window, cx, |window, cx| {
7703 ThreadImportModal::new(
7704 agent_server_store,
7705 agent_registry_store,
7706 workspace_handle.clone(),
7707 multi_workspace.clone(),
7708 window,
7709 cx,
7710 )
7711 });
7712 });
7713 }
7714
7715 fn should_render_acp_import_onboarding(&self, cx: &App) -> bool {
7716 let has_external_agents = self
7717 .active_workspace(cx)
7718 .map(|ws| {
7719 ws.read(cx)
7720 .project()
7721 .read(cx)
7722 .agent_server_store()
7723 .read(cx)
7724 .has_external_agents()
7725 })
7726 .unwrap_or(false);
7727
7728 has_external_agents && !AcpThreadImportOnboarding::dismissed(cx)
7729 }
7730
7731 fn render_acp_import_onboarding(
7732 &mut self,
7733 verbose_labels: bool,
7734 cx: &mut Context<Self>,
7735 ) -> impl IntoElement {
7736 let on_import = cx.listener(|this, _, window, cx| {
7737 this.show_archive(window, cx);
7738 this.show_thread_import_modal("external_agent_onboarding", window, cx);
7739 });
7740 render_import_onboarding_banner(
7741 "acp",
7742 "Looking for threads from external agents?",
7743 "Import threads from agents like Claude Agent, Codex, and more, whether started in Omega or another client.",
7744 if verbose_labels {
7745 "Import Threads from External Agents"
7746 } else {
7747 "Import Threads"
7748 },
7749 |_, _window, cx| AcpThreadImportOnboarding::dismiss(cx),
7750 on_import,
7751 cx,
7752 )
7753 }
7754
7755 fn should_render_cross_channel_import_onboarding(&self, cx: &App) -> bool {
7756 !CrossChannelImportOnboarding::dismissed(cx)
7757 && !self.cross_channel_import_channels.is_empty()
7758 }
7759
7760 fn render_cross_channel_import_onboarding(
7761 &mut self,
7762 verbose_labels: bool,
7763 cx: &mut Context<Self>,
7764 ) -> impl IntoElement {
7765 let channel_names = self
7766 .cross_channel_import_channels
7767 .iter()
7768 .map(SharedString::as_str)
7769 .join(" and ");
7770
7771 let description = format!(
7772 "Import threads from {} to continue where you left off.",
7773 channel_names
7774 );
7775
7776 let on_import = cx.listener(|this, _, _window, cx| {
7777 telemetry::event!(
7778 "Agent Threads Import Clicked",
7779 source = "cross_channel_onboarding",
7780 side = match this.side(cx) {
7781 SidebarSide::Left => "left",
7782 SidebarSide::Right => "right",
7783 }
7784 );
7785 CrossChannelImportOnboarding::dismiss(cx);
7786 if let Some(workspace) = this.active_workspace(cx) {
7787 workspace.update(cx, |workspace, cx| {
7788 import_threads_from_other_channels(workspace, cx);
7789 });
7790 }
7791 });
7792 render_import_onboarding_banner(
7793 "channel",
7794 "Threads found from other channels",
7795 description,
7796 if verbose_labels {
7797 "Import Threads from Other Channels"
7798 } else {
7799 "Import Threads"
7800 },
7801 |_, _window, cx| CrossChannelImportOnboarding::dismiss(cx),
7802 on_import,
7803 cx,
7804 )
7805 }
7806
7807 fn toggle_archive(
7808 &mut self,
7809 _: &ToggleThreadHistory,
7810 window: &mut Window,
7811 cx: &mut Context<Self>,
7812 ) {
7813 match &self.view {
7814 SidebarView::ThreadList => {
7815 self.show_archive(window, cx);
7816 }
7817 SidebarView::Archive(_) => self.show_thread_list(window, cx),
7818 }
7819 }
7820
7821 fn show_archive(&mut self, window: &mut Window, cx: &mut Context<Self>) {
7822 let side = match self.side(cx) {
7823 SidebarSide::Left => "left",
7824 SidebarSide::Right => "right",
7825 };
7826 telemetry::event!("Thread History Viewed", side = side);
7827
7828 let Some(active_workspace) = self
7829 .multi_workspace
7830 .upgrade()
7831 .map(|w| w.read(cx).workspace().clone())
7832 else {
7833 return;
7834 };
7835 let Some(agent_panel) = active_workspace.read(cx).panel::<AgentPanel>(cx) else {
7836 return;
7837 };
7838
7839 let agent_server_store = active_workspace
7840 .read(cx)
7841 .project()
7842 .read(cx)
7843 .agent_server_store()
7844 .downgrade();
7845
7846 let agent_connection_store = agent_panel.read(cx).connection_store().downgrade();
7847
7848 let archive_view = cx.new(|cx| {
7849 ThreadsArchiveView::new(
7850 active_workspace.downgrade(),
7851 agent_connection_store.clone(),
7852 agent_server_store.clone(),
7853 window,
7854 cx,
7855 )
7856 });
7857
7858 let subscription = cx.subscribe_in(
7859 &archive_view,
7860 window,
7861 |this, _, event: &ThreadsArchiveViewEvent, window, cx| match event {
7862 ThreadsArchiveViewEvent::Close => {
7863 this.show_thread_list(window, cx);
7864 }
7865 ThreadsArchiveViewEvent::Activate { thread } => {
7866 this.open_thread_from_archive(thread.clone(), window, cx);
7867 }
7868 ThreadsArchiveViewEvent::CancelRestore { thread_id } => {
7869 this.restoring_tasks.remove(thread_id);
7870 }
7871 ThreadsArchiveViewEvent::Import => {
7872 this.show_thread_import_modal("thread_history", window, cx);
7873 }
7874 ThreadsArchiveViewEvent::NewThread => {
7875 this.show_thread_list(window, cx);
7876 if let Some(workspace) = this.active_workspace(cx) {
7877 this.create_new_entry(&workspace, window, cx);
7878 }
7879 }
7880 },
7881 );
7882
7883 self._subscriptions.push(subscription);
7884 self.view = SidebarView::Archive(archive_view.clone());
7885 archive_view.update(cx, |view, cx| view.focus_filter_editor(window, cx));
7886 self.serialize(cx);
7887 cx.notify();
7888 }
7889
7890 fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
7891 self.view = SidebarView::ThreadList;
7892 self._subscriptions.clear();
7893 let handle = self.filter_editor.read(cx).focus_handle(cx);
7894 handle.focus(window, cx);
7895 self.serialize(cx);
7896 cx.notify();
7897 }
7898}
7899
7900fn render_import_onboarding_banner(
7901 id: impl Into<SharedString>,
7902 title: impl Into<SharedString>,
7903 description: impl Into<SharedString>,
7904 button_label: impl Into<SharedString>,
7905 on_dismiss: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
7906 on_import: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
7907 cx: &App,
7908) -> impl IntoElement {
7909 let id: SharedString = id.into();
7910 let bg = cx.theme().colors().text_accent;
7911
7912 v_flex()
7913 .min_w_0()
7914 .w_full()
7915 .p_2()
7916 .border_t_1()
7917 .border_color(cx.theme().colors().border)
7918 .bg(linear_gradient(
7919 360.,
7920 linear_color_stop(bg.opacity(0.06), 1.),
7921 linear_color_stop(bg.opacity(0.), 0.),
7922 ))
7923 .child(
7924 h_flex()
7925 .min_w_0()
7926 .w_full()
7927 .gap_1()
7928 .justify_between()
7929 .flex_wrap()
7930 .child(Label::new(title).size(LabelSize::Small))
7931 .child(
7932 IconButton::new(
7933 SharedString::from(format!("close-{id}-onboarding")),
7934 IconName::Close,
7935 )
7936 .icon_size(IconSize::Small)
7937 .on_click(on_dismiss),
7938 ),
7939 )
7940 .child(
7941 Label::new(description)
7942 .size(LabelSize::Small)
7943 .color(Color::Muted)
7944 .mb_2(),
7945 )
7946 .child(
7947 Button::new(SharedString::from(format!("import-{id}")), button_label)
7948 .full_width()
7949 .style(ButtonStyle::OutlinedCustom(cx.theme().colors().border))
7950 .label_size(LabelSize::Small)
7951 .start_icon(
7952 Icon::new(IconName::Download)
7953 .size(IconSize::Small)
7954 .color(Color::Muted),
7955 )
7956 .on_click(on_import),
7957 )
7958}
7959
7960impl WorkspaceSidebar for Sidebar {
7961 fn width(&self, _cx: &App) -> Pixels {
7962 self.width
7963 }
7964
7965 fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
7966 self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
7967 cx.notify();
7968 }
7969
7970 fn has_notifications(&self, _cx: &App) -> bool {
7971 !self.contents.notified_threads.is_empty() || !self.contents.notified_terminals.is_empty()
7972 }
7973
7974 fn is_threads_list_view_active(&self) -> bool {
7975 matches!(self.view, SidebarView::ThreadList)
7976 }
7977
7978 fn side(&self, cx: &App) -> SidebarSide {
7979 AgentSettings::get_global(cx).sidebar_side()
7980 }
7981
7982 fn prepare_for_focus(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
7983 self.selection = None;
7984 cx.notify();
7985 }
7986
7987 fn toggle_thread_switcher(
7988 &mut self,
7989 select_last: bool,
7990 window: &mut Window,
7991 cx: &mut Context<Self>,
7992 ) {
7993 self.toggle_thread_switcher_impl(select_last, window, cx);
7994 }
7995
7996 fn cycle_project(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
7997 self.cycle_project_impl(forward, window, cx);
7998 }
7999
8000 fn cycle_thread(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
8001 self.cycle_thread_impl(forward, window, cx);
8002 }
8003
8004 fn serialized_state(&self, _cx: &App) -> Option<String> {
8005 let serialized = SerializedSidebar {
8006 width: Some(f32::from(self.width)),
8007 active_view: match self.view {
8008 SidebarView::ThreadList => SerializedSidebarView::ThreadList,
8009 SidebarView::Archive(_) => SerializedSidebarView::History,
8010 },
8011 };
8012 serde_json::to_string(&serialized).ok()
8013 }
8014
8015 fn restore_serialized_state(
8016 &mut self,
8017 state: &str,
8018 window: &mut Window,
8019 cx: &mut Context<Self>,
8020 ) {
8021 if let Some(serialized) = serde_json::from_str::<SerializedSidebar>(state).log_err() {
8022 if let Some(width) = serialized.width {
8023 self.width = px(width).clamp(MIN_WIDTH, MAX_WIDTH);
8024 }
8025 if serialized.active_view == SerializedSidebarView::History {
8026 cx.defer_in(window, |this, window, cx| {
8027 this.show_archive(window, cx);
8028 });
8029 }
8030 }
8031 cx.notify();
8032 }
8033}
8034
8035impl gpui::EventEmitter<workspace::SidebarEvent> for Sidebar {}
8036
8037impl Focusable for Sidebar {
8038 fn focus_handle(&self, _cx: &App) -> FocusHandle {
8039 self.focus_handle.clone()
8040 }
8041}
8042
8043impl Render for Sidebar {
8044 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
8045 let _titlebar_height = ui::utils::platform_title_bar_height(window);
8046 let ui_font = theme_settings::setup_ui_font(window, cx);
8047 let sticky_header = self.render_sticky_header(window, cx);
8048
8049 let color = cx.theme().colors();
8050 let bg = color
8051 .title_bar_background
8052 .blend(color.panel_background.opacity(0.25));
8053
8054 let no_open_projects = !self.contents.has_open_projects;
8055 let no_search_results = self.contents.entries.is_empty();
8056
8057 v_flex()
8058 .id("workspace-sidebar")
8059 .key_context(self.dispatch_context(window, cx))
8060 .track_focus(&self.focus_handle)
8061 .on_action(cx.listener(Self::select_next))
8062 .on_action(cx.listener(Self::select_previous))
8063 .on_action(cx.listener(Self::editor_move_down))
8064 .on_action(cx.listener(Self::editor_move_up))
8065 .on_action(cx.listener(Self::select_first))
8066 .on_action(cx.listener(Self::select_last))
8067 .on_action(cx.listener(Self::confirm))
8068 .on_action(cx.listener(Self::expand_selected_entry))
8069 .on_action(cx.listener(Self::collapse_selected_entry))
8070 .on_action(cx.listener(Self::toggle_selected_fold))
8071 .on_action(cx.listener(Self::fold_all))
8072 .on_action(cx.listener(Self::unfold_all))
8073 .on_action(cx.listener(Self::cancel))
8074 .on_action(cx.listener(Self::archive_selected_thread))
8075 .on_action(cx.listener(Self::rename_selected_thread))
8076 .on_action(cx.listener(Self::new_thread_in_group))
8077 .on_action(cx.listener(Self::new_terminal_thread))
8078 .on_action(cx.listener(Self::toggle_archive))
8079 .on_action(cx.listener(Self::focus_sidebar_filter))
8080 .on_action(cx.listener(Self::on_toggle_thread_switcher))
8081 .on_action(cx.listener(Self::on_next_project))
8082 .on_action(cx.listener(Self::on_previous_project))
8083 .on_action(cx.listener(Self::on_next_thread))
8084 .on_action(cx.listener(Self::on_previous_thread))
8085 .on_action(cx.listener(|this, _: &OpenRecent, window, cx| {
8086 this.recent_projects_popover_handle.toggle(window, cx);
8087 }))
8088 .font(ui_font)
8089 .map(|el| {
8090 let on_left = self.side(cx) == SidebarSide::Left;
8091 match window.window_decorations() {
8092 Decorations::Server => el.h_full().w(self.width),
8093 // With client-side decorations the sidebar owns the window
8094 // corners on its side, so round them like the title bar and
8095 // status bar do. The sidebar is stretched 1px outwards over
8096 // the window border on untiled edges (with compensating
8097 // padding) so its rounded background lines up exactly with
8098 // the window shape, avoiding a transparent gap in the
8099 // rounded corners.
8100 Decorations::Client { tiling, .. } => el
8101 .absolute()
8102 .top(if tiling.top { px(0.) } else { px(-1.) })
8103 .bottom(if tiling.bottom { px(0.) } else { px(-1.) })
8104 .when(!tiling.top, |el| el.pt_px())
8105 .when(!tiling.bottom, |el| el.pb_px())
8106 .map(|el| {
8107 if on_left {
8108 el.right(px(0.))
8109 .left(if tiling.left { px(0.) } else { px(-1.) })
8110 .when(!tiling.left, |el| el.pl(px(1.)))
8111 } else {
8112 el.left(px(0.))
8113 .right(if tiling.right { px(0.) } else { px(-1.) })
8114 .when(!tiling.right, |el| el.pr(px(1.)))
8115 }
8116 })
8117 .when(on_left && !(tiling.top || tiling.left), |el| {
8118 el.rounded_tl(CLIENT_SIDE_DECORATION_ROUNDING)
8119 })
8120 .when(on_left && !(tiling.bottom || tiling.left), |el| {
8121 el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING)
8122 })
8123 .when(!on_left && !(tiling.top || tiling.right), |el| {
8124 el.rounded_tr(CLIENT_SIDE_DECORATION_ROUNDING)
8125 })
8126 .when(!on_left && !(tiling.bottom || tiling.right), |el| {
8127 el.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
8128 }),
8129 }
8130 })
8131 .bg(bg)
8132 .when(self.side(cx) == SidebarSide::Left, |el| el.border_r_1())
8133 .when(self.side(cx) == SidebarSide::Right, |el| el.border_l_1())
8134 .border_color(color.border)
8135 .map(|this| match &self.view {
8136 SidebarView::ThreadList => this
8137 .child(self.render_sidebar_header(no_open_projects, window, cx))
8138 .map(|this| {
8139 if no_open_projects {
8140 this.child(self.render_empty_state(cx))
8141 } else {
8142 this.child(
8143 v_flex()
8144 .relative()
8145 .flex_1()
8146 .overflow_hidden()
8147 .child(
8148 list(
8149 self.list_state.clone(),
8150 cx.processor(Self::render_list_entry),
8151 )
8152 .flex_1()
8153 .size_full(),
8154 )
8155 .when(no_search_results, |this| {
8156 this.child(self.render_no_results(cx))
8157 })
8158 .when_some(sticky_header, |this, header| this.child(header))
8159 .custom_scrollbars(
8160 Scrollbars::new(ScrollAxes::Vertical)
8161 .tracked_scroll_handle(&self.list_state),
8162 window,
8163 cx,
8164 ),
8165 )
8166 }
8167 }),
8168 SidebarView::Archive(archive_view) => this.child(archive_view.clone()),
8169 })
8170 .map(|this| {
8171 let show_acp = self.should_render_acp_import_onboarding(cx);
8172 let show_cross_channel = self.should_render_cross_channel_import_onboarding(cx);
8173
8174 let verbose = *self
8175 .import_banners_use_verbose_labels
8176 .get_or_insert(show_acp && show_cross_channel);
8177
8178 this.when(show_acp, |this| {
8179 this.child(self.render_acp_import_onboarding(verbose, cx))
8180 })
8181 .when(show_cross_channel, |this| {
8182 this.child(self.render_cross_channel_import_onboarding(verbose, cx))
8183 })
8184 })
8185 .child(self.render_sidebar_bottom_bar(cx))
8186 }
8187}
8188
8189fn all_thread_infos_for_workspace(
8190 workspace: &Entity<Workspace>,
8191 cx: &App,
8192) -> impl Iterator<Item = ActiveThreadInfo> {
8193 let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
8194 return None.into_iter().flatten();
8195 };
8196 let agent_panel = agent_panel.read(cx);
8197 let threads = agent_panel
8198 .conversation_views()
8199 .into_iter()
8200 .filter_map(|conversation_view| {
8201 let has_pending_tool_call = conversation_view
8202 .read(cx)
8203 .root_thread_has_pending_tool_call(cx);
8204 let conversation_thread_id = conversation_view.read(cx).parent_id();
8205 let thread_view = conversation_view.read(cx).root_thread_view()?;
8206 let thread_view_ref = thread_view.read(cx);
8207 let thread = thread_view_ref.thread.read(cx);
8208
8209 let icon = thread_view_ref.agent_icon;
8210 let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone();
8211 let title = thread
8212 .title()
8213 .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into());
8214 let is_title_generating = thread_view_ref
8215 .as_native_thread(cx)
8216 .is_some_and(|native_thread| native_thread.read(cx).is_generating_title());
8217 let session_id = thread.session_id().clone();
8218 let is_background = agent_panel.is_retained_thread(&conversation_thread_id);
8219
8220 let status = if has_pending_tool_call {
8221 AgentThreadStatus::WaitingForConfirmation
8222 } else if thread.had_error() {
8223 AgentThreadStatus::Error
8224 } else {
8225 match thread.status() {
8226 ThreadStatus::Generating => AgentThreadStatus::Running,
8227 ThreadStatus::Idle => AgentThreadStatus::Completed,
8228 }
8229 };
8230
8231 let diff_stats = thread.action_log().read(cx).diff_stats(cx);
8232
8233 Some(ActiveThreadInfo {
8234 session_id,
8235 title,
8236 status,
8237 icon,
8238 icon_from_external_svg,
8239 is_background,
8240 is_title_generating,
8241 diff_stats,
8242 })
8243 });
8244
8245 Some(threads).into_iter().flatten()
8246}
8247
8248pub fn dump_workspace_info(
8249 workspace: &mut Workspace,
8250 _: &DumpWorkspaceInfo,
8251 window: &mut gpui::Window,
8252 cx: &mut gpui::Context<Workspace>,
8253) {
8254 use std::fmt::Write;
8255
8256 let mut output = String::new();
8257 let this_entity = cx.entity();
8258
8259 let multi_workspace = workspace.multi_workspace().and_then(|weak| weak.upgrade());
8260 let workspaces: Vec<gpui::Entity<Workspace>> = match &multi_workspace {
8261 Some(mw) => mw.read(cx).workspaces().cloned().collect(),
8262 None => vec![this_entity.clone()],
8263 };
8264 let active_workspace = multi_workspace
8265 .as_ref()
8266 .map(|mw| mw.read(cx).workspace().clone());
8267
8268 writeln!(output, "MultiWorkspace: {} workspace(s)", workspaces.len()).ok();
8269
8270 if let Some(mw) = &multi_workspace {
8271 let keys: Vec<_> = mw.read(cx).project_group_keys();
8272 writeln!(output, "Project group keys ({}):", keys.len()).ok();
8273 for key in keys {
8274 writeln!(output, " - {key:?}").ok();
8275 }
8276 }
8277
8278 writeln!(output).ok();
8279
8280 for (index, ws) in workspaces.iter().enumerate() {
8281 let is_active = active_workspace.as_ref() == Some(ws);
8282 writeln!(
8283 output,
8284 "--- Workspace {index}{} ---",
8285 if is_active { " (active)" } else { "" }
8286 )
8287 .ok();
8288
8289 // project_group_key_for_workspace internally reads the workspace,
8290 // so we can only call it for workspaces other than this_entity
8291 // (which is already being updated).
8292 if let Some(mw) = &multi_workspace {
8293 if *ws == this_entity {
8294 let workspace_key = workspace.project_group_key(cx);
8295 writeln!(output, "ProjectGroupKey: {workspace_key:?}").ok();
8296 } else {
8297 let effective_key = mw.read(cx).project_group_key_for_workspace(ws, cx);
8298 let workspace_key = ws.read(cx).project_group_key(cx);
8299 if effective_key != workspace_key {
8300 writeln!(
8301 output,
8302 "ProjectGroupKey (multi_workspace): {effective_key:?}"
8303 )
8304 .ok();
8305 writeln!(
8306 output,
8307 "ProjectGroupKey (workspace, DISAGREES): {workspace_key:?}"
8308 )
8309 .ok();
8310 } else {
8311 writeln!(output, "ProjectGroupKey: {effective_key:?}").ok();
8312 }
8313 }
8314 } else {
8315 let workspace_key = workspace.project_group_key(cx);
8316 writeln!(output, "ProjectGroupKey: {workspace_key:?}").ok();
8317 }
8318
8319 // The action handler is already inside an update on `this_entity`,
8320 // so we must avoid a nested read/update on that same entity.
8321 if *ws == this_entity {
8322 dump_single_workspace(workspace, &mut output, cx);
8323 } else {
8324 ws.read_with(cx, |ws, cx| {
8325 dump_single_workspace(ws, &mut output, cx);
8326 });
8327 }
8328 }
8329
8330 let project = workspace.project().clone();
8331 cx.spawn_in(window, async move |_this, cx| {
8332 let buffer = project
8333 .update(cx, |project, cx| project.create_buffer(None, false, cx))
8334 .await?;
8335
8336 buffer.update(cx, |buffer, cx| {
8337 buffer.set_text(output, cx);
8338 });
8339
8340 let buffer = cx.new(|cx| {
8341 editor::MultiBuffer::singleton(buffer, cx).with_title("Workspace Info".into())
8342 });
8343
8344 _this.update_in(cx, |workspace, window, cx| {
8345 workspace.add_item_to_active_pane(
8346 Box::new(cx.new(|cx| {
8347 let mut editor =
8348 editor::Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
8349 editor.set_read_only(true);
8350 editor.set_should_serialize(false, cx);
8351 editor.set_breadcrumb_header("Workspace Info".into());
8352 editor
8353 })),
8354 None,
8355 true,
8356 window,
8357 cx,
8358 );
8359 })
8360 })
8361 .detach_and_log_err(cx);
8362}
8363
8364fn dump_single_workspace(workspace: &Workspace, output: &mut String, cx: &gpui::App) {
8365 use std::fmt::Write;
8366
8367 let workspace_db_id = workspace.database_id();
8368 match workspace_db_id {
8369 Some(id) => writeln!(output, "Workspace DB ID: {id:?}").ok(),
8370 None => writeln!(output, "Workspace DB ID: (none)").ok(),
8371 };
8372
8373 let project = workspace.project().read(cx);
8374
8375 let repos: Vec<_> = project
8376 .repositories(cx)
8377 .values()
8378 .map(|repo| repo.read(cx).snapshot())
8379 .collect();
8380
8381 writeln!(output, "Worktrees:").ok();
8382 for worktree in project.worktrees(cx) {
8383 let worktree = worktree.read(cx);
8384 let abs_path = worktree.abs_path();
8385 let visible = worktree.is_visible();
8386
8387 let repo_info = repos
8388 .iter()
8389 .find(|snapshot| abs_path.starts_with(&*snapshot.work_directory_abs_path));
8390
8391 let is_linked = repo_info.map(|s| s.is_linked_worktree()).unwrap_or(false);
8392 let main_worktree_path = repo_info.and_then(|s| s.main_worktree_abs_path());
8393 let branch = repo_info.and_then(|s| s.branch.as_ref().map(|b| b.ref_name.clone()));
8394
8395 write!(output, " - {}", abs_path.display()).ok();
8396 if !visible {
8397 write!(output, " (hidden)").ok();
8398 }
8399 if let Some(branch) = &branch {
8400 write!(output, " [branch: {branch}]").ok();
8401 }
8402 if is_linked {
8403 if let Some(main_worktree_path) = main_worktree_path {
8404 write!(
8405 output,
8406 " [linked worktree -> {}]",
8407 main_worktree_path.display()
8408 )
8409 .ok();
8410 } else {
8411 write!(output, " [linked worktree]").ok();
8412 }
8413 }
8414 writeln!(output).ok();
8415 }
8416
8417 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
8418 let panel = panel.read(cx);
8419
8420 let panel_workspace_id = panel.workspace_id();
8421 if panel_workspace_id != workspace_db_id {
8422 writeln!(
8423 output,
8424 " \u{26a0} workspace ID mismatch! panel has {panel_workspace_id:?}, workspace has {workspace_db_id:?}"
8425 )
8426 .ok();
8427 }
8428
8429 if let Some(thread) = panel.active_agent_thread(cx) {
8430 let thread = thread.read(cx);
8431 let title = thread.title().unwrap_or_else(|| "(untitled)".into());
8432 let session_id = thread.session_id();
8433 let status = match thread.status() {
8434 ThreadStatus::Idle => "idle",
8435 ThreadStatus::Generating => "generating",
8436 };
8437 let entry_count = thread.entries().len();
8438 write!(output, "Active thread: {title} (session: {session_id})").ok();
8439 write!(output, " [{status}, {entry_count} entries").ok();
8440 if panel
8441 .active_conversation_view()
8442 .is_some_and(|conversation_view| {
8443 conversation_view
8444 .read(cx)
8445 .root_thread_has_pending_tool_call(cx)
8446 })
8447 {
8448 write!(output, ", awaiting confirmation").ok();
8449 }
8450 writeln!(output, "]").ok();
8451 } else {
8452 writeln!(output, "Active thread: (none)").ok();
8453 }
8454
8455 let background_threads = panel.retained_threads();
8456 if !background_threads.is_empty() {
8457 writeln!(
8458 output,
8459 "Background threads ({}): ",
8460 background_threads.len()
8461 )
8462 .ok();
8463 for (session_id, conversation_view) in background_threads {
8464 if let Some(thread_view) = conversation_view.read(cx).root_thread_view() {
8465 let thread = thread_view.read(cx).thread.read(cx);
8466 let title = thread.title().unwrap_or_else(|| "(untitled)".into());
8467 let status = match thread.status() {
8468 ThreadStatus::Idle => "idle",
8469 ThreadStatus::Generating => "generating",
8470 };
8471 let entry_count = thread.entries().len();
8472 write!(output, " - {title} (thread: {session_id:?})").ok();
8473 write!(output, " [{status}, {entry_count} entries").ok();
8474 if conversation_view
8475 .read(cx)
8476 .root_thread_has_pending_tool_call(cx)
8477 {
8478 write!(output, ", awaiting confirmation").ok();
8479 }
8480 writeln!(output, "]").ok();
8481 } else {
8482 writeln!(output, " - (not connected) (thread: {session_id:?})").ok();
8483 }
8484 }
8485 }
8486 } else {
8487 writeln!(output, "Agent panel: not loaded").ok();
8488 }
8489
8490 writeln!(output).ok();
8491}
8492