mod thread_switcher; use acp_thread::ThreadStatus; use action_log::DiffStats; use agent::{ThreadStore, ZED_AGENT_ID}; use agent_client_protocol::schema::v1 as acp; use agent_settings::AgentSettings; use agent_ui::terminal_thread_metadata_store::{ TerminalThreadMetadata, TerminalThreadMetadataStore, terminal_title_prefix, }; use agent_ui::thread_metadata_store::{ ThreadMetadata, ThreadMetadataStore, WorktreePaths, worktree_info_from_thread_paths, }; use agent_ui::threads_archive_view::{ ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp, fuzzy_match_positions, }; use agent_ui::{ AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, AgentThreadSource, ArchiveSelectedThread, CrossChannelImportOnboarding, DEFAULT_THREAD_TITLE, NewTerminalThread, NewThread, RenameSelectedThread, TerminalId, ThreadId, ThreadImportModal, ThreadTitleRegenerationResult, channels_with_threads, import_threads_from_other_channels, }; use agent_ui::{MessageEditorEvent, StateChange, thread_worktree_archive}; use chrono::{DateTime, Utc}; use editor::Editor; use feature_flags::{ AgentThreadWorktreeLabel, AgentThreadWorktreeLabelFlag, FeatureFlag, FeatureFlagAppExt as _, }; use gpui::{ Action as _, AnyElement, App, ClickEvent, Context, Decorations, DismissEvent, Entity, EntityId, FocusHandle, Focusable, KeyContext, ListState, Modifiers, Pixels, Render, SharedString, Task, TaskExt, WeakEntity, Window, WindowBackgroundAppearance, WindowHandle, linear_color_stop, linear_gradient, list, prelude::*, px, }; use itertools::Itertools; use language_model::LanguageModelRegistry; use menu::{ Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious, }; use notifications::status_toast::StatusToast; use project::{AgentId, AgentRegistryStore, Event as ProjectEvent, WorktreeId}; use recent_projects::sidebar_recent_projects::SidebarRecentProjects; use remote::{RemoteConnectionOptions, same_remote_connection_identity}; use ui::utils::platform_title_bar_height; use serde::{Deserialize, Serialize}; use settings::Settings as _; use std::cmp::Ordering; use std::collections::{HashMap, HashSet}; use std::mem; use std::path::{Path, PathBuf}; use std::rc::Rc; use std::sync::Arc; use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING}; use ui::{ AgentThreadStatus, CommonAnimationExt, ContextMenu, ContextMenuEntry, Divider, GradientFade, HighlightedLabel, KeyBinding, PopoverMenu, PopoverMenuHandle, ProjectEmptyState, ScrollAxes, Scrollbars, Tab, ThreadItem, ThreadItemWorktreeInfo, TintColor, Tooltip, WithScrollbar, prelude::*, render_modifiers, right_click_menu, }; use unicode_segmentation::UnicodeSegmentation as _; use util::ResultExt as _; use util::path_list::PathList; use workspace::{ CloseWindow, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, NextProject, NextThread, Open, OpenMode, PreviousProject, PreviousThread, ProjectGroupKey, SaveIntent, Sidebar as WorkspaceSidebar, SidebarSide, Toast, ToggleWorkspaceSidebar, Workspace, notifications::NotificationId, sidebar_side_context_menu, }; use git_ui::worktree_service::{RemoteBranchName, worktree_create_targets}; use zed_actions::editor::{MoveDown, MoveUp}; use zed_actions::{CreateWorktree, NewWorktreeBranchTarget, OpenRecent}; use zed_actions::agents_sidebar::{FocusSidebarFilter, ToggleThreadSwitcher}; use crate::thread_switcher::{ ThreadSwitcher, ThreadSwitcherEntry, ThreadSwitcherEvent, ThreadSwitcherSelection, ThreadSwitcherTerminalEntry, ThreadSwitcherThreadEntry, }; #[cfg(test)] mod sidebar_tests; gpui::actions!( agents_sidebar, [ /// Creates a new thread in the currently selected or active project group. NewThreadInGroup, /// Toggles between the thread list and the thread history. ToggleThreadHistory, ] ); gpui::actions!( dev, [ /// Dumps multi-workspace state (projects, worktrees, active threads) into a new buffer. DumpWorkspaceInfo, ] ); const DEFAULT_WIDTH: Pixels = px(300.0); const MIN_WIDTH: Pixels = px(200.0); const MAX_WIDTH: Pixels = px(800.0); #[derive(Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] enum SerializedSidebarView { #[default] ThreadList, #[serde(alias = "Archive")] History, } #[derive(Clone, Copy)] enum NewEntryTarget { LastCreatedKind, Terminal, } #[derive(Default, Serialize, Deserialize)] struct SerializedSidebar { #[serde(default)] width: Option, #[serde(default)] active_view: SerializedSidebarView, } #[derive(Debug, Default)] enum SidebarView { #[default] ThreadList, Archive(Entity), } enum ArchiveWorktreeOutcome { Success, Cancelled, } #[derive(Clone, Debug)] enum ActiveEntry { Thread { thread_id: agent_ui::ThreadId, /// Stable remote identifier, used for matching when thread_id /// differs (e.g. after cross-window activation creates a new /// local ThreadId). session_id: Option, workspace: Entity, }, Terminal { terminal_id: TerminalId, workspace: Entity, }, } impl ActiveEntry { fn workspace(&self) -> &Entity { match self { ActiveEntry::Thread { workspace, .. } | ActiveEntry::Terminal { workspace, .. } => { workspace } } } fn is_active_thread(&self, thread_id: &agent_ui::ThreadId) -> bool { matches!(self, ActiveEntry::Thread { thread_id: active_thread_id, .. } if active_thread_id == thread_id) } fn is_active_terminal(&self, terminal_id: TerminalId) -> bool { matches!(self, ActiveEntry::Terminal { terminal_id: active_terminal_id, .. } if *active_terminal_id == terminal_id) } fn matches_entry(&self, entry: &ListEntry) -> bool { match (self, entry) { ( ActiveEntry::Thread { thread_id, session_id, .. }, ListEntry::Thread(thread), ) => { *thread_id == thread.metadata.thread_id || session_id .as_ref() .zip(thread.metadata.session_id.as_ref()) .is_some_and(|(a, b)| a == b) } (ActiveEntry::Terminal { terminal_id, .. }, ListEntry::Terminal(terminal)) => { *terminal_id == terminal.metadata.terminal_id } _ => false, } } } #[derive(Clone, Debug)] struct ActiveThreadInfo { session_id: acp::SessionId, title: SharedString, status: AgentThreadStatus, icon: IconName, icon_from_external_svg: Option, is_background: bool, is_title_generating: bool, diff_stats: DiffStats, } #[derive(Clone)] enum ThreadEntryWorkspace { Open(Entity), Closed { /// The paths this entry uses (may point to linked worktrees). folder_paths: PathList, /// The project group this entry belongs to. project_group_key: ProjectGroupKey, }, } impl ThreadEntryWorkspace { fn is_remote(&self, cx: &App) -> bool { match self { ThreadEntryWorkspace::Open(workspace) => { !workspace.read(cx).project().read(cx).is_local() } ThreadEntryWorkspace::Closed { project_group_key, .. } => project_group_key.host().is_some(), } } } /// If the title begins with a decorative prefix (such as a leading emoji, /// spinner glyph, or symbol the agent prefixed the title with), splits that /// prefix off so a single representative glyph can be displayed in place of the /// entry's icon. fn split_leading_icon_char( title: &SharedString, highlight_positions: &[usize], ) -> Option<(SharedString, SharedString, Vec)> { let prefix = terminal_title_prefix(title)?; let icon_char = pick_icon_glyph(prefix)?; let stripped_len = prefix.len(); let trimmed_title = &title[stripped_len..]; if trimmed_title.is_empty() { return None; } let adjusted_positions = highlight_positions .iter() .filter(|&&position| position >= stripped_len) .map(|&position| position - stripped_len) .collect(); Some(( icon_char, trimmed_title.to_string().into(), adjusted_positions, )) } /// Picks a single glyph to render as the icon from a detected title prefix. /// /// We only ever show one glyph, so this makes a best effort to choose a /// meaningful one by glancing at the leading characters of the prefix: /// runs of `.` are condensed into a single ellipsis, surrounding ASCII brackets /// are stripped (so `[!]` yields `!`), and a leading run of the same character /// is collapsed (so `>>>` yields `>`). The result is the first grapheme cluster /// of whatever remains, keeping multi-codepoint emoji intact. fn pick_icon_glyph(prefix: &str) -> Option { let prefix = prefix.trim(); if prefix.is_empty() { return None; } // Strip a single pair of surrounding ASCII brackets, e.g. `[!]` -> `!`. let unwrapped = match prefix.chars().next() { Some('[') => prefix.strip_prefix('[').and_then(|s| s.strip_suffix(']')), Some('(') => prefix.strip_prefix('(').and_then(|s| s.strip_suffix(')')), Some('{') => prefix.strip_prefix('{').and_then(|s| s.strip_suffix('}')), Some('<') => prefix.strip_prefix('<').and_then(|s| s.strip_suffix('>')), _ => None, }; let prefix = unwrapped .map(str::trim) .filter(|s| !s.is_empty()) .unwrap_or(prefix); // Condense a leading run of dots (`...`) into a single ellipsis. if prefix.starts_with("..") { return Some("\u{2026}".into()); } // Take the first grapheme cluster so multi-codepoint emoji stay intact. let first_grapheme = prefix.graphemes(true).next()?; if first_grapheme.trim().is_empty() { return None; } Some(first_grapheme.to_string().into()) } fn draft_display_label_for_thread_metadata( metadata: &ThreadMetadata, workspace: &ThreadEntryWorkspace, cx: &App, ) -> Option<(SharedString, DraftKind)> { let workspace = match workspace { ThreadEntryWorkspace::Open(workspace) => Some(workspace), ThreadEntryWorkspace::Closed { .. } => None, }; if let Some(label) = agent_ui::draft_prompt_store::display_label_for_draft(workspace, metadata.thread_id, cx) { return Some((label, DraftKind::WithContent)); } let placeholder = agent_ui::draft_prompt_store::empty_draft_placeholder_label( workspace, &metadata.agent_id, cx, ); Some((placeholder, DraftKind::Empty)) } fn thread_metadata_would_render_sidebar_row( metadata: &ThreadMetadata, workspace: &ThreadEntryWorkspace, cx: &App, ) -> bool { if !metadata.is_draft() { return true; } draft_display_label_for_thread_metadata(metadata, workspace, cx).is_some() } #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum DraftKind { WithContent, Empty, } #[derive(Clone)] struct ThreadEntry { metadata: ThreadMetadata, icon: IconName, icon_from_external_svg: Option, status: AgentThreadStatus, workspace: ThreadEntryWorkspace, is_live: bool, is_background: bool, is_title_generating: bool, draft: Option, highlight_positions: Vec, worktrees: Vec, diff_stats: DiffStats, } #[derive(Clone)] struct TerminalEntry { metadata: TerminalThreadMetadata, workspace: ThreadEntryWorkspace, worktrees: Vec, has_notification: bool, highlight_positions: Vec, } impl ThreadEntry { /// Updates this thread entry with active thread information. /// /// The existing [`ThreadEntry`] was likely deserialized from the database /// but if we have a correspond thread already loaded we want to apply the /// live information. fn apply_active_info(&mut self, info: &ActiveThreadInfo) { self.metadata.title = Some(info.title.clone()); self.status = info.status; self.icon = info.icon; self.icon_from_external_svg = info.icon_from_external_svg.clone(); self.is_live = true; self.is_background = info.is_background; self.is_title_generating = info.is_title_generating; self.diff_stats = info.diff_stats; } } #[derive(Clone)] enum ListEntry { ProjectHeader { key: ProjectGroupKey, label: SharedString, highlight_positions: Vec, has_running_threads: bool, waiting_thread_count: usize, has_notifications: bool, is_active: bool, has_threads: bool, }, Thread(Arc), Terminal(TerminalEntry), } #[derive(Clone)] enum ActivatableEntry { Thread { metadata: ThreadMetadata, workspace: ThreadEntryWorkspace, }, Terminal { metadata: TerminalThreadMetadata, workspace: ThreadEntryWorkspace, }, } impl ActivatableEntry { fn from_list_entry(entry: &ListEntry) -> Option { match entry { ListEntry::Thread(thread) => Some(Self::Thread { metadata: thread.metadata.clone(), workspace: thread.workspace.clone(), }), ListEntry::Terminal(terminal) => Some(Self::Terminal { metadata: terminal.metadata.clone(), workspace: terminal.workspace.clone(), }), ListEntry::ProjectHeader { .. } => None, } } fn project_location(&self, cx: &App) -> (PathList, ProjectGroupKey) { match self { Self::Thread { workspace: ThreadEntryWorkspace::Open(workspace), .. } | Self::Terminal { workspace: ThreadEntryWorkspace::Open(workspace), .. } => ( PathList::new(&workspace.read(cx).root_paths(cx)), workspace.read(cx).project_group_key(cx), ), Self::Thread { workspace: ThreadEntryWorkspace::Closed { folder_paths, project_group_key, }, .. } | Self::Terminal { workspace: ThreadEntryWorkspace::Closed { folder_paths, project_group_key, }, .. } => (folder_paths.clone(), project_group_key.clone()), } } } #[cfg(test)] impl ListEntry { fn session_id(&self) -> Option<&acp::SessionId> { match self { ListEntry::Thread(thread_entry) => thread_entry.metadata.session_id.as_ref(), ListEntry::Terminal(_) | ListEntry::ProjectHeader { .. } => None, } } fn reachable_workspaces<'a>( &'a self, multi_workspace: &'a workspace::MultiWorkspace, cx: &'a App, ) -> Vec> { match self { ListEntry::Thread(thread) => match &thread.workspace { ThreadEntryWorkspace::Open(ws) => vec![ws.clone()], ThreadEntryWorkspace::Closed { .. } => Vec::new(), }, ListEntry::Terminal(terminal) => match &terminal.workspace { ThreadEntryWorkspace::Open(workspace) => vec![workspace.clone()], ThreadEntryWorkspace::Closed { .. } => Vec::new(), }, ListEntry::ProjectHeader { key, .. } => multi_workspace .workspaces_for_project_group(key, cx) .unwrap_or_default(), } } } impl From for ListEntry { fn from(thread: ThreadEntry) -> Self { ListEntry::Thread(Arc::new(thread)) } } impl From for ListEntry { fn from(terminal: TerminalEntry) -> Self { ListEntry::Terminal(terminal) } } #[derive(Default)] struct SidebarContents { entries: Vec, notified_threads: HashSet, notified_terminals: HashSet, project_header_indices: Vec, has_open_projects: bool, } /// Identity-and-layout key for a [`ListEntry`] used to preserve measured list items /// across rebuilds. Equal shapes must render to the same height; add any new /// height-affecting state here. #[derive(Debug, PartialEq, Eq)] enum EntryShape { ProjectHeader { key: ProjectGroupKey, // Toggles the "No threads yet" empty-state row when not collapsed. has_threads: bool, // Determines whether the "No threads yet" row is rendered (only shown when // `!is_collapsed && !has_threads`). is_collapsed: bool, }, Thread(ThreadId), Terminal(TerminalId), } impl SidebarContents { fn is_thread_notified(&self, thread_id: &agent_ui::ThreadId) -> bool { self.notified_threads.contains(thread_id) } fn is_terminal_notified(&self, terminal_id: TerminalId) -> bool { self.notified_terminals.contains(&terminal_id) } } // TODO: The mapping from workspace root paths to git repositories needs a // unified approach across the codebase: this function, `AgentPanel::classify_worktrees`, // thread persistence (which PathList is saved to the database), and thread // querying (which PathList is used to read threads back). All of these need // to agree on how repos are resolved for a given workspace, especially in // multi-root and nested-repo configurations. fn root_repository_snapshots( workspace: &Entity, cx: &App, ) -> impl Iterator { let path_list = workspace_path_list(workspace, cx); let project = workspace.read(cx).project().read(cx); project.repositories(cx).values().filter_map(move |repo| { let snapshot = repo.read(cx).snapshot(); let is_root = path_list .paths() .iter() .any(|p| p.as_path() == snapshot.work_directory_abs_path.as_ref()); is_root.then_some(snapshot) }) } fn workspace_path_list(workspace: &Entity, cx: &App) -> PathList { PathList::new(&workspace.read(cx).root_paths(cx)) } fn linked_worktree_path_lists_for_workspaces( workspaces: &[Entity], cx: &App, ) -> Vec { let mut linked_worktree_paths = Vec::new(); for workspace in workspaces { if workspace.read(cx).visible_worktrees(cx).count() != 1 { continue; } for snapshot in root_repository_snapshots(workspace, cx) { linked_worktree_paths.extend( snapshot.linked_worktrees().iter().map(|linked_worktree| { PathList::new(std::slice::from_ref(&linked_worktree.path)) }), ); } } linked_worktree_paths.sort_by(|a, b| a.paths()[0].cmp(&b.paths()[0])); linked_worktree_paths } fn workspace_has_terminal_metadata_except( workspace: &Entity, except_terminal_id: Option, cx: &App, ) -> bool { let Some(store) = TerminalThreadMetadataStore::try_global(cx) else { return false; }; let path_list = workspace_path_list(workspace, cx); let remote_connection = workspace .read(cx) .project() .read(cx) .remote_connection_options(cx); store .read(cx) .entries_for_path(&path_list, remote_connection.as_ref()) .any(|terminal| except_terminal_id != Some(terminal.terminal_id)) } #[derive(Clone)] struct WorkspaceMenuWorktreeLabel { icon: Option, primary_name: SharedString, secondary_name: Option, } impl WorkspaceMenuWorktreeLabel { fn render(&self) -> impl IntoElement { h_flex() .min_w_0() .gap_0p5() .when_some(self.icon, |this, icon| { this.child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)) }) .child(Label::new(self.primary_name.clone()).truncate()) .when_some(self.secondary_name.clone(), |this, secondary_name| { this.child(Label::new("/").alpha(0.5)) .child(Label::new(secondary_name).truncate()) }) } } fn workspace_menu_worktree_labels( workspace: &Entity, cx: &App, ) -> Vec { let root_paths = workspace.read(cx).root_paths(cx); let show_folder_name = root_paths.len() > 1; let project = workspace.read(cx).project().clone(); let repository_snapshots: Vec<_> = project .read(cx) .repositories(cx) .values() .map(|repo| repo.read(cx).snapshot()) .collect(); root_paths .into_iter() .map(|root_path| { let root_path = root_path.as_ref(); let folder_name = root_path .file_name() .map(|name| SharedString::from(name.to_string_lossy().to_string())) .unwrap_or_default(); let repository_snapshot = repository_snapshots .iter() .find(|snapshot| snapshot.work_directory_abs_path.as_ref() == root_path); if let Some(snapshot) = repository_snapshot { let worktree_name = if snapshot.is_linked_worktree() { snapshot .main_worktree_abs_path() .and_then(|main_worktree_path| { project::linked_worktree_short_name(main_worktree_path, root_path) }) .unwrap_or_else(|| folder_name.clone()) } else { "main".into() }; if show_folder_name { WorkspaceMenuWorktreeLabel { icon: Some(IconName::GitWorktree), primary_name: folder_name, secondary_name: Some(worktree_name), } } else { WorkspaceMenuWorktreeLabel { icon: Some(IconName::GitWorktree), primary_name: worktree_name, secondary_name: None, } } } else { WorkspaceMenuWorktreeLabel { icon: None, primary_name: folder_name, secondary_name: None, } } }) .collect() } fn apply_worktree_label_mode( mut worktrees: Vec, mode: AgentThreadWorktreeLabel, ) -> Vec { match mode { AgentThreadWorktreeLabel::Both => {} AgentThreadWorktreeLabel::Worktree => { for wt in &mut worktrees { wt.branch_name = None; } } AgentThreadWorktreeLabel::Branch => { for wt in &mut worktrees { // Fall back to showing the worktree name when no branch is // known; an empty chip would be worse than a mismatched icon. if wt.branch_name.is_some() { wt.worktree_name = None; } } } } worktrees } /// Shows a [`RemoteConnectionModal`] on the given workspace and establishes /// an SSH connection. Suitable for passing to /// [`MultiWorkspace::find_or_create_workspace`] as the `connect_remote` /// argument. fn connect_remote( modal_workspace: Entity, connection_options: RemoteConnectionOptions, window: &mut Window, cx: &mut Context, ) -> gpui::Task>>> { remote_connection::connect_with_modal(&modal_workspace, connection_options, window, cx) } // Per-project-group cache of the remote default branch, used to populate the // "Create New Worktree" submenu without doing git I/O while the menu is open. enum DefaultBranchCache { Pending, Resolved(Option), } // Mirrors the behavior of the worktree picker's "Create new worktree" entries. fn create_worktree_in_workspace( workspace: &Entity, branch_target: NewWorktreeBranchTarget, window: &mut Window, cx: &mut App, ) { workspace.update(cx, |workspace, cx| { let focused_dock = workspace.focused_dock_position(window, cx); git_ui::worktree_service::handle_create_worktree( workspace, &CreateWorktree { worktree_name: None, branch_target, }, window, focused_dock, cx, ); }); } /// The sidebar re-derives its entire entry list from scratch on every /// change via `update_entries` → `rebuild_contents`. Avoid adding /// incremental or inter-event coordination state — if something can /// be computed from the current world state, compute it in the rebuild. pub struct Sidebar { multi_workspace: WeakEntity, width: Pixels, focus_handle: FocusHandle, filter_editor: Entity, thread_rename_editor: Entity, list_state: ListState, contents: SidebarContents, /// The index of the list item that currently has the keyboard focus /// /// Note: This is NOT the same as the active item. selection: Option, /// Tracks which sidebar entry is currently active (highlighted). active_entry: Option, hovered_thread_index: Option, renaming_thread_id: Option, /// Threads in the database-backed regeneration path need their own loading /// state because they do not have a live `agent::Thread` to report it. regenerating_titles: HashSet, /// start_renaming_thread must seed current title into the title editor /// so this prevents that BufferEdited event from being interpreted as user input. suppress_next_rename_edit: bool, /// Updated only in response to explicit user actions (clicking a /// thread, confirming in the thread switcher, etc.) — never from /// background data changes. Used to sort the thread switcher popup. thread_last_accessed: HashMap>, terminal_last_accessed: HashMap>, thread_switcher: Option>, _thread_switcher_subscriptions: Vec, pending_thread_activation: Option, /// Persists live thread statuses across rebuilds so that Running→Completed /// transitions can be detected even when the group is collapsed (and /// thread entries are not present in the list). live_thread_statuses: HashMap, /// Remembers whether each draft last rendered as empty or with content so /// that when a draft that was empty gains content again, we refresh /// its interaction time. draft_kinds: HashMap, view: SidebarView, restoring_tasks: HashMap>, recent_projects_popover_handle: PopoverMenuHandle, project_header_menu_handles: HashMap>, project_header_new_thread_menu_handles: HashMap>, project_header_menu_ix: Option, worktree_default_branches: HashMap, _subscriptions: Vec, _draft_editor_observations: Vec, update_task: Option>, /// For the thread import banners, if there is just one we show "Import /// Threads" but if we are showing both the external agents and other /// channels import banners then we change the text to disambiguate the /// buttons. This field tracks whether we were using verbose labels so they /// can stay stable after dismissing one of the banners. import_banners_use_verbose_labels: Option, /// Display names of other release channels that have threads available to /// import. cross_channel_import_channels: Vec, } impl Sidebar { pub fn new( multi_workspace: Entity, window: &mut Window, cx: &mut Context, ) -> Self { let focus_handle = cx.focus_handle(); cx.on_focus_in(&focus_handle, window, Self::focus_in) .detach(); AgentThreadWorktreeLabelFlag::watch(cx); let filter_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); editor.set_placeholder_text("Search threads…", window, cx); editor }); let thread_rename_editor = cx.new(|cx| Editor::single_line(window, cx)); cx.subscribe_in( &multi_workspace, window, |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event { MultiWorkspaceEvent::ActiveWorkspaceChanged { .. } => { this.sync_active_entry_from_active_workspace(cx); this.replace_archived_panel_thread(window, cx); this.schedule_update_entries(false, cx); } MultiWorkspaceEvent::WorkspaceAdded(workspace) => { this.subscribe_to_workspace(workspace, window, cx); this.schedule_update_entries(false, cx); } MultiWorkspaceEvent::WorkspaceRemoved(_) | MultiWorkspaceEvent::ProjectGroupsChanged => { this.schedule_update_entries(false, cx); } }, ) .detach(); cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| { if let editor::EditorEvent::BufferEdited = event { let query = this.filter_editor.read(cx).text(cx); if !query.is_empty() { this.selection.take(); } this.schedule_update_entries(!query.is_empty(), cx); } }) .detach(); cx.subscribe_in( &thread_rename_editor, window, |this, title_editor, event, window, cx| { this.handle_thread_rename_editor_event(title_editor, event, window, cx); }, ) .detach(); cx.observe(&ThreadMetadataStore::global(cx), |this, _store, cx| { this.schedule_update_entries(false, cx); }) .detach(); cx.observe( &TerminalThreadMetadataStore::global(cx), |this, _store, cx| { this.schedule_update_entries(false, cx); }, ) .detach(); let channels_with_threads = channels_with_threads(cx); cx.spawn(async move |this, cx| { let channels = channels_with_threads.await; this.update(cx, |this, cx| { this.cross_channel_import_channels = channels; cx.notify(); }) .ok(); }) .detach(); let deferred_multi_workspace = multi_workspace.downgrade(); cx.defer_in(window, move |this, window, cx| { if let Some(multi_workspace) = deferred_multi_workspace.upgrade() { let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect(); for workspace in &workspaces { this.subscribe_to_workspace(workspace, window, cx); } } this.schedule_update_entries(false, cx); }); Self { multi_workspace: multi_workspace.downgrade(), width: DEFAULT_WIDTH, focus_handle, filter_editor, thread_rename_editor, list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)), contents: SidebarContents::default(), selection: None, active_entry: None, hovered_thread_index: None, renaming_thread_id: None, regenerating_titles: HashSet::new(), suppress_next_rename_edit: false, thread_last_accessed: HashMap::new(), terminal_last_accessed: HashMap::new(), thread_switcher: None, _thread_switcher_subscriptions: Vec::new(), pending_thread_activation: None, live_thread_statuses: HashMap::new(), draft_kinds: HashMap::new(), view: SidebarView::default(), restoring_tasks: HashMap::new(), recent_projects_popover_handle: PopoverMenuHandle::default(), project_header_menu_handles: HashMap::new(), project_header_new_thread_menu_handles: HashMap::new(), project_header_menu_ix: None, worktree_default_branches: HashMap::new(), _subscriptions: Vec::new(), _draft_editor_observations: Vec::new(), update_task: None, import_banners_use_verbose_labels: None, cross_channel_import_channels: Vec::new(), } } fn serialize(&mut self, cx: &mut Context) { cx.emit(workspace::SidebarEvent::SerializeNeeded); } fn is_group_collapsed(&self, key: &ProjectGroupKey, cx: &App) -> bool { self.multi_workspace .upgrade() .and_then(|mw| { mw.read(cx) .group_state_by_key(key) .map(|state| !state.expanded) }) .unwrap_or(false) } fn set_group_expanded(&self, key: &ProjectGroupKey, expanded: bool, cx: &mut Context) { if let Some(mw) = self.multi_workspace.upgrade() { mw.update(cx, |mw, cx| { if let Some(state) = mw.group_state_by_key_mut(key) { state.expanded = expanded; } mw.serialize(cx); }); } } fn is_active_workspace(&self, workspace: &Entity, cx: &App) -> bool { self.multi_workspace .upgrade() .map_or(false, |mw| mw.read(cx).workspace() == workspace) } fn subscribe_to_workspace( &mut self, workspace: &Entity, window: &mut Window, cx: &mut Context, ) { let project = workspace.read(cx).project().clone(); if project.read(cx).is_via_collab() { return; } cx.subscribe_in( &project, window, |this, project, event, _window, cx| match event { ProjectEvent::WorktreeAdded(_) | ProjectEvent::WorktreeRemoved(_) | ProjectEvent::WorktreeOrderChanged => { this.schedule_update_entries(false, cx); } ProjectEvent::WorktreePathsChanged { old_worktree_paths } => { this.move_entry_paths(project, old_worktree_paths, cx); this.schedule_update_entries(false, cx); } _ => {} }, ) .detach(); let git_store = workspace.read(cx).project().read(cx).git_store().clone(); cx.subscribe_in( &git_store, window, |this, _, event: &project::git_store::GitStoreEvent, _window, cx| { if matches!( event, project::git_store::GitStoreEvent::RepositoryUpdated( _, project::git_store::RepositoryEvent::GitWorktreeListChanged | project::git_store::RepositoryEvent::HeadChanged, _, ) ) { this.schedule_update_entries(false, cx); } }, ) .detach(); cx.subscribe_in( workspace, window, move |this, workspace, event: &workspace::Event, window, cx| { if let workspace::Event::PanelAdded(view) = event { if let Ok(agent_panel) = view.clone().downcast::() { this.subscribe_to_agent_panel(workspace, &agent_panel, window, cx); this.schedule_update_entries(false, cx); } } }, ) .detach(); self.observe_docks(workspace, cx); if let Some(agent_panel) = workspace.read(cx).panel::(cx) { self.subscribe_to_agent_panel(workspace, &agent_panel, window, cx); } } fn move_entry_paths( &mut self, project: &Entity, old_paths: &WorktreePaths, cx: &mut Context, ) { if project.read(cx).is_via_collab() { return; } let new_paths = project.read(cx).worktree_paths(cx); let old_folder_paths = old_paths.folder_path_list().clone(); let added_pairs: Vec<_> = new_paths .ordered_pairs() .filter(|(main, folder)| { !old_paths .ordered_pairs() .any(|(old_main, old_folder)| old_main == *main && old_folder == *folder) }) .map(|(m, f)| (m.clone(), f.clone())) .collect(); let new_folder_paths = new_paths.folder_path_list(); let removed_folder_paths: Vec = old_folder_paths .paths() .iter() .filter(|p| !new_folder_paths.paths().contains(p)) .cloned() .collect(); if added_pairs.is_empty() && removed_folder_paths.is_empty() { return; } let remote_connection = project.read(cx).remote_connection_options(cx); let apply_path_changes = |paths: &mut WorktreePaths| { for (main_path, folder_path) in &added_pairs { paths.add_path(main_path, folder_path); } for path in &removed_folder_paths { paths.remove_folder_path(path); } }; ThreadMetadataStore::global(cx).update(cx, |store, store_cx| { store.change_worktree_paths( &old_folder_paths, remote_connection.as_ref(), &apply_path_changes, store_cx, ); }); TerminalThreadMetadataStore::global(cx).update(cx, |store, store_cx| { store.change_worktree_paths( &old_folder_paths, remote_connection.as_ref(), &apply_path_changes, store_cx, ); }); } fn subscribe_to_agent_panel( &mut self, workspace: &Entity, agent_panel: &Entity, window: &mut Window, cx: &mut Context, ) { let workspace = workspace.downgrade(); cx.subscribe_in( agent_panel, window, move |this, agent_panel, event: &AgentPanelEvent, window, cx| match event { AgentPanelEvent::ActiveViewChanged | AgentPanelEvent::ActiveViewFocused | AgentPanelEvent::EntryChanged => { this.sync_active_entry_from_panel(agent_panel, cx); this.schedule_update_entries(false, cx); } AgentPanelEvent::TerminalCloseRequested { metadata } => { if let Some(workspace) = workspace.upgrade() { let workspace = ThreadEntryWorkspace::Open(workspace); this.close_terminal(metadata, &workspace, window, cx); } } AgentPanelEvent::ThreadInteracted { thread_id } => { this.record_thread_interacted(thread_id, cx); this.schedule_update_entries(false, cx); } }, ) .detach(); } fn sync_active_entry_from_active_workspace(&mut self, cx: &App) { let panel = self .active_workspace(cx) .and_then(|ws| ws.read(cx).panel::(cx)); if let Some(panel) = panel { self.sync_active_entry_from_panel(&panel, cx); } } /// When switching workspaces, the active panel may still be showing /// a thread that was archived from a different workspace. In that /// case, create a fresh draft so the panel has valid content and /// `active_entry` can point at it. fn replace_archived_panel_thread(&mut self, window: &mut Window, cx: &mut Context) { let Some(workspace) = self.active_workspace(cx) else { return; }; let Some(panel) = workspace.read(cx).panel::(cx) else { return; }; let Some(thread_id) = panel.read(cx).active_thread_id(cx) else { return; }; let is_archived = ThreadMetadataStore::global(cx) .read(cx) .entry(thread_id) .is_some_and(|m| m.archived); if is_archived { self.create_new_thread(&workspace, window, cx); } } /// Syncs `active_entry` from the agent panel's current state. /// Called from `ActiveViewChanged` — the panel has settled into its /// new view, so we can safely read it without race conditions. /// /// Also resolves `pending_thread_activation` when the panel's /// active thread matches the pending activation. fn sync_active_entry_from_panel(&mut self, agent_panel: &Entity, cx: &App) -> bool { let Some(active_workspace) = self.active_workspace(cx) else { return false; }; // Only sync when the event comes from the active workspace's panel. let is_active_panel = active_workspace .read(cx) .panel::(cx) .is_some_and(|p| p == *agent_panel); if !is_active_panel { return false; } let panel = agent_panel.read(cx); if let Some(pending_thread_id) = self.pending_thread_activation { let panel_thread_id = panel .active_conversation_view() .map(|cv| cv.read(cx).parent_id()); if panel_thread_id == Some(pending_thread_id) { let session_id = panel .active_agent_thread(cx) .map(|thread| thread.read(cx).session_id().clone()); self.active_entry = Some(ActiveEntry::Thread { thread_id: pending_thread_id, session_id, workspace: active_workspace, }); self.pending_thread_activation = None; return true; } // Pending activation not yet resolved — keep current active_entry. return false; } if let Some(terminal_id) = panel.active_terminal_id() { self.active_entry = Some(ActiveEntry::Terminal { terminal_id, workspace: active_workspace, }); } else if let Some(thread_id) = panel.active_thread_id(cx) { let is_archived = ThreadMetadataStore::global(cx) .read(cx) .entry(thread_id) .is_some_and(|m| m.archived); if !is_archived { let session_id = panel .active_agent_thread(cx) .map(|thread| thread.read(cx).session_id().clone()); self.active_entry = Some(ActiveEntry::Thread { thread_id, session_id, workspace: active_workspace, }); } } false } fn observe_docks(&mut self, workspace: &Entity, cx: &mut Context) { let docks: Vec<_> = workspace .read(cx) .all_docks() .into_iter() .cloned() .collect(); let workspace = workspace.downgrade(); for dock in docks { let workspace = workspace.clone(); cx.observe(&dock, move |this, _dock, cx| { let Some(workspace) = workspace.upgrade() else { return; }; if !this.is_active_workspace(&workspace, cx) { return; } cx.notify(); }) .detach(); } } /// Opens a new workspace for a group that has no open workspaces. fn open_workspace_for_group( &mut self, project_group_key: &ProjectGroupKey, window: &mut Window, cx: &mut Context, ) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; let path_list = project_group_key.path_list().clone(); let host = project_group_key.host(); let provisional_key = Some(project_group_key.clone()); let active_workspace = multi_workspace.read(cx).workspace().clone(); let modal_workspace = active_workspace.clone(); let task = multi_workspace.update(cx, |this, cx| { this.find_or_create_workspace( path_list, host, provisional_key, |options, window, cx| connect_remote(active_workspace, options, window, cx), &[], None, OpenMode::Activate, window, cx, ) }); cx.spawn_in(window, async move |_this, cx| { let result = task.await; remote_connection::dismiss_connection_modal(&modal_workspace, cx); result?; anyhow::Ok(()) }) .detach_and_log_err(cx); } fn open_workspace_and_create_entry( &mut self, project_group_key: &ProjectGroupKey, target: NewEntryTarget, window: &mut Window, cx: &mut Context, ) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; let path_list = project_group_key.path_list().clone(); let host = project_group_key.host(); let provisional_key = Some(project_group_key.clone()); let active_workspace = multi_workspace.read(cx).workspace().clone(); let task = multi_workspace.update(cx, |this, cx| { this.find_or_create_workspace( path_list, host, provisional_key, |options, window, cx| connect_remote(active_workspace, options, window, cx), &[], None, OpenMode::Activate, window, cx, ) }); cx.spawn_in(window, async move |this, cx| { let workspace = task.await?; this.update_in(cx, |this, window, cx| match target { NewEntryTarget::LastCreatedKind => this.create_new_entry(&workspace, window, cx), NewEntryTarget::Terminal => this.create_new_terminal(&workspace, window, cx), })?; anyhow::Ok(()) }) .detach_and_log_err(cx); } /// Rebuilds the sidebar contents from current workspace and thread state. /// /// Iterates [`MultiWorkspace::project_group_keys`] to determine project /// groups, then populates thread entries from the metadata store and /// merges live thread info from active agent panels. /// /// Aim for a single forward pass over workspaces and threads plus an /// O(T log T) sort. Avoid adding extra scans over the data. /// /// Properties: /// /// - Should always show every workspace in the multiworkspace /// - If you have no threads, and two workspaces for the worktree and the main workspace, make sure at least one is shown /// - Should always show every thread, associated with each workspace in the multiworkspace /// - After every build_contents, our "active" state should exactly match the current workspace's, current agent panel's current thread. fn rebuild_contents(&mut self, cx: &App) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; let mw = multi_workspace.read(cx); let workspaces: Vec<_> = mw.workspaces().cloned().collect(); let active_workspace = Some(mw.workspace().clone()); let agent_server_store = workspaces .first() .map(|ws| ws.read(cx).project().read(cx).agent_server_store().clone()); let query = self.filter_editor.read(cx).text(cx); let previous = mem::take(&mut self.contents); let old_statuses = &self.live_thread_statuses; let mut entries = Vec::new(); let mut notified_threads = previous.notified_threads; let mut notified_terminals: HashSet = HashSet::new(); let mut new_live_statuses: HashMap = HashMap::new(); let mut current_session_ids: HashSet = HashSet::new(); let mut current_thread_ids: HashSet = HashSet::new(); let mut current_terminal_ids: HashSet = HashSet::new(); let mut project_header_indices: Vec = Vec::new(); let mut seen_thread_ids: HashSet = HashSet::new(); let mut seen_terminal_ids: HashSet = HashSet::new(); let has_open_projects = workspaces .iter() .any(|ws| !workspace_path_list(ws, cx).paths().is_empty()); let resolve_agent_icon = |agent_id: &AgentId| -> (IconName, Option) { let agent = Agent::from(agent_id.clone()); let icon = match agent { Agent::NativeAgent => IconName::ZedAgent, Agent::Custom { .. } => IconName::Terminal, _ => IconName::ZedAgent, }; let icon_from_external_svg = agent_server_store .as_ref() .and_then(|store| store.read(cx).agent_icon(&agent_id)); (icon, icon_from_external_svg) }; let groups = mw.project_groups(cx); let mut live_notified_terminal_ids: HashSet = HashSet::new(); for workspace in &workspaces { if let Some(agent_panel) = workspace.read(cx).panel::(cx) { live_notified_terminal_ids.extend( agent_panel .read(cx) .terminals(cx) .into_iter() .filter_map(|terminal| terminal.has_notification.then_some(terminal.id)), ); } } let mut all_paths: Vec = groups .iter() .flat_map(|group| group.key.path_list().paths().iter().cloned()) .collect(); all_paths.sort_unstable(); all_paths.dedup(); let path_details = util::disambiguate::compute_disambiguation_details(&all_paths, |path, detail| { project::path_suffix(path, detail) }); let path_detail_map: HashMap = all_paths.into_iter().zip(path_details).collect(); let mut branch_by_path: HashMap = HashMap::new(); for ws in &workspaces { let project = ws.read(cx).project().read(cx); for repo in project.repositories(cx).values() { let snapshot = repo.read(cx).snapshot(); if let Some(branch) = &snapshot.branch { branch_by_path.insert( snapshot.work_directory_abs_path.to_path_buf(), SharedString::from(Arc::::from(branch.name())), ); } for linked_wt in snapshot.linked_worktrees() { if let Some(branch) = linked_wt.branch_name() { branch_by_path.insert( linked_wt.path.clone(), SharedString::from(Arc::::from(branch)), ); } } } } for group in &groups { let group_key = &group.key; let group_workspaces = &group.workspaces; let workspace_by_path_list: HashMap> = group_workspaces .iter() .map(|ws| (workspace_path_list(ws, cx), ws)) .collect(); let resolve_workspace = |folder_paths: &PathList| -> ThreadEntryWorkspace { workspace_by_path_list .get(folder_paths) .map(|ws| ThreadEntryWorkspace::Open((*ws).clone())) .unwrap_or_else(|| ThreadEntryWorkspace::Closed { folder_paths: folder_paths.clone(), project_group_key: group_key.clone(), }) }; let linked_worktree_path_lists = linked_worktree_path_lists_for_workspaces(group_workspaces, cx); let make_terminal_entry = |metadata: TerminalThreadMetadata, workspace: ThreadEntryWorkspace| { let worktrees = worktree_info_from_thread_paths(&metadata.worktree_paths, &branch_by_path); let has_notification = live_notified_terminal_ids.contains(&metadata.terminal_id); TerminalEntry { metadata, workspace, worktrees, has_notification, highlight_positions: Vec::new(), } }; let mut terminals = Vec::new(); let terminal_store = TerminalThreadMetadataStore::global(cx); let group_host = group_key.host(); let mut push_terminal_metadata = |metadata: TerminalThreadMetadata, workspace: ThreadEntryWorkspace| { if !seen_terminal_ids.insert(metadata.terminal_id) { return; } terminals.push(make_terminal_entry(metadata, workspace)); }; for row in terminal_store .read(cx) .entries_for_main_worktree_path(group_key.path_list(), group_host.as_ref()) .cloned() { let workspace = resolve_workspace(row.folder_paths()); push_terminal_metadata(row, workspace); } for row in terminal_store .read(cx) .entries_for_path(group_key.path_list(), group_host.as_ref()) .cloned() { let workspace = resolve_workspace(row.folder_paths()); push_terminal_metadata(row, workspace); } for ws in group_workspaces { let ws_paths = workspace_path_list(ws, cx); if ws_paths.paths().is_empty() { continue; } for row in terminal_store .read(cx) .entries_for_path(&ws_paths, group_host.as_ref()) .cloned() { push_terminal_metadata(row, ThreadEntryWorkspace::Open(ws.clone())); } } for worktree_path_list in &linked_worktree_path_lists { for row in terminal_store .read(cx) .entries_for_path(worktree_path_list, group_host.as_ref()) .cloned() { push_terminal_metadata( row, ThreadEntryWorkspace::Closed { folder_paths: worktree_path_list.clone(), project_group_key: group_key.clone(), }, ); } } current_terminal_ids.extend( terminals .iter() .map(|terminal| terminal.metadata.terminal_id), ); notified_terminals.extend(terminals.iter().filter_map(|terminal| { terminal .has_notification .then_some(terminal.metadata.terminal_id) })); if group_key.path_list().paths().is_empty() { continue; } let label = group_key.display_name(&path_detail_map); let is_collapsed = self.is_group_collapsed(group_key, cx); let should_load_threads = !is_collapsed || !query.is_empty(); let is_active = active_workspace .as_ref() .is_some_and(|active| group_workspaces.contains(active)); // Collect live thread infos from all workspaces in this group. let live_infos = group_workspaces .iter() .flat_map(|ws| all_thread_infos_for_workspace(ws, cx)); let mut threads: Vec> = Vec::new(); let mut has_running_threads = false; let mut waiting_thread_count: usize = 0; let group_host = group_key.host(); if should_load_threads { let thread_store = ThreadMetadataStore::global(cx); let make_thread_entry = |row: ThreadMetadata, workspace: ThreadEntryWorkspace| -> Arc { let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id); let worktrees = worktree_info_from_thread_paths(&row.worktree_paths, &branch_by_path); // Start drafts as `WithContent`; the post-processing // pass below downgrades them to `Empty` if no draft // label can be derived. let draft = row.is_draft().then_some(DraftKind::WithContent); Arc::new(ThreadEntry { metadata: row, icon, icon_from_external_svg, status: AgentThreadStatus::default(), workspace, is_live: false, is_background: false, is_title_generating: false, draft, highlight_positions: Vec::new(), worktrees, diff_stats: DiffStats::default(), }) }; // Main code path: one query per group via main_worktree_paths. // The main_worktree_paths column is set on all new threads and // points to the group's canonical paths regardless of which // linked worktree the thread was opened in. for row in thread_store .read(cx) .entries_for_main_worktree_path(group_key.path_list(), group_host.as_ref()) .cloned() { if !seen_thread_ids.insert(row.thread_id) { continue; } let workspace = resolve_workspace(row.folder_paths()); threads.push(make_thread_entry(row, workspace)); } // Legacy threads did not have `main_worktree_paths` populated, so they // must be queried by their `folder_paths`. // Load any legacy threads for the main worktrees of this project group. for row in thread_store .read(cx) .entries_for_path(group_key.path_list(), group_host.as_ref()) .cloned() { if !seen_thread_ids.insert(row.thread_id) { continue; } let workspace = resolve_workspace(row.folder_paths()); threads.push(make_thread_entry(row, workspace)); } // Also surface any thread whose `folder_paths` equals // one of this group's open workspaces' root paths. // The three lookups above can all miss when the // thread's stored `main_worktree_paths` disagree with // the group key (for example, a stale row whose main // paths equal its folder paths for a linked-worktree // workspace). The thread will be rewritten into the // correct shape the next time `handle_conversation_event` // fires, but until then the sidebar should still show // it under the group whose workspace it actually // belongs to. for ws in group_workspaces { let ws_paths = workspace_path_list(ws, cx); if ws_paths.paths().is_empty() { continue; } for row in thread_store .read(cx) .entries_for_path(&ws_paths, group_host.as_ref()) .cloned() { if !seen_thread_ids.insert(row.thread_id) { continue; } threads.push(make_thread_entry( row, ThreadEntryWorkspace::Open(ws.clone()), )); } } // Load any legacy threads for any single linked worktree of this project group. for worktree_path_list in &linked_worktree_path_lists { for row in thread_store .read(cx) .entries_for_path(worktree_path_list, group_host.as_ref()) .cloned() { if !seen_thread_ids.insert(row.thread_id) { continue; } threads.push(make_thread_entry( row, ThreadEntryWorkspace::Closed { folder_paths: worktree_path_list.clone(), project_group_key: group_key.clone(), }, )); } } for thread in &mut threads { if thread.draft.is_none() { continue; } if let Some((label, kind)) = draft_display_label_for_thread_metadata( &thread.metadata, &thread.workspace, cx, ) { let thread = Arc::make_mut(thread); thread.metadata.title = Some(label); thread.draft = Some(kind); } } threads.retain(|thread| thread.draft.is_none() || thread.metadata.title.is_some()); // Keep empty drafts only while their thread is active; preserve // drafts with content because they hold user-typed state. let pending_activation = self.pending_thread_activation; let active_panel_thread_id = active_workspace .as_ref() .and_then(|ws| ws.read(cx).panel::(cx)) .and_then(|panel| panel.read(cx).active_thread_id(cx)); threads.retain(|thread| { if thread.draft != Some(DraftKind::Empty) { return true; } if pending_activation.is_some() { return false; } Some(thread.metadata.thread_id) == active_panel_thread_id }); // Build a lookup from live_infos and compute running/waiting // counts in a single pass. let mut live_info_by_session: HashMap = HashMap::new(); for info in live_infos { if info.status == AgentThreadStatus::Running { has_running_threads = true; } if info.status == AgentThreadStatus::WaitingForConfirmation { waiting_thread_count += 1; } live_info_by_session.insert(info.session_id.clone(), info); } // Merge live info into threads and update notification state // in a single pass. for thread in &mut threads { if let Some(session_id) = thread.metadata.session_id.clone() { if let Some(info) = live_info_by_session.get(&session_id) { let status = info.status; let thread_id = thread.metadata.thread_id; Arc::make_mut(thread).apply_active_info(info); new_live_statuses.insert(session_id, (status, thread_id)); } } let session_id = &thread.metadata.session_id; let is_active_thread = self.active_entry.as_ref().is_some_and(|entry| { entry.is_active_thread(&thread.metadata.thread_id) && active_workspace .as_ref() .is_some_and(|active| active == entry.workspace()) }); if thread.status == AgentThreadStatus::Completed && !is_active_thread && session_id .as_ref() .and_then(|sid| old_statuses.get(sid)) .is_some_and(|(s, _)| *s == AgentThreadStatus::Running) { notified_threads.insert(thread.metadata.thread_id); } if is_active_thread && !thread.is_background { notified_threads.remove(&thread.metadata.thread_id); } } threads.sort_by(|a, b| { let a_time = Self::thread_display_time(&a.metadata); let b_time = Self::thread_display_time(&b.metadata); b_time.cmp(&a_time) }); } else { for info in live_infos { if info.status == AgentThreadStatus::Running { has_running_threads = true; } if info.status == AgentThreadStatus::WaitingForConfirmation { waiting_thread_count += 1; } // Resolve the thread_id for this session so we can // track its status and detect transitions even while // the group is collapsed. let thread_id = old_statuses .get(&info.session_id) .map(|(_, tid)| *tid) .or_else(|| { ThreadMetadataStore::global(cx) .read(cx) .entry_by_session(&info.session_id) .map(|m| m.thread_id) }); if let Some(thread_id) = thread_id { let old_status = old_statuses.get(&info.session_id).map(|(s, _)| *s); new_live_statuses.insert(info.session_id.clone(), (info.status, thread_id)); if info.status == AgentThreadStatus::Completed && old_status == Some(AgentThreadStatus::Running) { notified_threads.insert(thread_id); } } } if is_active && let Some(ActiveEntry::Thread { thread_id, .. }) = self.active_entry.as_ref() { notified_threads.remove(thread_id); } } let has_visible_rows = !threads.is_empty() || !terminals.is_empty(); let has_stored_thread_rows = !should_load_threads && !has_visible_rows && { let store = ThreadMetadataStore::global(cx).read(cx); store .entries_for_main_worktree_path(group_key.path_list(), group_host.as_ref()) .any(|metadata| { let workspace = resolve_workspace(metadata.folder_paths()); thread_metadata_would_render_sidebar_row(metadata, &workspace, cx) }) || store .entries_for_path(group_key.path_list(), group_host.as_ref()) .any(|metadata| { let workspace = resolve_workspace(metadata.folder_paths()); thread_metadata_would_render_sidebar_row(metadata, &workspace, cx) }) }; let has_threads = has_visible_rows || has_stored_thread_rows; if !query.is_empty() { let workspace_highlight_positions = fuzzy_match_positions(&query, &label).unwrap_or_default(); let workspace_matched = !workspace_highlight_positions.is_empty(); let mut matched_threads: Vec> = Vec::new(); for mut thread in threads { let mut worktree_matched = false; { let thread = Arc::make_mut(&mut thread); let title = thread.metadata.display_title(); if let Some(positions) = fuzzy_match_positions(&query, title.as_ref()) { thread.highlight_positions = positions; } for worktree in &mut thread.worktrees { let Some(name) = worktree.worktree_name.as_ref() else { continue; }; if let Some(positions) = fuzzy_match_positions(&query, name) { worktree.highlight_positions = positions; worktree_matched = true; } } } if workspace_matched || !thread.highlight_positions.is_empty() || worktree_matched { matched_threads.push(thread); } } let mut matched_terminals: Vec = Vec::new(); for mut terminal in terminals { let mut terminal_matched = false; let terminal_title = terminal.metadata.display_title(); if let Some(positions) = fuzzy_match_positions(&query, terminal_title.as_ref()) { terminal.highlight_positions = positions; terminal_matched = true; } let mut worktree_matched = false; for worktree in &mut terminal.worktrees { let Some(name) = worktree.worktree_name.as_ref() else { continue; }; if let Some(positions) = fuzzy_match_positions(&query, name) { worktree.highlight_positions = positions; worktree_matched = true; } } if workspace_matched || terminal_matched || worktree_matched { matched_terminals.push(terminal); } } if matched_threads.is_empty() && matched_terminals.is_empty() && !workspace_matched { continue; } // Check for notifications: threads that completed while not active. let has_thread_notifications = matched_threads .iter() .any(|t| notified_threads.contains(&t.metadata.thread_id)); let has_terminal_notifications = matched_terminals .iter() .any(|t| notified_terminals.contains(&t.metadata.terminal_id)); project_header_indices.push(entries.len()); entries.push(ListEntry::ProjectHeader { key: group_key.clone(), label, highlight_positions: workspace_highlight_positions, has_running_threads, waiting_thread_count, has_notifications: has_thread_notifications || has_terminal_notifications, is_active, has_threads, }); Self::push_entries_by_display_time( &mut entries, matched_terminals, matched_threads, &mut current_session_ids, &mut current_thread_ids, ); } else { let has_terminal_notifications = terminals .iter() .any(|t| notified_terminals.contains(&t.metadata.terminal_id)); // When collapsed, threads aren't loaded into `threads`, so we // query the store for thread IDs to check notifications and // to prevent the retain below from purging them. let has_thread_notifications = if threads.is_empty() && !notified_threads.is_empty() { let thread_store = ThreadMetadataStore::global(cx); let store = thread_store.read(cx); let group_thread_ids = store .entries_for_main_worktree_path(group_key.path_list(), group_host.as_ref()) .chain(store.entries_for_path(group_key.path_list(), group_host.as_ref())) .map(|m| m.thread_id) .collect::>(); current_thread_ids.extend(group_thread_ids.iter()); group_thread_ids .iter() .any(|id| notified_threads.contains(id)) } else { threads .iter() .any(|t| notified_threads.contains(&t.metadata.thread_id)) }; project_header_indices.push(entries.len()); entries.push(ListEntry::ProjectHeader { key: group_key.clone(), label, highlight_positions: Vec::new(), has_running_threads, waiting_thread_count, has_notifications: has_thread_notifications || has_terminal_notifications, is_active, has_threads, }); if is_collapsed { continue; } Self::push_entries_by_display_time( &mut entries, terminals, threads, &mut current_session_ids, &mut current_thread_ids, ); } } notified_threads.retain(|id| current_thread_ids.contains(id)); self.thread_last_accessed .retain(|id, _| current_thread_ids.contains(id)); self.terminal_last_accessed .retain(|id, _| current_terminal_ids.contains(id)); self.live_thread_statuses = new_live_statuses; self.contents = SidebarContents { entries, notified_threads, notified_terminals, project_header_indices, has_open_projects, }; } fn schedule_update_entries(&mut self, select_first_after_update: bool, cx: &mut Context) { if self.update_task.is_some() && !select_first_after_update { return; } self.update_task = Some(cx.spawn(async move |this, cx| { this.update(cx, |this, cx| { this.update_task = None; this.update_entries(cx); if select_first_after_update { this.select_first_entry(); cx.notify(); } }) .ok(); })); } /// Rebuilds the sidebar's visible entries from already-cached state. fn update_entries(&mut self, cx: &mut Context) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; if !multi_workspace.read(cx).multi_workspace_enabled(cx) { return; } let had_notifications = self.has_notifications(cx); let previous_shapes: Vec = self.entry_shapes(multi_workspace.read(cx)).collect(); self.rebuild_contents(cx); self.refresh_refilled_draft_times(cx); self.refresh_draft_editor_observations(cx); // Preserve measurements for unchanged entries so sticky headers do not flicker. self.apply_list_state_diff(&previous_shapes, multi_workspace.read(cx)); self.prefetch_worktree_default_branches(cx); if had_notifications != self.has_notifications(cx) { multi_workspace.update(cx, |_, cx| { cx.notify(); }); } cx.notify(); } /// Splices only the changed entry range, leaving unchanged item measurements intact. fn apply_list_state_diff( &self, previous_shapes: &[EntryShape], multi_workspace: &MultiWorkspace, ) { let mut new_iter = self.entry_shapes(multi_workspace); let mut prefix_len = 0; let leading_new = loop { match (previous_shapes.get(prefix_len), new_iter.next()) { (Some(prev), Some(next)) if *prev == next => prefix_len += 1, (None, None) => return, (_, leading) => break leading, } }; let new_tail: Vec = leading_new.into_iter().chain(new_iter).collect(); let prev_tail = &previous_shapes[prefix_len..]; let suffix_len = prev_tail .iter() .rev() .zip(new_tail.iter().rev()) .take_while(|(prev, next)| prev == next) .count(); let old_changed = prefix_len..previous_shapes.len() - suffix_len; let new_changed_count = new_tail.len() - suffix_len; self.list_state.splice(old_changed, new_changed_count); } fn entry_shapes<'a>( &'a self, multi_workspace: &'a MultiWorkspace, ) -> impl Iterator + 'a { self.contents.entries.iter().map(move |entry| match entry { ListEntry::ProjectHeader { key, has_threads, .. } => EntryShape::ProjectHeader { key: key.clone(), has_threads: *has_threads, is_collapsed: multi_workspace .group_state_by_key(key) .map(|state| !state.expanded) .unwrap_or(false), }, ListEntry::Thread(thread) => EntryShape::Thread(thread.metadata.thread_id), ListEntry::Terminal(terminal) => EntryShape::Terminal(terminal.metadata.terminal_id), }) } /// Detects drafts that just went from empty back to having content and /// refreshes their interaction time to now, so a re-filled draft sorts to /// the top of the list instead of falling back to its original creation time. fn refresh_refilled_draft_times(&mut self, cx: &mut Context) { let mut new_kinds: HashMap = HashMap::new(); let mut refilled: Vec = Vec::new(); for entry in &self.contents.entries { let ListEntry::Thread(thread) = entry else { continue; }; let Some(kind) = thread.draft else { continue; }; let thread_id = thread.metadata.thread_id; if kind == DraftKind::WithContent && self.draft_kinds.get(&thread_id) == Some(&DraftKind::Empty) { refilled.push(thread_id); } new_kinds.insert(thread_id, kind); } self.draft_kinds = new_kinds; if refilled.is_empty() { return; } let now = Utc::now(); ThreadMetadataStore::global(cx).update(cx, |store, store_cx| { for thread_id in refilled { store.update_interacted_at(&thread_id, now, store_cx); } }); } /// Re-establishes subscriptions to each visible draft's message editor /// so we rebuild entries (and their displayed titles) as the user types. fn refresh_draft_editor_observations(&mut self, cx: &mut Context) { self._draft_editor_observations.clear(); let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; let draft_conversation_views: Vec> = multi_workspace .read(cx) .workspaces() .filter_map(|ws| ws.read(cx).panel::(cx)) .flat_map(|panel| panel.read(cx).conversation_views()) .collect(); for cv in draft_conversation_views { if let Some(thread_view) = cv.read(cx).active_thread() { let editor = thread_view.read(cx).message_editor.clone(); self._draft_editor_observations.push(cx.subscribe( &editor, |this, _editor, event, cx| match event { MessageEditorEvent::Edited => this.schedule_update_entries(false, cx), _ => (), }, )); } // Also subscribe to the ConversationView itself so that editor // replacements during lifecycle transitions (Loading → // Connected) re-wire the editor observation above. self._draft_editor_observations.push(cx.subscribe( &cv, |this, _cv, _event: &StateChange, cx| { this.schedule_update_entries(false, cx); }, )); } } fn select_first_entry(&mut self) { self.selection = self .contents .entries .iter() .position(|entry| matches!(entry, ListEntry::Thread(_) | ListEntry::Terminal(_))) .or_else(|| { if self.contents.entries.is_empty() { None } else { Some(0) } }); } fn render_list_entry( &mut self, ix: usize, window: &mut Window, cx: &mut Context, ) -> AnyElement { let Some(entry) = self.contents.entries.get(ix) else { return div().into_any_element(); }; let is_focused = self.focus_handle.is_focused(window); // is_selected means the keyboard selector is here. let is_selected = is_focused && self.selection == Some(ix); let is_group_header_after_first = ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. }); let is_active = self .active_entry .as_ref() .is_some_and(|active| active.matches_entry(entry)); let rendered = match entry { ListEntry::ProjectHeader { key, label, highlight_positions, has_running_threads, waiting_thread_count, has_notifications, is_active: is_active_group, has_threads, } => { self.project_header_menu_handles.entry(ix).or_default(); self.project_header_new_thread_menu_handles .entry(ix) .or_default(); self.render_project_header( ix, false, key, label, highlight_positions, *has_running_threads, *waiting_thread_count, *has_notifications, *is_active_group, is_selected, *has_threads, // has_active_draft, cx, ) } ListEntry::Thread(thread) => self.render_thread(ix, thread, is_active, is_selected, cx), ListEntry::Terminal(terminal) => { self.render_terminal(ix, terminal, is_active, is_selected, cx) } }; if is_group_header_after_first { v_flex() .w_full() .border_t_1() .border_color(cx.theme().colors().border) .child(rendered) .into_any_element() } else { rendered } } fn render_remote_project_icon( &self, ix: usize, host: Option<&RemoteConnectionOptions>, ) -> Option { let remote_icon_per_type = match host? { RemoteConnectionOptions::Wsl(_) => IconName::Linux, RemoteConnectionOptions::Docker(_) => IconName::Box, _ => IconName::Server, }; Some( div() .id(format!("remote-project-icon-{}", ix)) .child( Icon::new(remote_icon_per_type) .size(IconSize::XSmall) .color(Color::Muted), ) .tooltip(Tooltip::text("Remote Project")) .into_any_element(), ) } fn render_project_header( &self, ix: usize, is_sticky: bool, key: &ProjectGroupKey, label: &SharedString, highlight_positions: &[usize], has_running_threads: bool, waiting_thread_count: usize, has_notifications: bool, is_active: bool, is_focused: bool, has_threads: bool, cx: &mut Context, ) -> AnyElement { let host = key.host(); let has_filter = self.has_filter_query(cx); let id_prefix = if is_sticky { "sticky-" } else { "" }; let id = SharedString::from(format!("{id_prefix}project-header-{ix}")); let group_name = SharedString::from(format!("{id_prefix}header-group-{ix}")); let is_collapsed = self.is_group_collapsed(key, cx); let disclosure_icon = if is_collapsed { IconName::ChevronRight } else { IconName::ChevronDown }; let key_for_toggle = key.clone(); let key_for_focus = key.clone(); // The fade gradient renders as a visible patch on transparent windows, // so truncate the label instead. let opaque_window = cx.theme().window_background_appearance() == WindowBackgroundAppearance::Opaque; let label = if highlight_positions.is_empty() { Label::new(label.clone()) .when(!is_active, |this| this.color(Color::Muted)) .when(!opaque_window, |this| this.truncate()) .into_any_element() } else { HighlightedLabel::new(label.clone(), highlight_positions.to_vec()) .when(!is_active, |this| this.color(Color::Muted)) .when(!opaque_window, |this| this.truncate()) .into_any_element() }; let color = cx.theme().colors(); let sidebar_base_bg = color .title_bar_background .blend(color.panel_background.opacity(0.25)); let base_bg = color.background.blend(sidebar_base_bg); let hover_base = color .element_active .blend(color.element_background.opacity(0.2)); let hover_solid = base_bg.blend(hover_base); let group_name_for_gradient = group_name.clone(); let gradient_overlay = move || { GradientFade::new(base_bg, hover_solid, hover_solid) .width(px(92.0)) .right(px(-2.0)) .gradient_stop(0.7) .when(!has_filter, |this| { this.group_name(group_name_for_gradient.clone()) }) }; let header = h_flex() .id(id) .group(&group_name) .when(!has_filter, |this| this.cursor_pointer()) .relative() .h(Tab::content_height(cx)) .w_full() .pl_2() .pr_1p5() .justify_between() .border_1() .map(|this| { if is_focused { this.border_color(color.border_focused) } else { this.border_color(gpui::transparent_black()) } }) .when(!has_filter, |this| this.hover(|s| s.bg(hover_solid))) .child( h_flex() .relative() .min_w_0() .w_full() .gap_1() .child(label) .when_some( self.render_remote_project_icon(ix, host.as_ref()), |this, icon| this.child(icon), ) .when(is_collapsed, |this| { this.when(has_running_threads, |this| { this.child( Icon::new(IconName::LoadCircle) .size(IconSize::XSmall) .color(Color::Muted) .with_rotate_animation(2), ) }) .when(waiting_thread_count > 0, |this| { let tooltip_text = if waiting_thread_count == 1 { "1 thread is waiting for confirmation".to_string() } else { format!( "{waiting_thread_count} threads are waiting for confirmation", ) }; this.child( div() .id(format!("{id_prefix}waiting-indicator-{ix}")) .child( Icon::new(IconName::Warning) .size(IconSize::XSmall) .color(Color::Warning), ) .tooltip(Tooltip::text(tooltip_text)), ) }) .when( has_notifications && !has_running_threads && waiting_thread_count == 0, |this| { this.child( Icon::new(IconName::Circle) .size(IconSize::Small) .color(Color::Accent), ) }, ) }) .when(!has_filter, |this| { this.child( div() .when(!is_focused, |this| this.visible_on_hover(&group_name)) .child( Icon::new(disclosure_icon) .size(IconSize::Small) .color(Color::Muted), ), ) }), ) .children(opaque_window.then(|| gradient_overlay())) .child( h_flex() .gap_px() .pr_1p5() .children(opaque_window.then(|| gradient_overlay())) .child(self.render_new_thread_button(ix, id_prefix, key, &group_name, cx)) .child(self.render_project_header_ellipsis_menu( ix, id_prefix, key, is_active, has_threads, &group_name, cx, )) .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| { cx.stop_propagation(); }), ) .on_mouse_down(gpui::MouseButton::Right, { let menu_handle = self .project_header_menu_handles .get(&ix) .cloned() .unwrap_or_default(); move |_, window, cx| { cx.stop_propagation(); menu_handle.toggle(window, cx); } }) .on_click( cx.listener(move |this, event: &gpui::ClickEvent, window, cx| { if event.modifiers().secondary() { this.activate_or_open_workspace_for_group(&key_for_focus, window, cx); } else if !this.has_filter_query(cx) { this.toggle_collapse(&key_for_toggle, window, cx); } }), ) .block_mouse_except_scroll(); if !is_collapsed && !has_threads { v_flex() .w_full() .child(header) .child( h_flex() .px_2() .pt_1() .pb_2() .gap(px(7.)) .child(Icon::new(IconName::Circle).size(IconSize::Small).color( Color::Custom(cx.theme().colors().icon_placeholder.opacity(0.1)), )) .child( Label::new("No threads yet") .size(LabelSize::Small) .color(Color::Placeholder), ), ) .into_any_element() } else { header.into_any_element() } } fn render_new_thread_button( &self, ix: usize, id_prefix: &str, key: &ProjectGroupKey, group_name: &SharedString, cx: &mut Context, ) -> AnyElement { let focus_handle = self.focus_handle.clone(); let menu_handle = self .project_header_new_thread_menu_handles .get(&ix) .cloned() .unwrap_or_default(); let is_menu_open = menu_handle.is_deployed(); let button = IconButton::new( SharedString::from(format!("{id_prefix}project-header-new-thread-{ix}")), IconName::Plus, ) .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .icon_size(IconSize::Small) .when(!is_menu_open, |this| this.visible_on_hover(group_name)); let open_workspaces = self .multi_workspace .upgrade() .and_then(|mw| mw.read(cx).workspaces_for_project_group(key, cx)) .unwrap_or_default(); if open_workspaces.is_empty() { let key = key.clone(); return button .tooltip(move |_, cx| { Tooltip::for_action_in("Start New Agent Thread", &NewThread, &focus_handle, cx) }) .on_click(cx.listener(move |this, _, window, cx| { this.set_group_expanded(&key, true, cx); this.selection = None; if let Some(workspace) = this.workspace_for_group(&key, cx) { this.create_new_entry(&workspace, window, cx); } else { this.open_workspace_and_create_entry( &key, NewEntryTarget::LastCreatedKind, window, cx, ); } })) .into_any_element(); } let this = cx.weak_entity(); let key = key.clone(); PopoverMenu::new(SharedString::from(format!( "{id_prefix}project-header-new-thread-menu-{ix}" ))) .with_handle(menu_handle) .trigger_with_tooltip(button, move |_, cx| { Tooltip::for_action_in("Start New Agent Thread", &NewThread, &focus_handle, cx) }) .anchor(gpui::Anchor::TopLeft) .on_open(Rc::new({ let this = this.clone(); move |_window, cx| { this.update(cx, |_sidebar, cx| cx.notify()).ok(); } })) .menu(move |window, cx| { let this = this.clone(); let key = key.clone(); let open_workspaces = open_workspaces.clone(); let active_workspace = this .read_with(cx, |sidebar, cx| { sidebar .multi_workspace .upgrade() .map(|mw| mw.read(cx).workspace().clone()) }) .ok() .flatten(); let workspace_labels: Vec<_> = open_workspaces .iter() .map(|workspace| workspace_menu_worktree_labels(workspace, cx)) .collect(); Some(ContextMenu::build( window, cx, move |mut menu, _window, cx| { menu = menu.header("New Thread In…"); for (workspace, labels) in open_workspaces .iter() .cloned() .zip(workspace_labels.iter().cloned()) { let is_active_workspace = active_workspace.as_ref() == Some(&workspace); menu = menu.custom_entry( move |_window, _cx| { h_flex() .w_full() .gap_2() .justify_between() .child(h_flex().min_w_0().gap_1().children( labels.iter().enumerate().map(|(label_ix, label)| { h_flex() .gap_1() .when(label_ix > 0, |this| { this.child(Label::new("•").alpha(0.25)) }) .child(label.render()) .into_any_element() }), )) .when(is_active_workspace, |this| { this.child( Icon::new(IconName::Check) .size(IconSize::Small) .color(Color::Accent), ) }) .into_any_element() }, { let this = this.clone(); let key = key.clone(); let workspace = workspace.clone(); move |window, cx| { this.update(cx, |sidebar, cx| { sidebar.set_group_expanded(&key, true, cx); sidebar.selection = None; sidebar.create_new_entry(&workspace, window, cx); }) .ok(); } }, ); } let base_workspace = active_workspace .as_ref() .filter(|workspace| open_workspaces.contains(workspace)) .cloned() .or_else(|| open_workspaces.first().cloned()); // Only offer worktree creation when the base project can // actually create one; otherwise the submenu would expand to // nothing. Mirrors the picker's `creation_blocked_reason`. let creation_blocked = base_workspace.as_ref().is_none_or(|base_workspace| { let project = base_workspace.read(cx).project().read(cx); project.is_via_collab() || project.repositories(cx).is_empty() }); if let Some(base_workspace) = base_workspace.filter(|_| !creation_blocked) { menu = menu.separator().submenu("Create New Worktree…", { let this = this.clone(); move |mut submenu, _window, submenu_cx| { let project = base_workspace.read(submenu_cx).project().clone(); let project_ref = project.read(submenu_cx); let has_multiple_repositories = project_ref.repositories(submenu_cx).len() > 1; let current_branch = project_ref.active_repository(submenu_cx).and_then(|repo| { repo.read(submenu_cx) .branch .as_ref() .map(|branch| branch.name().to_string()) }); let default_branch = this .read_with(submenu_cx, |sidebar, _| { match sidebar.worktree_default_branches.get(&key) { Some(DefaultBranchCache::Resolved(branch)) => { branch.clone() } _ => None, } }) .ok() .flatten(); let targets = worktree_create_targets( has_multiple_repositories, default_branch, current_branch.as_deref(), ); for target in targets { let label = format!( "Based on {}", target.branch_label( has_multiple_repositories, current_branch.as_deref(), ) ); let branch_target = target.branch_target(); let workspace = base_workspace.clone(); submenu = submenu.entry(label, None, move |window, cx| { create_worktree_in_workspace( &workspace, branch_target.clone(), window, cx, ); }); } submenu } }); } menu }, )) }) .anchor(gpui::Anchor::TopRight) .offset(gpui::Point { x: px(0.), y: px(1.), }) .into_any_element() } // Warms `worktree_default_branches` for every project group with at least one // open workspace. The git query runs off the menu path so the submenu can read // the result synchronously when it opens. Worktrees of a repository share the // same default branch, so any workspace in the group yields the same answer. fn prefetch_worktree_default_branches(&mut self, cx: &mut Context) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; let keys: Vec = self .contents .entries .iter() .filter_map(|entry| match entry { ListEntry::ProjectHeader { key, .. } => Some(key.clone()), _ => None, }) .collect(); for key in keys { if self.worktree_default_branches.contains_key(&key) { continue; } let Some(base) = multi_workspace .read(cx) .workspaces_for_project_group(&key, cx) .and_then(|workspaces| workspaces.first().cloned()) else { continue; }; self.prefetch_worktree_default_branch(&key, &base, cx); } } fn prefetch_worktree_default_branch( &mut self, key: &ProjectGroupKey, workspace: &Entity, cx: &mut Context, ) { // Presence of the key means the group is already pending or resolved. The // no-repository case is deliberately not inserted so it retries on a // later rebuild once the repository has finished loading. if self.worktree_default_branches.contains_key(key) { return; } let Some(repository) = workspace.read(cx).project().read(cx).active_repository(cx) else { return; }; let request = repository.update(cx, |repository, _| repository.default_branch(true)); self.worktree_default_branches .insert(key.clone(), DefaultBranchCache::Pending); let key = key.clone(); cx.spawn(async move |this, cx| { let default_branch = request.await.ok().and_then(Result::ok).flatten(); let parsed = default_branch.as_deref().and_then(RemoteBranchName::parse); this.update(cx, |sidebar, cx| { sidebar .worktree_default_branches .insert(key, DefaultBranchCache::Resolved(parsed)); cx.notify(); }) .ok(); }) .detach(); } fn render_project_header_ellipsis_menu( &self, ix: usize, id_prefix: &str, project_group_key: &ProjectGroupKey, is_active: bool, has_threads: bool, group_name: &SharedString, cx: &mut Context, ) -> AnyElement { let multi_workspace = self.multi_workspace.clone(); let project_group_key = project_group_key.clone(); let show_multi_project_entries = multi_workspace .read_with(cx, |mw, _| { project_group_key.host().is_none() && mw.project_group_keys().len() >= 2 }) .unwrap_or(false); let this = cx.weak_entity(); let trigger_id = SharedString::from(format!("{id_prefix}-ellipsis-menu-{ix}")); let menu_handle = self .project_header_menu_handles .get(&ix) .cloned() .unwrap_or_default(); let is_menu_open = menu_handle.is_deployed(); PopoverMenu::new(format!("{id_prefix}project-header-menu-{ix}")) .with_handle(menu_handle) .trigger( IconButton::new(trigger_id, IconName::Ellipsis) .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .icon_size(IconSize::Small) .when(!is_menu_open, |el| el.visible_on_hover(group_name)), ) .on_open(Rc::new({ let this = this.clone(); move |_window, cx| { this.update(cx, |sidebar, cx| { sidebar.project_header_menu_ix = Some(ix); cx.notify(); }) .ok(); } })) .menu(move |window, cx| { let multi_workspace = multi_workspace.clone(); let project_group_key = project_group_key.clone(); let this_for_menu = this.clone(); let open_workspaces = multi_workspace .read_with(cx, |multi_workspace, cx| { multi_workspace .workspaces_for_project_group(&project_group_key, cx) .unwrap_or_default() }) .unwrap_or_default(); // Compute reorder state at menu-open time so it reflects the // most recent group ordering. let (group_index, total_groups) = multi_workspace .read_with(cx, |mw, _| { let keys = mw.project_group_keys(); let index = keys.iter().position(|k| k == &project_group_key); (index, keys.len()) }) .unwrap_or((None, 0)); let show_reorder_entries = total_groups >= 2; let can_move_up = group_index.is_some_and(|i| i > 0); let can_move_down = group_index.is_some_and(|i| i + 1 < total_groups); let active_workspace = multi_workspace .read_with(cx, |multi_workspace, _cx| { multi_workspace.workspace().clone() }) .ok(); let workspace_labels: Vec<_> = open_workspaces .iter() .map(|workspace| workspace_menu_worktree_labels(workspace, cx)) .collect(); let workspace_is_active: Vec<_> = open_workspaces .iter() .map(|workspace| active_workspace.as_ref() == Some(workspace)) .collect(); let menu = ContextMenu::build_persistent(window, cx, move |menu, _window, menu_cx| { let menu = menu.end_slot_action(Box::new(menu::SecondaryConfirm)); let weak_menu = menu_cx.weak_entity(); let menu = menu.when(show_multi_project_entries, |this| { this.entry( "Open Project in New Window", Some(Box::new(workspace::MoveProjectToNewWindow)), { let project_group_key = project_group_key.clone(); let multi_workspace = multi_workspace.clone(); move |window, cx| { multi_workspace .update(cx, |multi_workspace, cx| { multi_workspace .open_project_group_in_new_window( &project_group_key, window, cx, ) .detach_and_log_err(cx); }) .ok(); } }, ) }); let menu = menu .custom_entry( { move |_window, cx| { let action = h_flex() .opacity(0.6) .children(render_modifiers( &Modifiers::secondary_key(), PlatformStyle::platform(), None, Some(TextSize::Default.rems(cx).into()), false, )) .child(Label::new("-click").color(Color::Muted)); let label = if has_threads { "Focus Last Project" } else { "Focus Project" }; h_flex() .w_full() .justify_between() .gap_4() .child( Label::new(label) .when(is_active, |s| s.color(Color::Disabled)), ) .child(action) .into_any_element() } }, { let project_group_key = project_group_key.clone(); let this = this_for_menu.clone(); move |window, cx| { if is_active { return; } this.update(cx, |sidebar, cx| { if let Some(workspace) = sidebar.workspace_for_group(&project_group_key, cx) { sidebar.activate_workspace(&workspace, window, cx); } else { sidebar.open_workspace_for_group( &project_group_key, window, cx, ); } sidebar.selection = None; sidebar.active_entry = None; }) .ok(); } }, ) .selectable(!is_active); let menu = if open_workspaces.is_empty() { menu } else { let mut menu = menu.separator().header("Open Worktrees"); for ( workspace_index, ((workspace, workspace_label), is_active_workspace), ) in open_workspaces .iter() .cloned() .zip(workspace_labels.iter().cloned()) .zip(workspace_is_active.iter().copied()) .enumerate() { let activate_multi_workspace = multi_workspace.clone(); let close_multi_workspace = multi_workspace.clone(); let activate_weak_menu = weak_menu.clone(); let close_weak_menu = weak_menu.clone(); let activate_workspace = workspace.clone(); let close_workspace = workspace.clone(); menu = menu.custom_entry( move |_window, _cx| { let close_multi_workspace = close_multi_workspace.clone(); let close_weak_menu = close_weak_menu.clone(); let close_workspace = close_workspace.clone(); let row_group_name = SharedString::from(format!( "workspace-menu-row-{workspace_index}" )); h_flex() .group(&row_group_name) .w_full() .gap_2() .justify_between() .child(h_flex().min_w_0().gap_1().children( workspace_label.iter().enumerate().map( |(label_ix, label)| { h_flex() .gap_1() .when(label_ix > 0, |this| { this.child( Label::new("•").alpha(0.25), ) }) .child(label.render()) .into_any_element() }, ), )) .when(is_active_workspace, |this| { this.pr_1().child( Icon::new(IconName::Check) .size(IconSize::Small) .color(Color::Accent), ) }) .when(!is_active_workspace, |this| { let close_multi_workspace = close_multi_workspace.clone(); let close_weak_menu = close_weak_menu.clone(); let close_workspace = close_workspace.clone(); this.child( IconButton::new( ("close-workspace", workspace_index), IconName::Close, ) .icon_size(IconSize::Small) .visible_on_hover(&row_group_name) .tooltip(Tooltip::text("Close Worktree")) .on_click(move |_, window, cx| { cx.stop_propagation(); window.prevent_default(); close_multi_workspace .update(cx, |multi_workspace, cx| { multi_workspace .close_workspace( &close_workspace, window, cx, ) .detach_and_log_err(cx); }) .ok(); close_weak_menu .update(cx, |_, cx| { cx.emit(DismissEvent) }) .ok(); }), ) }) .into_any_element() }, move |window, cx| { activate_multi_workspace .update(cx, |multi_workspace, cx| { multi_workspace.activate( activate_workspace.clone(), None, window, cx, ); }) .ok(); activate_weak_menu .update(cx, |_, cx| cx.emit(DismissEvent)) .ok(); }, ); } menu }; let menu = menu.when(show_reorder_entries, |this| { let move_up_multi_workspace = multi_workspace.clone(); let move_up_key = project_group_key.clone(); let move_up_weak_menu = weak_menu.clone(); let move_down_multi_workspace = multi_workspace.clone(); let move_down_key = project_group_key.clone(); let move_down_weak_menu = weak_menu.clone(); this.separator() .item( ContextMenuEntry::new("Move Up") .disabled(!can_move_up) .handler(move |_window, cx| { move_up_multi_workspace .update(cx, |mw, cx| { mw.move_project_group_up(&move_up_key, cx); }) .ok(); move_up_weak_menu .update(cx, |_, cx| cx.emit(DismissEvent)) .ok(); }), ) .item( ContextMenuEntry::new("Move Down") .disabled(!can_move_down) .handler(move |_window, cx| { move_down_multi_workspace .update(cx, |mw, cx| { mw.move_project_group_down(&move_down_key, cx); }) .ok(); move_down_weak_menu .update(cx, |_, cx| cx.emit(DismissEvent)) .ok(); }), ) }); let project_group_key = project_group_key.clone(); let remove_multi_workspace = multi_workspace.clone(); menu.separator().entry("Remove", None, move |window, cx| { remove_multi_workspace .update(cx, |multi_workspace, cx| { multi_workspace .remove_project_group(&project_group_key, window, cx) .detach_and_log_err(cx); }) .ok(); weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok(); }) }); let this = this.clone(); window .subscribe(&menu, cx, move |_, _: &gpui::DismissEvent, _window, cx| { this.update(cx, |sidebar, cx| { sidebar.project_header_menu_ix = None; cx.notify(); }) .ok(); }) .detach(); Some(menu) }) .anchor(gpui::Anchor::TopRight) .offset(gpui::Point { x: px(0.), y: px(1.), }) .into_any_element() } fn render_sticky_header( &self, window: &mut Window, cx: &mut Context, ) -> Option { let scroll_top = self.list_state.logical_scroll_top(); let &header_idx = self .contents .project_header_indices .iter() .rev() .find(|&&idx| idx <= scroll_top.item_ix)?; let needs_sticky = header_idx < scroll_top.item_ix || (header_idx == scroll_top.item_ix && scroll_top.offset_in_item > px(0.)); if !needs_sticky { return None; } let ListEntry::ProjectHeader { key, label, highlight_positions, has_running_threads, waiting_thread_count, has_notifications, is_active, has_threads, } = self.contents.entries.get(header_idx)? else { return None; }; let is_focused = self.focus_handle.is_focused(window); let is_selected = is_focused && self.selection == Some(header_idx); let header_element = self.render_project_header( header_idx, true, key, &label, &highlight_positions, *has_running_threads, *waiting_thread_count, *has_notifications, *is_active, is_selected, *has_threads, cx, ); let top_offset = self .contents .project_header_indices .iter() .find(|&&idx| idx > header_idx) .and_then(|&next_idx| { let bounds = self.list_state.bounds_for_item(next_idx)?; let viewport = self.list_state.viewport_bounds(); let y_in_viewport = bounds.origin.y - viewport.origin.y; let header_height = bounds.size.height; (y_in_viewport < header_height).then_some(y_in_viewport - header_height) }) .unwrap_or(px(0.)); let color = cx.theme().colors(); let background = color .title_bar_background .blend(color.panel_background.opacity(0.2)); let element = v_flex() .absolute() .top(top_offset) .left_0() .w_full() .bg(background) .border_b_1() .border_color(color.border.opacity(0.5)) .child(header_element) .shadow_sm() .into_any_element(); Some(element) } fn toggle_collapse( &mut self, project_group_key: &ProjectGroupKey, _window: &mut Window, cx: &mut Context, ) { let is_collapsed = self.is_group_collapsed(project_group_key, cx); self.set_group_expanded(project_group_key, is_collapsed, cx); self.update_entries(cx); } fn dispatch_context(&self, window: &Window, cx: &Context) -> KeyContext { let mut dispatch_context = KeyContext::new_with_defaults(); dispatch_context.add("ThreadsSidebar"); dispatch_context.add("menu"); let is_archived_search_focused = matches!(&self.view, SidebarView::Archive(archive) if archive.read(cx).is_filter_editor_focused(window, cx)); let is_renaming_thread = self .thread_rename_editor .focus_handle(cx) .is_focused(window); let identifier = if self.filter_editor.focus_handle(cx).is_focused(window) || is_archived_search_focused { "searching" } else if is_renaming_thread { "editing" } else { "not_searching" }; dispatch_context.add(identifier); dispatch_context } fn focus_in(&mut self, window: &mut Window, cx: &mut Context) { if !self.focus_handle.is_focused(window) { return; } if let SidebarView::Archive(archive) = &self.view { let has_selection = archive.read(cx).has_selection(); if !has_selection { archive.update(cx, |view, cx| view.focus_filter_editor(window, cx)); } } else if self.selection.is_none() { self.filter_editor.focus_handle(cx).focus(window, cx); } } fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { if self.renaming_thread_id.is_some() { self.finish_thread_rename(window, cx); return; } if self.filter_editor.read(cx).is_focused(window) { if self.reset_filter_editor_text(window, cx) { self.selection = None; self.update_entries(cx); return; } if self.selection.is_none() { self.select_first_entry(); } if self.selection.is_some() { self.focus_handle.focus(window, cx); cx.notify(); } return; } if self.reset_filter_editor_text(window, cx) { self.update_entries(cx); } else { self.selection = None; self.filter_editor.focus_handle(cx).focus(window, cx); cx.notify(); } } fn focus_sidebar_filter( &mut self, _: &FocusSidebarFilter, window: &mut Window, cx: &mut Context, ) { self.selection = None; if let SidebarView::Archive(archive) = &self.view { archive.update(cx, |view, cx| { view.clear_selection(); view.focus_filter_editor(window, cx); }); } else { self.filter_editor.focus_handle(cx).focus(window, cx); } cx.notify(); } fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context) -> bool { self.filter_editor.update(cx, |editor, cx| { if editor.buffer().read(cx).len(cx).0 > 0 { editor.set_text("", window, cx); true } else { false } }) } fn has_filter_query(&self, cx: &App) -> bool { !self.filter_editor.read(cx).text(cx).is_empty() } fn start_renaming_thread( &mut self, ix: usize, thread_id: ThreadId, title: SharedString, window: &mut Window, cx: &mut Context, ) { if self.renaming_thread_id.is_some() && self.renaming_thread_id != Some(thread_id) { self.finish_thread_rename(window, cx); } self.selection = Some(ix); self.renaming_thread_id = Some(thread_id); self.suppress_next_rename_edit = true; self.list_state.scroll_to_reveal_item(ix); self.thread_rename_editor.update(cx, |editor, cx| { editor.set_text(title, window, cx); editor.select_all(&editor::actions::SelectAll, window, cx); editor.focus_handle(cx).focus(window, cx); }); cx.notify(); } fn handle_thread_rename_editor_event( &mut self, title_editor: &Entity, event: &editor::EditorEvent, window: &mut Window, cx: &mut Context, ) { match event { editor::EditorEvent::BufferEdited => { if self.suppress_next_rename_edit { self.suppress_next_rename_edit = false; return; } if !title_editor.read(cx).is_focused(window) { return; } let new_title = title_editor.read(cx).text(cx); if new_title.is_empty() { return; } let Some(thread_id) = self.renaming_thread_id else { return; }; self.apply_thread_rename(thread_id, SharedString::from(new_title), window, cx); } editor::EditorEvent::Blurred => { self.finish_thread_rename(window, cx); } _ => {} } } fn apply_thread_rename( &mut self, thread_id: ThreadId, title: SharedString, window: &mut Window, cx: &mut Context, ) { let mut found = false; if let Some(multi_workspace) = self.multi_workspace.upgrade() { let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect(); for workspace in workspaces { if let Some(agent_panel) = workspace.read(cx).panel::(cx) { if let Some(view) = agent_panel .read(cx) .conversation_view_for_id(&thread_id, cx) && let Some(thread_view) = view.read(cx).root_thread_view() { thread_view.update(cx, |thread_view, cx| { thread_view.rename(title.clone(), window, cx); }); found = true; } } } } if !found { ThreadMetadataStore::global(cx).update(cx, |store, cx| { store.set_title_override(thread_id, title, cx); }); } } fn finish_thread_rename(&mut self, window: &mut Window, cx: &mut Context) -> bool { if self.renaming_thread_id.take().is_none() { return false; } self.focus_handle.focus(window, cx); self.update_entries(cx); true } fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context) { self.select_next(&SelectNext, window, cx); if self.selection.is_some() { self.focus_handle.focus(window, cx); } } fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context) { self.select_previous(&SelectPrevious, window, cx); if self.selection.is_some() { self.focus_handle.focus(window, cx); } } fn editor_confirm(&mut self, window: &mut Window, cx: &mut Context) { if self.selection.is_none() { self.select_next(&SelectNext, window, cx); } if self.selection.is_some() { self.focus_handle.focus(window, cx); } } fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context) { let next = match self.selection { Some(ix) if ix + 1 < self.contents.entries.len() => ix + 1, Some(_) if !self.contents.entries.is_empty() => 0, None if !self.contents.entries.is_empty() => 0, _ => return, }; self.selection = Some(next); self.list_state.scroll_to_reveal_item(next); cx.notify(); } fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context) { match self.selection { Some(0) => { self.selection = None; self.filter_editor.focus_handle(cx).focus(window, cx); cx.notify(); } Some(ix) => { self.selection = Some(ix - 1); self.list_state.scroll_to_reveal_item(ix - 1); cx.notify(); } None if !self.contents.entries.is_empty() => { let last = self.contents.entries.len() - 1; self.selection = Some(last); self.list_state.scroll_to_reveal_item(last); cx.notify(); } None => {} } } fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context) { if !self.contents.entries.is_empty() { self.selection = Some(0); self.list_state.scroll_to_reveal_item(0); cx.notify(); } } fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context) { if let Some(last) = self.contents.entries.len().checked_sub(1) { self.selection = Some(last); self.list_state.scroll_to_reveal_item(last); cx.notify(); } } fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { if self.finish_thread_rename(window, cx) { return; } let Some(ix) = self.selection else { return }; let Some(entry) = self.contents.entries.get(ix) else { return; }; match entry { ListEntry::ProjectHeader { key, .. } => { let key = key.clone(); self.toggle_collapse(&key, window, cx); } ListEntry::Thread(thread) => { let metadata = thread.metadata.clone(); match &thread.workspace { ThreadEntryWorkspace::Open(workspace) => { let workspace = workspace.clone(); self.activate_thread(metadata, &workspace, false, window, cx); } ThreadEntryWorkspace::Closed { folder_paths, project_group_key, } => { let folder_paths = folder_paths.clone(); let project_group_key = project_group_key.clone(); self.open_workspace_and_activate_thread( metadata, folder_paths, &project_group_key, window, cx, ); } } } ListEntry::Terminal(terminal) => { let metadata = terminal.metadata.clone(); let workspace = terminal.workspace.clone(); self.activate_terminal_entry(metadata, workspace, false, window, cx); } } } fn find_workspace_across_windows( &self, cx: &App, predicate: impl Fn(&Entity, &App) -> bool, ) -> Option<(WindowHandle, Entity)> { cx.windows() .into_iter() .filter_map(|window| window.downcast::()) .find_map(|window| { let workspace = window.read(cx).ok().and_then(|multi_workspace| { multi_workspace .workspaces() .find(|workspace| predicate(workspace, cx)) .cloned() })?; Some((window, workspace)) }) } fn find_workspace_in_current_window( &self, cx: &App, predicate: impl Fn(&Entity, &App) -> bool, ) -> Option> { self.multi_workspace.upgrade().and_then(|multi_workspace| { multi_workspace .read(cx) .workspaces() .find(|workspace| predicate(workspace, cx)) .cloned() }) } fn load_agent_thread_in_workspace( workspace: &Entity, metadata: &ThreadMetadata, focus: bool, window: &mut Window, cx: &mut App, ) { let load_thread = |agent_panel: Entity, metadata: &ThreadMetadata, focus: bool, window: &mut Window, cx: &mut App| { agent_panel.update(cx, |panel, cx| { panel.load_agent_thread( Agent::from(metadata.agent_id.clone()), metadata.thread_id, Some(metadata.folder_paths().clone()), metadata.title.clone(), focus, AgentThreadSource::Sidebar, window, cx, ); }); }; let mut existing_panel = None; workspace.update(cx, |workspace, cx| { if let Some(panel) = workspace.panel::(cx) { existing_panel = Some(panel); } }); if let Some(agent_panel) = existing_panel { load_thread(agent_panel, metadata, focus, window, cx); workspace.update(cx, |workspace, cx| { if focus { workspace.focus_panel::(window, cx); } else { workspace.reveal_panel::(window, cx); } }); return; } let workspace = workspace.downgrade(); let metadata = metadata.clone(); let mut async_window_cx = window.to_async(cx); cx.spawn(async move |_cx| { let panel = AgentPanel::load(workspace.clone(), async_window_cx.clone()).await?; workspace.update_in(&mut async_window_cx, |workspace, window, cx| { let panel = workspace.panel::(cx).unwrap_or_else(|| { workspace.add_panel(panel.clone(), window, cx); panel.clone() }); load_thread(panel, &metadata, focus, window, cx); if focus { workspace.focus_panel::(window, cx); } else { workspace.reveal_panel::(window, cx); } })?; anyhow::Ok(()) }) .detach_and_log_err(cx); } fn open_closed_native_thread_as_markdown( session_id: &acp::SessionId, title: Option, workspace: &Entity, window: &mut Window, cx: &mut App, ) { let thread_store = ThreadStore::global(cx); let load_task = thread_store.update(cx, |store, cx| store.load_thread(session_id.clone(), cx)); let thread_title = title .map(|t| t.to_string()) .unwrap_or_else(|| DEFAULT_THREAD_TITLE.to_string()); let workspace = workspace.clone(); window .spawn(cx, async move |cx| { let db_thread = load_task.await?; let Some(db_thread) = db_thread else { anyhow::bail!("Thread not found in database"); }; let markdown = db_thread.to_markdown(); cx.update(|window, cx| { agent_ui::open_markdown_in_workspace( thread_title, markdown, workspace, window, cx, ) })? .await }) .detach_and_log_err(cx); } fn show_thread_title_toast(workspace: Entity, message: &'static str, cx: &mut App) { workspace.update(cx, |workspace, cx| { let toast = StatusToast::new(message, cx, |this, _cx| { this.icon( Icon::new(IconName::Warning) .size(IconSize::Small) .color(Color::Warning), ) .dismiss_button(true) }); workspace.toggle_status_toast(toast, cx); }); } fn show_no_thread_summary_model_toast(workspace: Entity, cx: &mut App) { Self::show_thread_title_toast( workspace, "No model is configured for summarizing thread titles.", cx, ); } fn regenerate_thread_title( &mut self, session_id: &acp::SessionId, thread_id: ThreadId, folder_paths: PathList, thread_workspace: Option>, cx: &mut Context, ) { if let Some(panel) = thread_workspace .as_ref() .and_then(|w| w.read(cx).panel::(cx)) { match panel.update(cx, |panel, cx| panel.regenerate_thread_title(thread_id, cx)) { ThreadTitleRegenerationResult::Started | ThreadTitleRegenerationResult::AlreadyGenerating => return, ThreadTitleRegenerationResult::NoModel => { if let Some(workspace) = self.active_workspace(cx) { Self::show_no_thread_summary_model_toast(workspace, cx); } return; } ThreadTitleRegenerationResult::NotOpen => {} } } let Some(configured_model) = LanguageModelRegistry::read_global(cx).thread_summary_model(cx) else { if let Some(workspace) = self.active_workspace(cx) { Self::show_no_thread_summary_model_toast(workspace, cx); } return; }; if !self.regenerating_titles.insert(thread_id) { return; } let model = configured_model.model; let temperature = AgentSettings::temperature_for_model(&model, cx); let thread_store = ThreadStore::global(cx); let load_task = thread_store.update(cx, |store, cx| store.load_thread(session_id.clone(), cx)); let session_id = session_id.clone(); cx.notify(); cx.spawn(async move |this, cx| { let result: anyhow::Result = async { let Some(db_thread) = load_task.await? else { anyhow::bail!("Thread not found in database"); }; let request = agent::build_thread_title_request(&db_thread.messages, temperature); let title = SharedString::from(agent::stream_thread_title(model, request, cx).await?); let Some(mut db_thread) = thread_store .update(cx, |store, cx| store.load_thread(session_id.clone(), cx)) .await? else { anyhow::bail!("Thread not found in database"); }; db_thread.title = title.clone(); thread_store .update(cx, |store, cx| { store.save_thread(session_id, db_thread, folder_paths, cx) }) .await?; anyhow::Ok(title) } .await; this.update(cx, |this, cx| { this.regenerating_titles.remove(&thread_id); match &result { Ok(title) => { ThreadMetadataStore::global(cx).update(cx, |store, cx| { store.set_generated_title(thread_id, title.clone(), cx); }); } Err(_) => { if let Some(workspace) = this.active_workspace(cx) { Self::show_thread_title_toast( workspace, "Failed to regenerate thread title.", cx, ); } } } cx.notify(); }) .ok(); result.map(|_| ()) }) .detach_and_log_err(cx); } fn is_thread_active_in_workspace( &self, thread_id: &ThreadId, workspace: &Entity, cx: &App, ) -> bool { self.active_workspace(cx).as_ref() == Some(workspace) && self.active_entry.as_ref().is_some_and(|entry| { entry.is_active_thread(thread_id) && entry.workspace() == workspace }) } fn activate_thread_locally( &mut self, metadata: &ThreadMetadata, workspace: &Entity, retain: bool, window: &mut Window, cx: &mut Context, ) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; if self.is_thread_active_in_workspace(&metadata.thread_id, workspace, cx) { workspace.update(cx, |workspace, cx| { workspace.focus_panel::(window, cx); }); return; } // Set active_entry eagerly so the sidebar highlight updates // immediately, rather than waiting for a deferred AgentPanel // event which can race with ActiveWorkspaceChanged clearing it. self.active_entry = Some(ActiveEntry::Thread { thread_id: metadata.thread_id, session_id: metadata.session_id.clone(), workspace: workspace.clone(), }); self.record_thread_access(&metadata.thread_id); self.pending_thread_activation = Some(metadata.thread_id); multi_workspace.update(cx, |multi_workspace, cx| { multi_workspace.activate(workspace.clone(), None, window, cx); if retain { multi_workspace.retain_active_workspace(cx); } }); Self::load_agent_thread_in_workspace(workspace, metadata, true, window, cx); self.update_entries(cx); } fn activate_thread_in_other_window( &self, metadata: ThreadMetadata, workspace: Entity, target_window: WindowHandle, cx: &mut Context, ) { let target_session_id = metadata.session_id.clone(); let metadata_thread_id = metadata.thread_id; let workspace_for_entry = workspace.clone(); let activated = target_window .update(cx, |multi_workspace, window, cx| { window.activate_window(); multi_workspace.activate(workspace.clone(), None, window, cx); Self::load_agent_thread_in_workspace(&workspace, &metadata, true, window, cx); }) .log_err() .is_some(); if activated { if let Some(target_sidebar) = target_window .read(cx) .ok() .and_then(|multi_workspace| { multi_workspace.sidebar().map(|sidebar| sidebar.to_any()) }) .and_then(|sidebar| sidebar.downcast::().ok()) { target_sidebar.update(cx, |sidebar, cx| { sidebar.pending_thread_activation = Some(metadata_thread_id); sidebar.active_entry = Some(ActiveEntry::Thread { thread_id: metadata_thread_id, session_id: target_session_id.clone(), workspace: workspace_for_entry.clone(), }); sidebar.record_thread_access(&metadata_thread_id); sidebar.update_entries(cx); }); } } } fn activate_thread( &mut self, metadata: ThreadMetadata, workspace: &Entity, retain: bool, window: &mut Window, cx: &mut Context, ) { if self .find_workspace_in_current_window(cx, |candidate, _| candidate == workspace) .is_some() { self.activate_thread_locally(&metadata, &workspace, retain, window, cx); return; } let Some((target_window, workspace)) = self.find_workspace_across_windows(cx, |candidate, _| candidate == workspace) else { return; }; self.activate_thread_in_other_window(metadata, workspace, target_window, cx); } fn open_workspace_and_activate_thread( &mut self, metadata: ThreadMetadata, folder_paths: PathList, project_group_key: &ProjectGroupKey, window: &mut Window, cx: &mut Context, ) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; let pending_thread_id = metadata.thread_id; // Mark the pending thread activation so rebuild_contents // preserves the Thread active_entry during loading and // reconciliation cannot synthesize an empty fallback draft. self.pending_thread_activation = Some(pending_thread_id); let host = project_group_key.host(); let provisional_key = Some(project_group_key.clone()); let active_workspace = multi_workspace.read(cx).workspace().clone(); let modal_workspace = active_workspace.clone(); let open_task = multi_workspace.update(cx, |this, cx| { this.find_or_create_workspace( folder_paths, host, provisional_key, |options, window, cx| connect_remote(active_workspace, options, window, cx), &[], None, OpenMode::Activate, window, cx, ) }); cx.spawn_in(window, async move |this, cx| { let result = open_task.await; // Dismiss the modal as soon as the open attempt completes so // failures or cancellations do not leave a stale connection modal behind. remote_connection::dismiss_connection_modal(&modal_workspace, cx); if result.is_err() { this.update(cx, |this, _cx| { if this.pending_thread_activation == Some(pending_thread_id) { this.pending_thread_activation = None; } }) .ok(); } let workspace = result?; this.update_in(cx, |this, window, cx| { this.activate_thread(metadata, &workspace, false, window, cx); })?; anyhow::Ok(()) }) .detach_and_log_err(cx); } fn find_current_workspace_for_path_list( &self, path_list: &PathList, remote_connection: Option<&RemoteConnectionOptions>, cx: &App, ) -> Option> { self.find_workspace_in_current_window(cx, |workspace, cx| { workspace_path_list(workspace, cx).paths() == path_list.paths() && same_remote_connection_identity( workspace .read(cx) .project() .read(cx) .remote_connection_options(cx) .as_ref(), remote_connection, ) }) } fn find_open_workspace_for_path_list( &self, path_list: &PathList, remote_connection: Option<&RemoteConnectionOptions>, cx: &App, ) -> Option<(WindowHandle, Entity)> { self.find_workspace_across_windows(cx, |workspace, cx| { workspace_path_list(workspace, cx).paths() == path_list.paths() && same_remote_connection_identity( workspace .read(cx) .project() .read(cx) .remote_connection_options(cx) .as_ref(), remote_connection, ) }) } fn open_thread_from_archive( &mut self, metadata: ThreadMetadata, window: &mut Window, cx: &mut Context, ) { let thread_id = metadata.thread_id; let weak_archive_view = match &self.view { SidebarView::Archive(view) => Some(view.downgrade()), _ => None, }; if metadata.folder_paths().paths().is_empty() { ThreadMetadataStore::global(cx).update(cx, |store, cx| store.unarchive(thread_id, cx)); let active_workspace = self .multi_workspace .upgrade() .map(|w| w.read(cx).workspace().clone()); if let Some(workspace) = active_workspace { self.activate_thread_locally(&metadata, &workspace, false, window, cx); } else { let path_list = metadata.folder_paths().clone(); if let Some((target_window, workspace)) = self.find_open_workspace_for_path_list( &path_list, metadata.remote_connection.as_ref(), cx, ) { self.activate_thread_in_other_window(metadata, workspace, target_window, cx); } else { let key = ProjectGroupKey::from_worktree_paths( &metadata.worktree_paths, metadata.remote_connection.clone(), ); self.open_workspace_and_activate_thread(metadata, path_list, &key, window, cx); } } self.show_thread_list(window, cx); return; } let store = ThreadMetadataStore::global(cx); let task = if metadata.archived { store .read(cx) .get_archived_worktrees_for_thread(thread_id, cx) } else { Task::ready(Ok(Vec::new())) }; let path_list = metadata.folder_paths().clone(); let restore_task = cx.spawn_in(window, async move |this, cx| { let result: anyhow::Result<()> = async { let archived_worktrees = task.await?; if archived_worktrees.is_empty() { this.update_in(cx, |this, window, cx| { this.restoring_tasks.remove(&thread_id); if metadata.archived { ThreadMetadataStore::global(cx) .update(cx, |store, cx| store.unarchive(thread_id, cx)); } if let Some(workspace) = this.find_current_workspace_for_path_list( &path_list, metadata.remote_connection.as_ref(), cx, ) { this.activate_thread_locally(&metadata, &workspace, false, window, cx); } else if let Some((target_window, workspace)) = this .find_open_workspace_for_path_list( &path_list, metadata.remote_connection.as_ref(), cx, ) { this.activate_thread_in_other_window( metadata, workspace, target_window, cx, ); } else { let key = ProjectGroupKey::from_worktree_paths( &metadata.worktree_paths, metadata.remote_connection.clone(), ); this.open_workspace_and_activate_thread( metadata, path_list, &key, window, cx, ); } this.show_thread_list(window, cx); })?; return anyhow::Ok(()); } let mut path_replacements: Vec<(PathBuf, PathBuf)> = Vec::new(); for row in &archived_worktrees { match thread_worktree_archive::restore_worktree_via_git( row, metadata.remote_connection.as_ref(), &mut *cx, ) .await { Ok(restored_path) => { thread_worktree_archive::cleanup_archived_worktree_record( row, metadata.remote_connection.as_ref(), &mut *cx, ) .await; path_replacements.push((row.worktree_path.clone(), restored_path)); } Err(error) => { log::error!("Failed to restore worktree: {error:#}"); this.update_in(cx, |this, _window, cx| { this.restoring_tasks.remove(&thread_id); if let Some(weak_archive_view) = &weak_archive_view { weak_archive_view .update(cx, |view, cx| { view.clear_restoring(&thread_id, cx); }) .ok(); } if let Some(multi_workspace) = this.multi_workspace.upgrade() { let workspace = multi_workspace.read(cx).workspace().clone(); workspace.update(cx, |workspace, cx| { struct RestoreWorktreeErrorToast; workspace.show_toast( Toast::new( NotificationId::unique::( ), format!("Failed to restore worktree: {error:#}"), ) .autohide(), cx, ); }); } }) .ok(); return anyhow::Ok(()); } } } if !path_replacements.is_empty() { cx.update(|_window, cx| { store.update(cx, |store, cx| { store.update_restored_worktree_paths(thread_id, &path_replacements, cx); }); })?; let updated_metadata = cx.update(|_window, cx| store.read(cx).entry(thread_id).cloned())?; if let Some(updated_metadata) = updated_metadata { let new_paths = updated_metadata.folder_paths().clone(); let key = ProjectGroupKey::from_worktree_paths( &updated_metadata.worktree_paths, updated_metadata.remote_connection.clone(), ); cx.update(|_window, cx| { store.update(cx, |store, cx| { store.unarchive(updated_metadata.thread_id, cx); }); })?; this.update_in(cx, |this, window, cx| { this.restoring_tasks.remove(&thread_id); this.open_workspace_and_activate_thread( updated_metadata, new_paths, &key, window, cx, ); this.show_thread_list(window, cx); })?; } } anyhow::Ok(()) } .await; if let Err(error) = result { log::error!("{error:#}"); } }); self.restoring_tasks.insert(thread_id, restore_task); } fn expand_selected_entry( &mut self, _: &SelectChild, _window: &mut Window, cx: &mut Context, ) { let Some(ix) = self.selection else { return }; match self.contents.entries.get(ix) { Some(ListEntry::ProjectHeader { key, .. }) => { let key = key.clone(); if self.is_group_collapsed(&key, cx) { self.set_group_expanded(&key, true, cx); self.update_entries(cx); } else if ix + 1 < self.contents.entries.len() { self.selection = Some(ix + 1); self.list_state.scroll_to_reveal_item(ix + 1); cx.notify(); } } _ => {} } } fn collapse_selected_entry( &mut self, _: &SelectParent, _window: &mut Window, cx: &mut Context, ) { let Some(ix) = self.selection else { return }; match self.contents.entries.get(ix) { Some(ListEntry::ProjectHeader { key, .. }) => { let key = key.clone(); if !self.is_group_collapsed(&key, cx) { self.set_group_expanded(&key, false, cx); self.update_entries(cx); } } Some(ListEntry::Thread(_) | ListEntry::Terminal(_)) => { for i in (0..ix).rev() { if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(i) { let key = key.clone(); self.selection = Some(i); self.set_group_expanded(&key, false, cx); self.update_entries(cx); break; } } } None => {} } } fn toggle_selected_fold( &mut self, _: &editor::actions::ToggleFold, _window: &mut Window, cx: &mut Context, ) { let Some(ix) = self.selection else { return }; // Find the group header for the current selection. let header_ix = match self.contents.entries.get(ix) { Some(ListEntry::ProjectHeader { .. }) => Some(ix), Some(ListEntry::Thread(_) | ListEntry::Terminal(_)) => (0..ix).rev().find(|&i| { matches!( self.contents.entries.get(i), Some(ListEntry::ProjectHeader { .. }) ) }), None => None, }; if let Some(header_ix) = header_ix { if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(header_ix) { let key = key.clone(); if self.is_group_collapsed(&key, cx) { self.set_group_expanded(&key, true, cx); } else { self.selection = Some(header_ix); self.set_group_expanded(&key, false, cx); } self.update_entries(cx); } } } fn fold_all( &mut self, _: &editor::actions::FoldAll, _window: &mut Window, cx: &mut Context, ) { if let Some(mw) = self.multi_workspace.upgrade() { mw.update(cx, |mw, _cx| { mw.set_all_groups_expanded(false); }); } self.update_entries(cx); } fn unfold_all( &mut self, _: &editor::actions::UnfoldAll, _window: &mut Window, cx: &mut Context, ) { if let Some(mw) = self.multi_workspace.upgrade() { mw.update(cx, |mw, _cx| { mw.set_all_groups_expanded(true); }); } self.update_entries(cx); } fn stop_thread(&mut self, thread_id: &agent_ui::ThreadId, cx: &mut Context) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect(); for workspace in workspaces { if let Some(agent_panel) = workspace.read(cx).panel::(cx) { let cancelled = agent_panel.update(cx, |panel, cx| panel.cancel_thread(thread_id, cx)); if cancelled { return; } } } } /// Find the neighbor thread in the sidebar (by display position). /// Look below first, then above, for the nearest thread that isn't /// the one being archived. We capture both the neighbor's metadata /// (for activation) and its workspace paths (for the workspace /// removal fallback). fn neighboring_activatable_entry(&self, current_position: usize) -> Option { let after = self .contents .entries .get(current_position.checked_add(1)?..)?; let before = self.contents.entries.get(..current_position)?; after .iter() .chain(before.iter().rev()) .find_map(ActivatableEntry::from_list_entry) } fn activate_entry( &mut self, entry: &ActivatableEntry, window: &mut Window, cx: &mut Context, ) -> bool { match entry { ActivatableEntry::Thread { metadata, .. } => { let Some(workspace) = self.multi_workspace.upgrade().and_then(|multi_workspace| { multi_workspace .read(cx) .workspace_for_paths(metadata.folder_paths(), None, cx) }) else { return false; }; self.active_entry = Some(ActiveEntry::Thread { thread_id: metadata.thread_id, session_id: metadata.session_id.clone(), workspace: workspace.clone(), }); self.activate_workspace(&workspace, window, cx); Self::load_agent_thread_in_workspace(&workspace, metadata, true, window, cx); true } ActivatableEntry::Terminal { metadata, workspace, } => { self.activate_terminal_entry( metadata.clone(), workspace.clone(), false, window, cx, ); true } } } fn activate_terminal_entry( &mut self, metadata: TerminalThreadMetadata, workspace: ThreadEntryWorkspace, retain: bool, window: &mut Window, cx: &mut Context, ) { match workspace { ThreadEntryWorkspace::Open(workspace) => { self.activate_terminal_in_workspace(&workspace, metadata, retain, window, cx); } ThreadEntryWorkspace::Closed { folder_paths, project_group_key, } => { self.open_workspace_and_activate_terminal( metadata, folder_paths, &project_group_key, window, cx, ); } } } fn load_agent_terminal_in_workspace( workspace: &Entity, metadata: &TerminalThreadMetadata, focus: bool, window: &mut Window, cx: &mut App, ) { let restore_terminal = |agent_panel: Entity, metadata: &TerminalThreadMetadata, focus: bool, workspace: Option<&Workspace>, window: &mut Window, cx: &mut App| { agent_panel.update(cx, |panel, cx| { panel.restore_terminal( metadata.clone(), focus, AgentThreadSource::Sidebar, workspace, window, cx, ); }); }; let mut existing_panel = None; workspace.update(cx, |workspace, cx| { if let Some(panel) = workspace.panel::(cx) { existing_panel = Some(panel); } }); if let Some(agent_panel) = existing_panel { restore_terminal(agent_panel, metadata, focus, None, window, cx); workspace.update(cx, |workspace, cx| { if focus { workspace.focus_panel::(window, cx); } else { workspace.reveal_panel::(window, cx); } }); return; } let workspace = workspace.downgrade(); let metadata = metadata.clone(); let mut async_window_cx = window.to_async(cx); cx.spawn(async move |_cx| { let panel = AgentPanel::load(workspace.clone(), async_window_cx.clone()).await?; workspace.update_in(&mut async_window_cx, |workspace, window, cx| { let panel = workspace.panel::(cx).unwrap_or_else(|| { workspace.add_panel(panel.clone(), window, cx); panel.clone() }); restore_terminal(panel, &metadata, focus, Some(workspace), window, cx); if focus { workspace.focus_panel::(window, cx); } else { workspace.reveal_panel::(window, cx); } })?; anyhow::Ok(()) }) .detach_and_log_err(cx); } fn activate_terminal_in_workspace( &mut self, workspace: &Entity, metadata: TerminalThreadMetadata, retain: bool, window: &mut Window, cx: &mut Context, ) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; let terminal_id = metadata.terminal_id; self.record_terminal_access(terminal_id); self.active_entry = Some(ActiveEntry::Terminal { terminal_id, workspace: workspace.clone(), }); multi_workspace.update(cx, |multi_workspace, cx| { multi_workspace.activate(workspace.clone(), None, window, cx); if retain { multi_workspace.retain_active_workspace(cx); } }); Self::load_agent_terminal_in_workspace(workspace, &metadata, true, window, cx); self.update_entries(cx); } fn open_workspace_and_activate_terminal( &mut self, metadata: TerminalThreadMetadata, folder_paths: PathList, project_group_key: &ProjectGroupKey, window: &mut Window, cx: &mut Context, ) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; let host = project_group_key.host(); let provisional_key = Some(project_group_key.clone()); let active_workspace = multi_workspace.read(cx).workspace().clone(); let modal_workspace = active_workspace.clone(); let open_task = multi_workspace.update(cx, |this, cx| { this.find_or_create_workspace( folder_paths, host, provisional_key, |options, window, cx| connect_remote(active_workspace, options, window, cx), &[], None, OpenMode::Activate, window, cx, ) }); cx.spawn_in(window, async move |this, cx| { let result = open_task.await; remote_connection::dismiss_connection_modal(&modal_workspace, cx); let workspace = result?; this.update_in(cx, |this, window, cx| { this.activate_terminal_in_workspace(&workspace, metadata, false, window, cx); })?; anyhow::Ok(()) }) .detach_and_log_err(cx); } fn should_load_closed_workspace_for_archive( &self, folder_paths: &PathList, project_group_key: &ProjectGroupKey, remote_connection: Option<&RemoteConnectionOptions>, except_thread_id: Option, except_terminal_id: Option, cx: &App, ) -> bool { if folder_paths.is_empty() || folder_paths == project_group_key.path_list() { return false; } let archive_workspaces = self.archive_workspaces(cx); let thread_store = ThreadMetadataStore::global(cx); let thread_store = thread_store.read(cx); if folder_paths.ordered_paths().any(|path| { Self::path_is_referenced_by_unarchived_threads_for_archive( &thread_store, except_thread_id, path, remote_connection, &archive_workspaces, cx, ) }) { return false; } TerminalThreadMetadataStore::try_global(cx).is_none_or(|terminal_store| { let terminal_store = terminal_store.read(cx); !folder_paths.ordered_paths().any(|path| { terminal_store.path_is_referenced_by_terminal( except_terminal_id, path, remote_connection, ) }) }) } fn path_is_referenced_by_unarchived_threads_for_archive( thread_store: &ThreadMetadataStore, except_thread_id: Option, path: &Path, remote_connection: Option<&RemoteConnectionOptions>, archive_workspaces: &[Entity], cx: &App, ) -> bool { thread_store.path_is_referenced_by_unarchived_threads_matching( except_thread_id, path, remote_connection, |thread| Self::thread_blocks_worktree_archive(thread, archive_workspaces, cx), ) } fn archive_workspaces(&self, cx: &App) -> Vec> { let multi_workspace = self.multi_workspace.upgrade(); thread_worktree_archive::workspaces_for_archive(multi_workspace.as_ref(), cx) } fn count_threads_blocking_worktree_archive( &self, path_list: &PathList, remote_connection: Option<&RemoteConnectionOptions>, except_thread_id: Option, cx: &App, ) -> usize { let archive_workspaces = self.archive_workspaces(cx); ThreadMetadataStore::global(cx) .read(cx) .entries_for_path(path_list, remote_connection) .filter(|thread| Some(thread.thread_id) != except_thread_id) .filter(|thread| Self::thread_blocks_worktree_archive(thread, &archive_workspaces, cx)) .count() } fn roots_to_archive_for_paths( &self, folder_paths: &PathList, remote_connection: Option<&RemoteConnectionOptions>, except_thread_id: Option, except_terminal_id: Option, cx: &App, ) -> Vec { let workspaces = self.archive_workspaces(cx); folder_paths .ordered_paths() .filter_map(|path| { thread_worktree_archive::build_root_plan(path, remote_connection, &workspaces, cx) }) .filter(|plan| { let store = ThreadMetadataStore::global(cx); let store = store.read(cx); !Self::path_is_referenced_by_unarchived_threads_for_archive( &store, except_thread_id, plan.root_path.as_path(), remote_connection, &workspaces, cx, ) }) .filter(|root| { TerminalThreadMetadataStore::try_global(cx).is_none_or(|terminal_store| { !terminal_store.read(cx).path_is_referenced_by_terminal( except_terminal_id, root.root_path.as_path(), remote_connection, ) }) }) .collect() } fn linked_worktree_workspace_to_remove( &self, folder_paths: &PathList, remote_connection: Option<&RemoteConnectionOptions>, except_thread_id: Option, except_terminal_id: Option, roots_to_archive: &[thread_worktree_archive::RootPlan], cx: &App, ) -> Option> { if folder_paths.is_empty() { return None; } let remaining = self.count_threads_blocking_worktree_archive( folder_paths, remote_connection, except_thread_id, cx, ); if remaining > 0 { return None; } let multi_workspace = self.multi_workspace.upgrade()?; let workspace = multi_workspace .read(cx) .workspace_for_paths(folder_paths, remote_connection, cx)?; if workspace_has_terminal_metadata_except(&workspace, except_terminal_id, cx) { return None; } if !roots_to_archive.is_empty() { let archive_paths: HashSet<&Path> = roots_to_archive .iter() .map(|root| root.root_path.as_path()) .collect(); let project = workspace.read(cx).project().clone(); let visible_worktree_paths = project .read(cx) .visible_worktrees(cx) .map(|worktree| worktree.read(cx).abs_path()) .collect::>(); return (!visible_worktree_paths.is_empty() && visible_worktree_paths .iter() .all(|path| archive_paths.contains(path.as_ref()))) .then_some(workspace); } let group_key = workspace.read(cx).project_group_key(cx); (group_key.path_list() != folder_paths).then_some(workspace) } fn delete_empty_drafts_for_archive_roots( &self, roots: &[thread_worktree_archive::RootPlan], cx: &mut Context, ) { self.delete_empty_drafts_for_archive_targets( roots .iter() .map(|root| (root.root_path.as_path(), root.remote_connection.as_ref())), cx, ); } fn delete_empty_drafts_for_archive_paths( &self, paths: &PathList, remote_connection: Option<&RemoteConnectionOptions>, cx: &mut Context, ) { self.delete_empty_drafts_for_archive_targets( paths .ordered_paths() .map(|path| (path.as_path(), remote_connection)), cx, ); } fn delete_empty_drafts_for_archive_targets<'a>( &self, targets: impl IntoIterator)>, cx: &mut Context, ) { let targets = targets.into_iter().collect::>(); if targets.is_empty() { return; } let archive_workspaces = self.archive_workspaces(cx); let draft_thread_ids = ThreadMetadataStore::global(cx) .read(cx) .unarchived_draft_ids_matching(|thread| { targets.iter().any(|(path, remote_connection)| { thread.matches_remote_connection(*remote_connection) && thread.references_folder_path(path) }) && !Self::thread_blocks_worktree_archive(thread, &archive_workspaces, cx) }); if draft_thread_ids.is_empty() { return; } ThreadMetadataStore::global(cx).update(cx, |store, cx| { store.delete_all(draft_thread_ids, cx); }); } fn thread_blocks_worktree_archive( thread: &ThreadMetadata, archive_workspaces: &[Entity], cx: &App, ) -> bool { if !thread.is_draft() { return true; } agent_ui::draft_prompt_store::draft_has_user_content( thread.thread_id, archive_workspaces, cx, ) } async fn wait_for_archive_workspace_metadata( workspace: &Entity, cx: &mut gpui::AsyncApp, ) { let scans_complete = workspace.read_with(cx, |workspace, cx| workspace.worktree_scans_complete(cx)); scans_complete.await; let project = workspace.read_with(cx, |workspace, _| workspace.project().clone()); let barriers = project.update(cx, |project, cx| { let repositories = project .repositories(cx) .values() .cloned() .collect::>(); repositories .into_iter() .map(|repository| repository.update(cx, |repository, _| repository.barrier())) .collect::>() }); for barrier in barriers { let result: anyhow::Result<()> = barrier.await.map_err(|_| { anyhow::anyhow!("git repository barrier canceled while archiving worktree") }); result.log_err(); } } fn open_workspace_for_archive( &mut self, folder_paths: PathList, project_group_key: ProjectGroupKey, window: &mut Window, cx: &mut Context, ) -> Option<(Task>>, Entity)> { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return None; }; let host = project_group_key.host(); let active_workspace = multi_workspace.read(cx).workspace().clone(); let modal_workspace = active_workspace.clone(); let open_task = multi_workspace.update(cx, |this, cx| { this.find_or_create_workspace( folder_paths, host, Some(project_group_key), |options, window, cx| connect_remote(active_workspace, options, window, cx), &[], None, OpenMode::Add, window, cx, ) }); Some((open_task, modal_workspace)) } fn open_workspace_and_archive_thread( &mut self, session_id: acp::SessionId, folder_paths: PathList, project_group_key: ProjectGroupKey, window: &mut Window, cx: &mut Context, ) { let Some((open_task, modal_workspace)) = self.open_workspace_for_archive(folder_paths, project_group_key, window, cx) else { return; }; cx.spawn_in(window, async move |this, cx| { let result = open_task.await; remote_connection::dismiss_connection_modal(&modal_workspace, cx); let workspace = result?; Self::wait_for_archive_workspace_metadata(&workspace, cx).await; this.update_in(cx, |this, window, cx| { this.update_entries(cx); this.archive_thread(&session_id, window, cx); })?; anyhow::Ok(()) }) .detach_and_log_err(cx); } fn open_workspace_and_close_terminal( &mut self, metadata: TerminalThreadMetadata, folder_paths: PathList, project_group_key: ProjectGroupKey, window: &mut Window, cx: &mut Context, ) { let Some((open_task, modal_workspace)) = self.open_workspace_for_archive(folder_paths, project_group_key, window, cx) else { return; }; cx.spawn_in(window, async move |this, cx| { let result = open_task.await; remote_connection::dismiss_connection_modal(&modal_workspace, cx); let workspace = result?; Self::wait_for_archive_workspace_metadata(&workspace, cx).await; this.update_in(cx, |this, window, cx| { let workspace = ThreadEntryWorkspace::Open(workspace); this.close_terminal(&metadata, &workspace, window, cx); })?; anyhow::Ok(()) }) .detach_and_log_err(cx); } fn close_terminal( &mut self, metadata: &TerminalThreadMetadata, workspace: &ThreadEntryWorkspace, window: &mut Window, cx: &mut Context, ) { if let ThreadEntryWorkspace::Closed { folder_paths, project_group_key, } = workspace && self.should_load_closed_workspace_for_archive( folder_paths, project_group_key, metadata.remote_connection.as_ref(), None, Some(metadata.terminal_id), cx, ) { self.open_workspace_and_close_terminal( metadata.clone(), folder_paths.clone(), project_group_key.clone(), window, cx, ); return; } let terminal_id = metadata.terminal_id; let is_active = self .active_entry .as_ref() .is_some_and(|entry| entry.is_active_terminal(terminal_id)); let neighbor = self .contents .entries .iter() .position(|entry| { matches!( entry, ListEntry::Terminal(terminal) if terminal.metadata.terminal_id == terminal_id ) }) .and_then(|position| self.neighboring_activatable_entry(position)); let terminal_folder_paths = metadata.folder_paths().clone(); let roots_to_archive = self.roots_to_archive_for_paths( metadata.folder_paths(), metadata.remote_connection.as_ref(), None, Some(terminal_id), cx, ); let workspace_to_remove = self.linked_worktree_workspace_to_remove( &terminal_folder_paths, metadata.remote_connection.as_ref(), None, Some(terminal_id), &roots_to_archive, cx, ); let mut workspaces_to_remove: Vec> = workspace_to_remove.into_iter().collect(); let close_item_tasks = self.close_items_for_archived_worktrees( &roots_to_archive, &mut workspaces_to_remove, window, cx, ); if !workspaces_to_remove.is_empty() { let multi_workspace = self.multi_workspace.upgrade().unwrap(); let terminal_workspace_removed = matches!( workspace, ThreadEntryWorkspace::Open(workspace) if workspaces_to_remove.contains(workspace) ); let (fallback_paths, project_group_key) = neighbor .as_ref() .map(|neighbor| neighbor.project_location(cx)) .unwrap_or_else(|| { workspaces_to_remove .first() .map(|workspace| { let key = workspace.read(cx).project_group_key(cx); (key.path_list().clone(), key) }) .unwrap_or_default() }); let excluded = workspaces_to_remove.clone(); let remove_task = multi_workspace.update(cx, |multi_workspace, cx| { multi_workspace.remove( workspaces_to_remove, move |this, window, cx| { let active_workspace = this.workspace().clone(); this.find_or_create_workspace( fallback_paths, project_group_key.host(), Some(project_group_key), |options, window, cx| { connect_remote(active_workspace, options, window, cx) }, &excluded, None, OpenMode::Activate, window, cx, ) }, window, cx, ) }); let metadata = metadata.clone(); let workspace = workspace.clone(); cx.spawn_in(window, async move |this, cx| { if !remove_task.await? { return anyhow::Ok(()); } for task in close_item_tasks { let result: anyhow::Result<()> = task.await; result.log_err(); } this.update_in(cx, |this, window, cx| { if terminal_workspace_removed { this.delete_empty_drafts_for_archive_paths( metadata.folder_paths(), metadata.remote_connection.as_ref(), cx, ); } // If the terminal's workspace has already been removed, // don't synthesize a fallback draft in the detached // AgentPanel. this.close_terminal_entry( &metadata, &workspace, is_active, neighbor.as_ref(), !terminal_workspace_removed, roots_to_archive, window, cx, ); })?; anyhow::Ok(()) }) .detach_and_log_err(cx); } else if !close_item_tasks.is_empty() { let metadata = metadata.clone(); let workspace = workspace.clone(); cx.spawn_in(window, async move |this, cx| { for task in close_item_tasks { let result: anyhow::Result<()> = task.await; result.log_err(); } this.update_in(cx, |this, window, cx| { this.close_terminal_entry( &metadata, &workspace, is_active, neighbor.as_ref(), true, roots_to_archive, window, cx, ); })?; anyhow::Ok(()) }) .detach_and_log_err(cx); } else { self.close_terminal_entry( metadata, workspace, is_active, neighbor.as_ref(), true, roots_to_archive, window, cx, ); } } fn close_terminal_entry( &mut self, metadata: &TerminalThreadMetadata, workspace: &ThreadEntryWorkspace, is_active: bool, neighbor: Option<&ActivatableEntry>, activate_panel_draft: bool, roots_to_archive: Vec, window: &mut Window, cx: &mut Context, ) { let terminal_id = metadata.terminal_id; let defer_draft_activation = activate_panel_draft && is_active && neighbor.is_some(); // Closing from the sidebar must not steal focus, since the row's // workspace may not be the active workspace. if let ThreadEntryWorkspace::Open(workspace) = workspace { workspace.update(cx, |workspace, cx| { if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { if defer_draft_activation || !activate_panel_draft { panel.close_terminal_without_activating_draft(terminal_id, window, cx); } else { panel.close_terminal(terminal_id, window, cx); } }); } }); } if let Some(store) = TerminalThreadMetadataStore::try_global(cx) { store.update(cx, |store, cx| { store.delete(terminal_id, cx); }); } self.start_detached_archive_worktree_task(roots_to_archive, cx); if is_active { self.active_entry = None; if neighbor .as_ref() .is_some_and(|neighbor| self.activate_entry(neighbor, window, cx)) { return; } if defer_draft_activation && let ThreadEntryWorkspace::Open(workspace) = workspace { workspace.update(cx, |workspace, cx| { if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { panel.activate_draft(false, AgentThreadSource::AgentPanel, window, cx); }); } }); } self.sync_active_entry_from_active_workspace(cx); } self.update_entries(cx); } fn close_items_for_archived_worktrees( &self, roots_to_archive: &[thread_worktree_archive::RootPlan], workspaces_to_remove: &mut Vec>, window: &mut Window, cx: &mut Context, ) -> Vec>> { if roots_to_archive.is_empty() { return Vec::new(); } let archive_paths: HashSet<&Path> = roots_to_archive .iter() .map(|root| root.root_path.as_path()) .collect(); let mut mixed_workspaces: Vec<(Entity, Vec)> = Vec::new(); if let Some(multi_workspace) = self.multi_workspace.upgrade() { let all_workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect(); for workspace in all_workspaces { if workspaces_to_remove.contains(&workspace) { continue; } let project = workspace.read(cx).project().read(cx); let visible_worktrees: Vec<_> = project .visible_worktrees(cx) .map(|worktree| (worktree.read(cx).id(), worktree.read(cx).abs_path())) .collect(); let archived_worktree_ids: Vec = visible_worktrees .iter() .filter(|(_, path)| archive_paths.contains(path.as_ref())) .map(|(id, _)| *id) .collect(); if archived_worktree_ids.is_empty() { continue; } if visible_worktrees.len() == archived_worktree_ids.len() { workspaces_to_remove.push(workspace); } else { mixed_workspaces.push((workspace, archived_worktree_ids)); } } } let mut close_item_tasks = Vec::new(); for (workspace, archived_worktree_ids) in &mixed_workspaces { let panes: Vec<_> = workspace.read(cx).panes().to_vec(); for pane in panes { let items_to_close: Vec = pane .read(cx) .items() .filter(|item| { item.project_path(cx) .is_some_and(|pp| archived_worktree_ids.contains(&pp.worktree_id)) }) .map(|item| item.item_id()) .collect(); if !items_to_close.is_empty() { let task = pane.update(cx, |pane, cx| { pane.close_items(window, cx, SaveIntent::Close, &|item_id| { items_to_close.contains(&item_id) }) }); close_item_tasks.push(task); } } } close_item_tasks } fn archive_thread( &mut self, session_id: &acp::SessionId, window: &mut Window, cx: &mut Context, ) { let store = ThreadMetadataStore::global(cx); let metadata = store.read(cx).entry_by_session(session_id).cloned(); let metadata_thread_id = metadata.as_ref().map(|metadata| metadata.thread_id); let thread_entry = self.contents.entries.iter().find_map(|entry| match entry { ListEntry::Thread(thread) => metadata_thread_id .map_or_else( || thread.metadata.session_id.as_ref() == Some(session_id), |thread_id| thread.metadata.thread_id == thread_id, ) .then(|| thread.clone()), _ => None, }); let thread_id = metadata_thread_id.or_else(|| { thread_entry .as_ref() .map(|thread| thread.metadata.thread_id) }); let active_workspace = thread_id.and_then(|thread_id| { self.active_entry.as_ref().and_then(|entry| { if entry.is_active_thread(&thread_id) { Some(entry.workspace().clone()) } else { None } }) }); let thread_folder_paths = metadata .as_ref() .map(|metadata| metadata.folder_paths().clone()) .or_else(|| { thread_entry .as_ref() .map(|thread| thread.metadata.folder_paths().clone()) }) .or_else(|| { active_workspace .as_ref() .map(|workspace| PathList::new(&workspace.read(cx).root_paths(cx))) }); let thread_entry_workspace = thread_entry.map(|thread| thread.workspace.clone()); if let ( Some(metadata), Some(ThreadEntryWorkspace::Closed { folder_paths, project_group_key, }), ) = (metadata.as_ref(), thread_entry_workspace) && self.should_load_closed_workspace_for_archive( &folder_paths, &project_group_key, metadata.remote_connection.as_ref(), Some(metadata.thread_id), None, cx, ) { self.open_workspace_and_archive_thread( session_id.clone(), folder_paths, project_group_key, window, cx, ); return; } // Compute which linked worktree roots should be archived from disk if // this thread is archived. This must happen before we remove any // workspace from the MultiWorkspace, because `build_root_plan` needs // the currently open workspaces in order to find the affected projects // and repository handles for each linked worktree. let roots_to_archive = metadata .as_ref() .map(|metadata| { self.roots_to_archive_for_paths( metadata.folder_paths(), metadata.remote_connection.as_ref(), thread_id, None, cx, ) }) .unwrap_or_default(); let current_pos = self.contents.entries.iter().position(|entry| match entry { ListEntry::Thread(thread) => thread_id.map_or_else( || thread.metadata.session_id.as_ref() == Some(session_id), |tid| thread.metadata.thread_id == tid, ), _ => false, }); let neighbor = current_pos.and_then(|position| self.neighboring_activatable_entry(position)); // Check if archiving this thread would leave its worktree workspace // with no threads, requiring workspace removal. let workspace_to_remove = thread_folder_paths.as_ref().and_then(|folder_paths| { let thread_remote_connection = metadata.as_ref().and_then(|m| m.remote_connection.as_ref()); self.linked_worktree_workspace_to_remove( folder_paths, thread_remote_connection, thread_id, None, &roots_to_archive, cx, ) }); // Also find workspaces for root plans that aren't covered by // workspace_to_remove. For workspaces that exclusively contain // worktrees being archived, remove the whole workspace. For // "mixed" workspaces (containing both archived and non-archived // worktrees), close only the editor items referencing the // archived worktrees so their Entity handles are // dropped without destroying the user's workspace layout. let mut workspaces_to_remove: Vec> = workspace_to_remove.into_iter().collect(); let close_item_tasks = self.close_items_for_archived_worktrees( &roots_to_archive, &mut workspaces_to_remove, window, cx, ); if !workspaces_to_remove.is_empty() { let multi_workspace = self.multi_workspace.upgrade().unwrap(); let session_id = session_id.clone(); let (fallback_paths, project_group_key) = neighbor .as_ref() .map(|neighbor| neighbor.project_location(cx)) .unwrap_or_else(|| { workspaces_to_remove .first() .map(|workspace| { let key = workspace.read(cx).project_group_key(cx); (key.path_list().clone(), key) }) .unwrap_or_default() }); let excluded = workspaces_to_remove.clone(); let remove_task = multi_workspace.update(cx, |mw, cx| { mw.remove( workspaces_to_remove, move |this, window, cx| { let active_workspace = this.workspace().clone(); this.find_or_create_workspace( fallback_paths, project_group_key.host(), Some(project_group_key), |options, window, cx| { connect_remote(active_workspace, options, window, cx) }, &excluded, None, OpenMode::Activate, window, cx, ) }, window, cx, ) }); let thread_folder_paths = thread_folder_paths.clone(); let thread_remote_connection = metadata .as_ref() .and_then(|metadata| metadata.remote_connection.clone()); cx.spawn_in(window, async move |this, cx| { if !remove_task.await? { return anyhow::Ok(()); } for task in close_item_tasks { let result: anyhow::Result<()> = task.await; result.log_err(); } this.update_in(cx, |this, window, cx| { if let Some(thread_folder_paths) = thread_folder_paths.as_ref() { this.delete_empty_drafts_for_archive_paths( thread_folder_paths, thread_remote_connection.as_ref(), cx, ); } let in_flight = thread_id.and_then(|tid| { this.start_archive_worktree_task(tid, roots_to_archive, cx) }); this.archive_and_activate( &session_id, thread_id, neighbor.as_ref(), thread_folder_paths.as_ref(), thread_remote_connection.as_ref(), in_flight, window, cx, ); })?; anyhow::Ok(()) }) .detach_and_log_err(cx); } else if !close_item_tasks.is_empty() { let session_id = session_id.clone(); let thread_folder_paths = thread_folder_paths.clone(); let thread_remote_connection = metadata .as_ref() .and_then(|metadata| metadata.remote_connection.clone()); cx.spawn_in(window, async move |this, cx| { for task in close_item_tasks { let result: anyhow::Result<()> = task.await; result.log_err(); } this.update_in(cx, |this, window, cx| { let in_flight = thread_id.and_then(|tid| { this.start_archive_worktree_task(tid, roots_to_archive, cx) }); this.archive_and_activate( &session_id, thread_id, neighbor.as_ref(), thread_folder_paths.as_ref(), thread_remote_connection.as_ref(), in_flight, window, cx, ); })?; anyhow::Ok(()) }) .detach_and_log_err(cx); } else { let in_flight = thread_id .and_then(|tid| self.start_archive_worktree_task(tid, roots_to_archive, cx)); self.archive_and_activate( session_id, thread_id, neighbor.as_ref(), thread_folder_paths.as_ref(), metadata .as_ref() .and_then(|metadata| metadata.remote_connection.as_ref()), in_flight, window, cx, ); } } /// Archive a thread and activate the nearest neighbor or a draft. /// /// IMPORTANT: when activating a neighbor or creating a fallback draft, /// this method also activates the target workspace in the MultiWorkspace. /// This is critical because `rebuild_contents` derives the active /// workspace from `mw.workspace()`. If the linked worktree workspace is /// still active after archiving its last thread, `rebuild_contents` sees /// the threadless linked worktree as active and emits a spurious /// "+ New Thread" entry with the worktree chip — keeping the worktree /// alive and preventing disk cleanup. /// /// When `in_flight_archive` is present, it is the background task that /// persists the linked worktree's git state and deletes it from disk. /// We attach it to the metadata store at the same time we mark the thread /// archived so failures can automatically unarchive the thread and user- /// initiated unarchive can cancel the task. fn archive_and_activate( &mut self, _session_id: &acp::SessionId, thread_id: Option, neighbor: Option<&ActivatableEntry>, thread_folder_paths: Option<&PathList>, thread_remote_connection: Option<&RemoteConnectionOptions>, in_flight_archive: Option<(Task<()>, async_channel::Sender<()>)>, window: &mut Window, cx: &mut Context, ) { if let Some(thread_id) = thread_id { ThreadMetadataStore::global(cx).update(cx, |store, cx| { store.archive(thread_id, in_flight_archive, cx); }); } let is_active = self .active_entry .as_ref() .is_some_and(|entry| thread_id.is_some_and(|tid| entry.is_active_thread(&tid))); if is_active { self.active_entry = None; } if !is_active { // The user is looking at a different thread/draft. Clear the // archived thread from its workspace's panel so that switching // to that workspace later doesn't show a stale thread. if let Some(folder_paths) = thread_folder_paths { if let Some(workspace) = self.multi_workspace.upgrade().and_then(|mw| { mw.read(cx) .workspace_for_paths(folder_paths, thread_remote_connection, cx) }) { if let Some(panel) = workspace.read(cx).panel::(cx) { let panel_shows_archived = panel .read(cx) .active_conversation_view() .map(|cv| cv.read(cx).parent_id()) .is_some_and(|live_thread_id| { thread_id.is_some_and(|id| id == live_thread_id) }); if panel_shows_archived { panel.update(cx, |panel, cx| { panel.clear_base_view(window, cx); }); } } } } return; } if neighbor.is_some_and(|neighbor| self.activate_entry(neighbor, window, cx)) { return; } // No neighbor or its workspace isn't open — just clear the // panel so the group is left empty. if let Some(folder_paths) = thread_folder_paths { let workspace = self.multi_workspace.upgrade().and_then(|mw| { mw.read(cx) .workspace_for_paths(folder_paths, thread_remote_connection, cx) }); if let Some(workspace) = workspace { if let Some(panel) = workspace.read(cx).panel::(cx) { panel.update(cx, |panel, cx| { panel.clear_base_view(window, cx); }); } } } } fn start_archive_worktree_task( &self, thread_id: ThreadId, roots: Vec, cx: &mut Context, ) -> Option<(Task<()>, async_channel::Sender<()>)> { if roots.is_empty() { return None; } self.delete_empty_drafts_for_archive_roots(&roots, cx); let (cancel_tx, cancel_rx) = async_channel::bounded::<()>(1); let task = cx.spawn(async move |_this, cx| { match Self::archive_worktree_roots(roots, cancel_rx, cx).await { Ok(ArchiveWorktreeOutcome::Success) => { cx.update(|cx| { ThreadMetadataStore::global(cx).update(cx, |store, _cx| { store.cleanup_completed_archive(thread_id); }); }); } Ok(ArchiveWorktreeOutcome::Cancelled) => {} Err(error) => { log::error!("Failed to archive worktree: {error:#}"); cx.update(|cx| { ThreadMetadataStore::global(cx).update(cx, |store, cx| { store.unarchive(thread_id, cx); }); }); } } }); Some((task, cancel_tx)) } fn start_detached_archive_worktree_task( &self, roots: Vec, cx: &mut Context, ) { if roots.is_empty() { return; } self.delete_empty_drafts_for_archive_roots(&roots, cx); let (cancel_tx, cancel_rx) = async_channel::bounded::<()>(1); cx.spawn(async move |_this, cx| { let outcome = Self::archive_worktree_roots(roots, cancel_rx, cx).await; drop(cancel_tx); match outcome { Ok(ArchiveWorktreeOutcome::Success | ArchiveWorktreeOutcome::Cancelled) => {} Err(error) => { log::error!("Failed to archive worktree after closing sidebar item: {error:#}"); } } }) .detach(); } async fn archive_worktree_roots( roots: Vec, cancel_rx: async_channel::Receiver<()>, cx: &mut gpui::AsyncApp, ) -> anyhow::Result { let mut completed_persists: Vec<(i64, thread_worktree_archive::RootPlan)> = Vec::new(); for root in &roots { if cancel_rx.is_closed() { for &(id, ref completed_root) in completed_persists.iter().rev() { thread_worktree_archive::rollback_persist(id, completed_root, cx).await; } return Ok(ArchiveWorktreeOutcome::Cancelled); } match thread_worktree_archive::persist_worktree_state(root, cx).await { Ok(id) => { completed_persists.push((id, root.clone())); } Err(error) => { for &(id, ref completed_root) in completed_persists.iter().rev() { thread_worktree_archive::rollback_persist(id, completed_root, cx).await; } return Err(error); } } if cancel_rx.is_closed() { for &(id, ref completed_root) in completed_persists.iter().rev() { thread_worktree_archive::rollback_persist(id, completed_root, cx).await; } return Ok(ArchiveWorktreeOutcome::Cancelled); } if let Err(error) = thread_worktree_archive::remove_root(root.clone(), cx).await { if let Some(&(id, ref completed_root)) = completed_persists.last() { if completed_root.root_path == root.root_path { thread_worktree_archive::rollback_persist(id, completed_root, cx).await; completed_persists.pop(); } } for &(id, ref completed_root) in completed_persists.iter().rev() { thread_worktree_archive::rollback_persist(id, completed_root, cx).await; } return Err(error); } } Ok(ArchiveWorktreeOutcome::Success) } fn activate_workspace( &self, workspace: &Entity, window: &mut Window, cx: &mut Context, ) { if let Some(multi_workspace) = self.multi_workspace.upgrade() { multi_workspace.update(cx, |mw, cx| { mw.activate(workspace.clone(), None, window, cx); }); } } fn archive_selected_thread( &mut self, _: &ArchiveSelectedThread, window: &mut Window, cx: &mut Context, ) { let Some(ix) = self.selection else { return; }; match self.contents.entries.get(ix) { Some(ListEntry::Thread(thread)) => { match thread.status { AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation => { return; } AgentThreadStatus::Completed | AgentThreadStatus::Error => {} } if thread.draft.is_some() { let workspace = thread.workspace.clone(); let draft_id = thread.metadata.thread_id; self.remove_draft(draft_id, &workspace, window, cx); } else if let Some(session_id) = thread.metadata.session_id.clone() { self.archive_thread(&session_id, window, cx); } } Some(ListEntry::Terminal(terminal)) => { let metadata = terminal.metadata.clone(); let workspace = terminal.workspace.clone(); self.close_terminal(&metadata, &workspace, window, cx); } _ => {} } } fn rename_selected_thread( &mut self, _: &RenameSelectedThread, window: &mut Window, cx: &mut Context, ) { let Some(ix) = self.selection else { return; }; let Some(ListEntry::Thread(thread)) = self.contents.entries.get(ix) else { return; }; let thread_id = thread.metadata.thread_id; let title = thread.metadata.display_title(); self.start_renaming_thread(ix, thread_id, title, window, cx); } fn record_thread_access(&mut self, id: &ThreadId) { self.thread_last_accessed.insert(*id, Utc::now()); } fn record_terminal_access(&mut self, id: TerminalId) { self.terminal_last_accessed.insert(id, Utc::now()); } fn record_thread_interacted(&mut self, thread_id: &agent_ui::ThreadId, cx: &mut App) { let store = ThreadMetadataStore::global(cx); store.update(cx, |store, cx| { store.update_interacted_at(thread_id, Utc::now(), cx); }) } fn thread_display_time(metadata: &ThreadMetadata) -> DateTime { metadata.interacted_at.unwrap_or(metadata.updated_at) } fn push_entries_by_display_time( entries: &mut Vec, terminals: Vec, threads: Vec>, current_session_ids: &mut HashSet, current_thread_ids: &mut HashSet, ) { fn display_time(entry: &ListEntry) -> DateTime { match entry { ListEntry::Thread(thread) if thread.draft == Some(DraftKind::Empty) => { DateTime::::MAX_UTC } ListEntry::Thread(thread) => Sidebar::thread_display_time(&thread.metadata), ListEntry::Terminal(terminal) => terminal.metadata.created_at, ListEntry::ProjectHeader { .. } => unreachable!(), } } let row_entries = terminals .into_iter() .map(ListEntry::Terminal) .chain(threads.into_iter().map(ListEntry::Thread)) .sorted_by_key(|right| std::cmp::Reverse(display_time(right))); for entry in row_entries { if let ListEntry::Thread(thread) = &entry { if let Some(session_id) = &thread.metadata.session_id { current_session_ids.insert(session_id.clone()); } current_thread_ids.insert(thread.metadata.thread_id); } entries.push(entry); } } /// The sort order used by the ctrl-tab switcher fn switcher_entry_cmp( &self, left: &ThreadSwitcherEntry, right: &ThreadSwitcherEntry, ) -> Ordering { let sort_time = |entry: &ThreadSwitcherEntry| match entry { ThreadSwitcherEntry::Thread(entry) => self .thread_last_accessed .get(&entry.metadata.thread_id) .copied() .or(entry.metadata.interacted_at) .unwrap_or(entry.metadata.updated_at), ThreadSwitcherEntry::Terminal(entry) => self .terminal_last_accessed .get(&entry.metadata.terminal_id) .copied() .unwrap_or(entry.metadata.created_at), }; // .reverse() = most recent first sort_time(left).cmp(&sort_time(right)).reverse() } fn mru_entries_for_switcher(&self, cx: &App) -> Vec { let mut current_header_label: Option = None; let mut current_header_key: Option = None; let mut entries: Vec = self .contents .entries .iter() .filter_map(|entry| match entry { ListEntry::ProjectHeader { label, key, .. } => { current_header_label = Some(label.clone()); current_header_key = Some(key.clone()); None } ListEntry::Thread(thread) => { if thread.draft == Some(DraftKind::Empty) { return None; } let workspace = match &thread.workspace { ThreadEntryWorkspace::Open(workspace) => Some(workspace.clone()), ThreadEntryWorkspace::Closed { .. } => { current_header_key.as_ref().and_then(|key| { self.multi_workspace.upgrade().and_then(|mw| { mw.read(cx).workspace_for_paths( key.path_list(), key.host().as_ref(), cx, ) }) }) } }?; let notified = self.contents.is_thread_notified(&thread.metadata.thread_id); let timestamp: SharedString = format_history_entry_timestamp(Self::thread_display_time(&thread.metadata)) .into(); Some(ThreadSwitcherEntry::Thread(ThreadSwitcherThreadEntry { title: thread.metadata.display_title(), icon: thread.icon, icon_from_external_svg: thread.icon_from_external_svg.clone(), status: thread.status, metadata: thread.metadata.clone(), workspace, project_name: current_header_label.clone(), worktrees: thread .worktrees .iter() .cloned() .map(|mut wt| { wt.highlight_positions = Vec::new(); wt }) .collect(), diff_stats: thread.diff_stats, is_draft: thread.draft.is_some(), is_title_generating: thread.is_title_generating, notified, timestamp, })) } ListEntry::Terminal(terminal) => { let timestamp: SharedString = format_history_entry_timestamp(terminal.metadata.created_at).into(); Some(ThreadSwitcherEntry::Terminal(ThreadSwitcherTerminalEntry { metadata: terminal.metadata.clone(), workspace: terminal.workspace.clone(), project_name: current_header_label.clone(), worktrees: terminal .worktrees .iter() .cloned() .map(|mut wt| { wt.highlight_positions = Vec::new(); wt }) .collect(), notified: self .contents .is_terminal_notified(terminal.metadata.terminal_id), timestamp, })) } }) .collect(); entries.sort_by(|a, b| self.switcher_entry_cmp(a, b)); entries } fn dismiss_thread_switcher(&mut self, cx: &mut Context) { self.thread_switcher = None; self._thread_switcher_subscriptions.clear(); if let Some(mw) = self.multi_workspace.upgrade() { mw.update(cx, |mw, cx| { mw.set_sidebar_overlay(None, cx); }); } } fn on_toggle_thread_switcher( &mut self, action: &ToggleThreadSwitcher, window: &mut Window, cx: &mut Context, ) { self.toggle_thread_switcher_impl(action.select_last, window, cx); } fn preview_switcher_selection( &mut self, selection: &ThreadSwitcherSelection, window: &mut Window, cx: &mut Context, ) { match selection { ThreadSwitcherSelection::Thread { metadata, workspace, } => { if let Some(multi_workspace) = self.multi_workspace.upgrade() { multi_workspace.update(cx, |multi_workspace, cx| { multi_workspace.activate(workspace.clone(), None, window, cx); }); } self.active_entry = Some(ActiveEntry::Thread { thread_id: metadata.thread_id, session_id: metadata.session_id.clone(), workspace: workspace.clone(), }); self.update_entries(cx); Self::load_agent_thread_in_workspace(workspace, metadata, false, window, cx); } ThreadSwitcherSelection::Terminal { metadata, workspace, } => { if let ThreadEntryWorkspace::Open(workspace) = workspace { if let Some(multi_workspace) = self.multi_workspace.upgrade() { multi_workspace.update(cx, |multi_workspace, cx| { multi_workspace.activate(workspace.clone(), None, window, cx); }); } self.active_entry = Some(ActiveEntry::Terminal { terminal_id: metadata.terminal_id, workspace: workspace.clone(), }); self.update_entries(cx); Self::load_agent_terminal_in_workspace(workspace, metadata, false, window, cx); } } } } fn confirm_switcher_selection( &mut self, selection: &ThreadSwitcherSelection, window: &mut Window, cx: &mut Context, ) { match selection { ThreadSwitcherSelection::Thread { metadata, workspace, } => { if let Some(multi_workspace) = self.multi_workspace.upgrade() { multi_workspace.update(cx, |multi_workspace, cx| { multi_workspace.activate(workspace.clone(), None, window, cx); multi_workspace.retain_active_workspace(cx); }); } self.record_thread_access(&metadata.thread_id); self.active_entry = Some(ActiveEntry::Thread { thread_id: metadata.thread_id, session_id: metadata.session_id.clone(), workspace: workspace.clone(), }); self.update_entries(cx); self.dismiss_thread_switcher(cx); Self::load_agent_thread_in_workspace(workspace, metadata, true, window, cx); } ThreadSwitcherSelection::Terminal { metadata, workspace, } => { self.dismiss_thread_switcher(cx); self.activate_terminal_entry(metadata.clone(), workspace.clone(), true, window, cx); } } } fn toggle_thread_switcher_impl( &mut self, select_last: bool, window: &mut Window, cx: &mut Context, ) { if let Some(thread_switcher) = &self.thread_switcher { thread_switcher.update(cx, |switcher, cx| { if select_last { switcher.select_last(cx); } else { switcher.cycle_selection(cx); } }); return; } let entries = self.mru_entries_for_switcher(cx); if entries.len() < 2 { return; } let weak_multi_workspace = self.multi_workspace.clone(); // Snapshot the active entry (thread or terminal) so dismissal can // restore it. let original_active_entry = self.active_entry.clone(); let original_metadata = match &original_active_entry { Some(ActiveEntry::Thread { thread_id, .. }) => { entries.iter().find_map(|entry| match entry { ThreadSwitcherEntry::Thread(entry) if *thread_id == entry.metadata.thread_id => { Some(entry.metadata.clone()) } _ => None, }) } _ => None, }; let original_workspace = self .multi_workspace .upgrade() .map(|mw| mw.read(cx).workspace().clone()); let thread_switcher = cx.new(|cx| ThreadSwitcher::new(entries, select_last, window, cx)); let mut subscriptions = Vec::new(); subscriptions.push(cx.subscribe_in(&thread_switcher, window, { let thread_switcher = thread_switcher.clone(); move |this, _emitter, event: &ThreadSwitcherEvent, window, cx| match event { ThreadSwitcherEvent::Preview(selection) => { this.preview_switcher_selection(selection, window, cx); let focus = thread_switcher.focus_handle(cx); window.focus(&focus, cx); } ThreadSwitcherEvent::Confirmed(selection) => { this.confirm_switcher_selection(selection, window, cx); } ThreadSwitcherEvent::Dismissed => { if let Some(mw) = weak_multi_workspace.upgrade() { if let Some(original_ws) = &original_workspace { mw.update(cx, |mw, cx| { mw.activate(original_ws.clone(), None, window, cx); }); } } match &original_active_entry { Some(ActiveEntry::Thread { .. }) => { if let (Some(metadata), Some(original_ws)) = (&original_metadata, &original_workspace) { this.active_entry = Some(ActiveEntry::Thread { thread_id: metadata.thread_id, session_id: metadata.session_id.clone(), workspace: original_ws.clone(), }); this.update_entries(cx); Self::load_agent_thread_in_workspace( original_ws, metadata, false, window, cx, ); } } Some(ActiveEntry::Terminal { terminal_id, workspace, }) => { let terminal_id = *terminal_id; let workspace = workspace.clone(); this.active_entry = Some(ActiveEntry::Terminal { terminal_id, workspace: workspace.clone(), }); this.update_entries(cx); workspace.update(cx, |workspace, cx| { if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { panel.activate_terminal(terminal_id, false, window, cx); }); } }); } None => {} } this.dismiss_thread_switcher(cx); } } })); subscriptions.push(cx.subscribe_in( &thread_switcher, window, |this, _emitter, _event: &gpui::DismissEvent, _window, cx| { this.dismiss_thread_switcher(cx); }, )); let focus = thread_switcher.focus_handle(cx); let overlay_view = gpui::AnyView::from(thread_switcher.clone()); // Replay the initial preview that was emitted during construction // before subscriptions were wired up. let initial_preview = thread_switcher .read(cx) .selected_entry() .map(ThreadSwitcherEntry::selection); self.thread_switcher = Some(thread_switcher); self._thread_switcher_subscriptions = subscriptions; if let Some(mw) = self.multi_workspace.upgrade() { mw.update(cx, |mw, cx| { mw.set_sidebar_overlay(Some(overlay_view), cx); }); } if let Some(selection) = initial_preview { self.preview_switcher_selection(&selection, window, cx); } window.focus(&focus, cx); } fn render_thread( &self, ix: usize, thread: &ThreadEntry, is_active: bool, is_focused: bool, cx: &mut Context, ) -> AnyElement { let has_notification = self.contents.is_thread_notified(&thread.metadata.thread_id); let title: SharedString = thread.metadata.display_title(); let metadata = thread.metadata.clone(); let thread_workspace = thread.workspace.clone(); let is_hovered = self.hovered_thread_index == Some(ix); let is_selected = is_active; let is_draft = thread.draft.is_some(); let is_empty_draft = thread.draft == Some(DraftKind::Empty); let is_running = matches!( thread.status, AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation ); let is_renaming = self.renaming_thread_id == Some(thread.metadata.thread_id); let thread_id_for_actions = thread.metadata.thread_id; let session_id_for_delete = thread.metadata.session_id.clone(); let focus_handle = self.focus_handle.clone(); let title_editor = self.thread_rename_editor.clone(); let id = SharedString::from(format!("thread-entry-{}", ix)); let color = cx.theme().colors(); let sidebar_bg = color .title_bar_background .blend(color.panel_background.opacity(0.25)); let timestamp: SharedString = if is_empty_draft { SharedString::default() } else { format_history_entry_timestamp(Self::thread_display_time(&thread.metadata)).into() }; let is_remote = thread.workspace.is_remote(cx); let worktrees = apply_worktree_label_mode( thread.worktrees.clone(), cx.flag_value::(), ); let (icon, icon_svg) = if is_draft { (IconName::Circle, None) } else { (thread.icon, thread.icon_from_external_svg.clone()) }; let title_generating = thread.is_title_generating || self .regenerating_titles .contains(&thread.metadata.thread_id); let thread_item = ThreadItem::new(id, title.clone()) .base_bg(sidebar_bg) .icon(icon) .when(is_draft, |this| { this.icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.2))) }) .status(thread.status) .is_remote(is_remote) .when_some(icon_svg, |this, svg| { this.custom_icon_from_external_svg(svg) }) .worktrees(worktrees) .timestamp(timestamp) .highlight_positions(thread.highlight_positions.to_vec()) .title_generating(title_generating) .notified(has_notification) .when(thread.diff_stats.lines_added > 0, |this| { this.added(thread.diff_stats.lines_added as usize) }) .when(thread.diff_stats.lines_removed > 0, |this| { this.removed(thread.diff_stats.lines_removed as usize) }) .selected(is_selected) .focused(is_focused) .hovered(is_hovered) .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| { if *is_hovered { this.hovered_thread_index = Some(ix); } else if this.hovered_thread_index == Some(ix) { this.hovered_thread_index = None; } cx.notify(); })) .when(is_renaming, |this| { this.is_truncated(false).title_slot( div() .h_full() .min_w_0() .flex_1() .capture_action(cx.listener( |this, _: &editor::actions::Newline, window, cx| { this.finish_thread_rename(window, cx); }, )) .on_action(cx.listener(|this, _: &Confirm, window, cx| { this.finish_thread_rename(window, cx); })) .on_action( cx.listener(|this, _: &editor::actions::Cancel, window, cx| { this.finish_thread_rename(window, cx); }), ) .child(title_editor), ) }) .when(is_hovered && !is_renaming, |this| { let rename_button = IconButton::new(("rename-thread", ix), IconName::Pencil) .icon_size(IconSize::Small) .tooltip({ let focus_handle = focus_handle.clone(); move |_window, cx| { Tooltip::for_action_in( "Rename Thread", &RenameSelectedThread, &focus_handle, cx, ) } }) .on_click({ let title = title.clone(); cx.listener(move |this, _, window, cx| { this.start_renaming_thread( ix, thread_id_for_actions, title.clone(), window, cx, ); }) }); let contextual_action: Option = if is_running { Some( IconButton::new("stop-thread", IconName::Stop) .icon_size(IconSize::Small) .icon_color(Color::Error) .style(ButtonStyle::Tinted(TintColor::Error)) .tooltip(Tooltip::text("Stop Generation")) .on_click(cx.listener(move |this, _, _window, cx| { this.stop_thread(&thread_id_for_actions, cx); })) .into_any_element(), ) } else { match thread.draft { Some(DraftKind::Empty) => None, Some(DraftKind::WithContent) => Some( IconButton::new("discard_thread", IconName::Close) .icon_size(IconSize::Small) .tooltip(Tooltip::text("Discard Draft")) .on_click({ let thread_workspace = thread_workspace.clone(); cx.listener(move |this, _, window, cx| { this.remove_draft( thread_id_for_actions, &thread_workspace, window, cx, ); }) }) .into_any_element(), ), None => Some( IconButton::new("archive-thread", IconName::Archive) .icon_size(IconSize::Small) .tooltip({ let focus_handle = focus_handle.clone(); move |_window, cx| { Tooltip::for_action_in( "Archive Thread", &ArchiveSelectedThread, &focus_handle, cx, ) } }) .on_click({ let session_id = session_id_for_delete.clone(); cx.listener(move |this, _, window, cx| { if let Some(ref session_id) = session_id { this.archive_thread(session_id, window, cx); } }) }) .into_any_element(), ), } }; this.action_slot( h_flex() .gap_0p5() .child(rename_button) .when_some(contextual_action, |this, action| this.child(action)), ) }) .on_click({ let thread_workspace = thread_workspace.clone(); cx.listener(move |this, _, window, cx| { this.selection = None; match &thread_workspace { ThreadEntryWorkspace::Open(workspace) => { this.activate_thread(metadata.clone(), workspace, false, window, cx); } ThreadEntryWorkspace::Closed { folder_paths, project_group_key, } => { this.open_workspace_and_activate_thread( metadata.clone(), folder_paths.clone(), project_group_key, window, cx, ); } } }) }); if is_draft || thread.metadata.session_id.is_none() { return thread_item.into_any_element(); } let Some(session_id) = thread.metadata.session_id.clone() else { return thread_item.into_any_element(); }; let context_menu_id = SharedString::from(format!("thread-context-menu-{}", ix)); let sidebar = cx.weak_entity(); let active_workspace = self.active_workspace(cx); let thread_workspace = match &thread_workspace { ThreadEntryWorkspace::Open(workspace) => Some(workspace.clone()), ThreadEntryWorkspace::Closed { .. } => None, }; let is_zed_thread = thread.metadata.agent_id.as_ref() == ZED_AGENT_ID.as_ref(); let can_open_as_markdown = thread.is_live || is_zed_thread; let folder_paths = thread.metadata.folder_paths().clone(); right_click_menu(context_menu_id) .trigger(move |_, _, _| thread_item) .menu({ let thread_id = thread.metadata.thread_id; let markdown_title = Some(thread.metadata.display_title()); let rename_title = title; move |_window, cx| { let session_id = session_id.clone(); let sidebar = sidebar.clone(); let active_workspace = active_workspace.clone(); let thread_workspace = thread_workspace.clone(); let markdown_title = markdown_title.clone(); let rename_title = rename_title.clone(); let folder_paths = folder_paths.clone(); ContextMenu::build(_window, cx, move |mut menu, _window, _cx| { menu = menu.entry("Rename Title", None, { let sidebar = sidebar.clone(); let rename_title = rename_title.clone(); move |window, cx| { sidebar .update(cx, |sidebar, cx| { sidebar.start_renaming_thread( ix, thread_id, rename_title.clone(), window, cx, ); }) .ok(); } }); if is_zed_thread { menu = menu.entry("Regenerate Thread Title", None, { let session_id = session_id.clone(); let sidebar = sidebar.clone(); let thread_workspace = thread_workspace.clone(); let folder_paths = folder_paths.clone(); move |_window, cx| { sidebar .update(cx, |sidebar, cx| { sidebar.regenerate_thread_title( &session_id, thread_id, folder_paths.clone(), thread_workspace.clone(), cx, ); }) .ok(); } }); } if can_open_as_markdown { menu = menu.entry("Open Thread as Markdown", None, { let session_id = session_id.clone(); let markdown_title = markdown_title.clone(); let thread_workspace = thread_workspace.clone(); move |window, cx| { if let Some(thread_workspace) = thread_workspace.as_ref() && let Some(panel) = thread_workspace.read(cx).panel::(cx) { let opened = panel.update(cx, |panel, cx| { panel.open_thread_as_markdown( thread_id, thread_workspace.clone(), window, cx, ) }); if opened { return; } } if is_zed_thread && let Some(active_workspace) = &active_workspace { Self::open_closed_native_thread_as_markdown( &session_id, markdown_title.clone(), active_workspace, window, cx, ); } } }); } menu.separator().entry("Archive Thread", None, { let session_id = session_id.clone(); move |window, cx| { sidebar .update(cx, |sidebar, cx| { sidebar.archive_thread(&session_id, window, cx); }) .ok(); } }) }) } }) .into_any_element() } fn render_terminal( &self, ix: usize, terminal: &TerminalEntry, is_active: bool, is_focused: bool, cx: &mut Context, ) -> AnyElement { let id = ElementId::from(format!("terminal-{}", terminal.metadata.terminal_id)); let timestamp = format_history_entry_timestamp(terminal.metadata.created_at); let is_hovered = self.hovered_thread_index == Some(ix); let color = cx.theme().colors(); let sidebar_bg = color .title_bar_background .blend(color.panel_background.opacity(0.25)); let metadata = terminal.metadata.clone(); let workspace = terminal.workspace.clone(); let focus_handle = self.focus_handle.clone(); let worktrees = apply_worktree_label_mode( terminal.worktrees.clone(), cx.flag_value::(), ); let is_remote = terminal.workspace.is_remote(cx); let display_title = terminal.metadata.display_title(); let (icon_char, title, highlight_positions) = match split_leading_icon_char(&display_title, &terminal.highlight_positions) { Some((icon_char, title, positions)) => (Some(icon_char), title, positions), None => (None, display_title, terminal.highlight_positions.clone()), }; ThreadItem::new(id, title) .base_bg(sidebar_bg) .icon(IconName::Terminal) .when_some(icon_char, |this, icon_char| this.icon_char(icon_char)) .is_remote(is_remote) .worktrees(worktrees) .timestamp(timestamp) .notified(terminal.has_notification) .highlight_positions(highlight_positions) .selected(is_active) .focused(is_focused) .hovered(is_hovered) .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| { if *is_hovered { this.hovered_thread_index = Some(ix); } else if this.hovered_thread_index == Some(ix) { this.hovered_thread_index = None; } cx.notify(); })) .when(is_hovered, |this| { this.action_slot( IconButton::new("close-terminal", IconName::Close) .icon_size(IconSize::Small) .icon_color(Color::Muted) .tooltip({ let focus_handle = focus_handle.clone(); move |_window, cx| { Tooltip::for_action_in( "Close Terminal", &ArchiveSelectedThread, &focus_handle, cx, ) } }) .on_click(cx.listener(move |this, _, window, cx| { this.close_terminal(&metadata, &workspace, window, cx); })), ) }) .on_click(cx.listener({ let metadata = terminal.metadata.clone(); let workspace = terminal.workspace.clone(); move |this, _, window, cx| { this.activate_terminal_entry( metadata.clone(), workspace.clone(), false, window, cx, ); } })) .into_any_element() } fn render_filter_input(&self, cx: &mut Context) -> impl IntoElement { div() .min_w_0() .flex_1() .capture_action( cx.listener(|this, _: &editor::actions::Newline, window, cx| { this.editor_confirm(window, cx); }), ) .child(self.filter_editor.clone()) } fn render_recent_projects_button(&self, cx: &mut Context) -> impl IntoElement { let multi_workspace = self.multi_workspace.upgrade(); let workspace = multi_workspace .as_ref() .map(|mw| mw.read(cx).workspace().downgrade()); let focus_handle = workspace .as_ref() .and_then(|ws| ws.upgrade()) .map(|w| w.read(cx).focus_handle(cx)) .unwrap_or_else(|| cx.focus_handle()); let window_project_groups: Vec = multi_workspace .as_ref() .map(|mw| mw.read(cx).project_group_keys()) .unwrap_or_default(); let popover_handle = self.recent_projects_popover_handle.clone(); PopoverMenu::new("sidebar-recent-projects-menu") .with_handle(popover_handle) .menu(move |window, cx| { workspace.as_ref().map(|ws| { SidebarRecentProjects::popover( ws.clone(), window_project_groups.clone(), focus_handle.clone(), window, cx, ) }) }) .trigger_with_tooltip( IconButton::new("open-project", IconName::FolderAdd) .icon_size(IconSize::Small) .selected_style(ButtonStyle::Tinted(TintColor::Accent)), |_window, cx| Tooltip::for_action("Add Project", &OpenRecent::default(), cx), ) .offset(gpui::Point { x: px(-2.0), y: px(-2.0), }) .anchor(gpui::Anchor::BottomRight) } fn new_thread_in_group( &mut self, _: &NewThreadInGroup, window: &mut Window, cx: &mut Context, ) { if let Some(key) = self.selected_group_key() { self.set_group_expanded(&key, true, cx); self.selection = None; if let Some(workspace) = self.workspace_for_group(&key, cx) { self.create_new_entry(&workspace, window, cx); } else { self.open_workspace_and_create_entry( &key, NewEntryTarget::LastCreatedKind, window, cx, ); } } else if let Some(workspace) = self.active_workspace(cx) { self.create_new_entry(&workspace, window, cx); } } fn new_terminal_thread( &mut self, _: &NewTerminalThread, window: &mut Window, cx: &mut Context, ) { cx.stop_propagation(); if let Some(key) = self.selected_group_key() { self.set_group_expanded(&key, true, cx); self.selection = None; if let Some(workspace) = self.workspace_for_group(&key, cx) { self.create_new_terminal(&workspace, window, cx); } else { self.open_workspace_and_create_entry(&key, NewEntryTarget::Terminal, window, cx); } } else if let Some(workspace) = self.active_workspace(cx) { self.create_new_terminal(&workspace, window, cx); } } /// Closed linked-worktree drafts need an open workspace so archive root /// planning can inspect repositories before deleting the worktree. fn open_workspace_and_remove_draft( &mut self, draft_id: ThreadId, folder_paths: PathList, project_group_key: ProjectGroupKey, window: &mut Window, cx: &mut Context, ) { let Some((open_task, modal_workspace)) = self.open_workspace_for_archive(folder_paths, project_group_key, window, cx) else { return; }; cx.spawn_in(window, async move |this, cx| { let result = open_task.await; remote_connection::dismiss_connection_modal(&modal_workspace, cx); let workspace = result?; Self::wait_for_archive_workspace_metadata(&workspace, cx).await; this.update_in(cx, |this, window, cx| { let workspace = ThreadEntryWorkspace::Open(workspace); this.remove_draft(draft_id, &workspace, window, cx); })?; anyhow::Ok(()) }) .detach_and_log_err(cx); } fn remove_draft( &mut self, draft_id: ThreadId, workspace: &ThreadEntryWorkspace, window: &mut Window, cx: &mut Context, ) { let metadata = ThreadMetadataStore::global(cx) .read(cx) .entry(draft_id) .cloned(); if let ThreadEntryWorkspace::Closed { folder_paths, project_group_key, } = workspace && self.should_load_closed_workspace_for_archive( folder_paths, project_group_key, metadata .as_ref() .and_then(|metadata| metadata.remote_connection.as_ref()), Some(draft_id), None, cx, ) { self.open_workspace_and_remove_draft( draft_id, folder_paths.clone(), project_group_key.clone(), window, cx, ); return; } let draft_folder_paths = metadata .as_ref() .map(|metadata| metadata.folder_paths().clone()) .or_else(|| match workspace { ThreadEntryWorkspace::Open(workspace) => { Some(PathList::new(&workspace.read(cx).root_paths(cx))) } ThreadEntryWorkspace::Closed { folder_paths, .. } => Some(folder_paths.clone()), }); let draft_remote_connection = metadata .as_ref() .and_then(|metadata| metadata.remote_connection.clone()); let roots_to_archive = metadata .as_ref() .map(|metadata| { self.roots_to_archive_for_paths( metadata.folder_paths(), metadata.remote_connection.as_ref(), Some(draft_id), None, cx, ) }) .unwrap_or_default(); let was_active = self .active_entry .as_ref() .is_some_and(|entry| entry.is_active_thread(&draft_id)); let neighbor = self .contents .entries .iter() .position(|entry| { matches!( entry, ListEntry::Thread(thread) if thread.metadata.thread_id == draft_id ) }) .and_then(|position| self.neighboring_activatable_entry(position)); let workspace_to_remove = draft_folder_paths.as_ref().and_then(|folder_paths| { self.linked_worktree_workspace_to_remove( folder_paths, draft_remote_connection.as_ref(), Some(draft_id), None, &roots_to_archive, cx, ) }); let mut workspaces_to_remove: Vec> = workspace_to_remove.into_iter().collect(); let close_item_tasks = self.close_items_for_archived_worktrees( &roots_to_archive, &mut workspaces_to_remove, window, cx, ); if !workspaces_to_remove.is_empty() { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; let draft_workspace_removed = matches!( workspace, ThreadEntryWorkspace::Open(workspace) if workspaces_to_remove.contains(workspace) ); let (fallback_paths, project_group_key) = neighbor .as_ref() .map(|neighbor| neighbor.project_location(cx)) .unwrap_or_else(|| { workspaces_to_remove .first() .map(|workspace| { let key = workspace.read(cx).project_group_key(cx); (key.path_list().clone(), key) }) .unwrap_or_default() }); let excluded = workspaces_to_remove.clone(); let remove_task = multi_workspace.update(cx, |multi_workspace, cx| { multi_workspace.remove( workspaces_to_remove, move |this, window, cx| { let active_workspace = this.workspace().clone(); this.find_or_create_workspace( fallback_paths, project_group_key.host(), Some(project_group_key), |options, window, cx| { connect_remote(active_workspace, options, window, cx) }, &excluded, None, OpenMode::Activate, window, cx, ) }, window, cx, ) }); let workspace = workspace.clone(); cx.spawn_in(window, async move |this, cx| { if !remove_task.await? { return anyhow::Ok(()); } for task in close_item_tasks { let result: anyhow::Result<()> = task.await; result.log_err(); } this.update_in(cx, |this, window, cx| { if draft_workspace_removed { if let Some(draft_folder_paths) = draft_folder_paths.as_ref() { this.delete_empty_drafts_for_archive_paths( draft_folder_paths, draft_remote_connection.as_ref(), cx, ); } } this.remove_draft_entry( draft_id, &workspace, was_active, neighbor.as_ref(), !draft_workspace_removed, roots_to_archive, window, cx, ); })?; anyhow::Ok(()) }) .detach_and_log_err(cx); } else if !close_item_tasks.is_empty() { let workspace = workspace.clone(); cx.spawn_in(window, async move |this, cx| { for task in close_item_tasks { let result: anyhow::Result<()> = task.await; result.log_err(); } this.update_in(cx, |this, window, cx| { this.remove_draft_entry( draft_id, &workspace, was_active, neighbor.as_ref(), true, roots_to_archive, window, cx, ); })?; anyhow::Ok(()) }) .detach_and_log_err(cx); } else { self.remove_draft_entry( draft_id, workspace, was_active, neighbor.as_ref(), true, roots_to_archive, window, cx, ); } } fn remove_draft_entry( &mut self, draft_id: ThreadId, workspace: &ThreadEntryWorkspace, was_active: bool, neighbor: Option<&ActivatableEntry>, activate_panel_draft: bool, roots_to_archive: Vec, window: &mut Window, cx: &mut Context, ) { // Fallback to a neighbor thread when the discarded // draft was the active entry. let activate_panel_draft = activate_panel_draft && !(was_active && neighbor.is_some()); let removed_from_panel = if let ThreadEntryWorkspace::Open(workspace) = workspace { workspace.update(cx, |workspace, cx| { if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { if activate_panel_draft { panel.remove_thread(draft_id, window, cx); } else { panel.remove_thread_without_activating_draft(draft_id, window, cx); } }); true } else { false } }) } else { false }; if !removed_from_panel { ThreadMetadataStore::global(cx).update(cx, |store, cx| { store.delete(draft_id, cx); }); } self.start_detached_archive_worktree_task(roots_to_archive, cx); if was_active { self.active_entry = None; if !activate_panel_draft { if neighbor .as_ref() .is_some_and(|neighbor| self.activate_entry(neighbor, window, cx)) { return; } self.sync_active_entry_from_active_workspace(cx); } } self.update_entries(cx); } fn create_new_entry( &mut self, workspace: &Entity, window: &mut Window, cx: &mut Context, ) { if workspace_path_list(workspace, cx).paths().is_empty() { return; } if self.should_create_terminal_for_workspace(workspace, cx) { self.create_new_terminal(workspace, window, cx); } else { self.create_new_thread(workspace, window, cx); } } fn should_create_terminal_for_workspace( &self, workspace: &Entity, cx: &App, ) -> bool { workspace .read(cx) .panel::(cx) .is_some_and(|panel| panel.read(cx).should_create_terminal_for_new_entry(cx)) } fn create_new_thread( &mut self, workspace: &Entity, window: &mut Window, cx: &mut Context, ) { if workspace_path_list(workspace, cx).paths().is_empty() { return; } let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; multi_workspace.update(cx, |multi_workspace, cx| { multi_workspace.activate(workspace.clone(), None, window, cx); }); let draft_id = workspace.update(cx, |workspace, cx| { let panel = workspace.panel::(cx)?; let draft_id = panel.update(cx, |panel, cx| { panel.activate_new_thread(true, AgentThreadSource::Sidebar, window, cx); panel.active_thread_id(cx) }); workspace.focus_panel::(window, cx); draft_id }); if let Some(draft_id) = draft_id { self.active_entry = Some(ActiveEntry::Thread { thread_id: draft_id, session_id: None, workspace: workspace.clone(), }); } } fn create_new_terminal( &mut self, workspace: &Entity, window: &mut Window, cx: &mut Context, ) { if workspace_path_list(workspace, cx).paths().is_empty() { return; } let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; multi_workspace.update(cx, |multi_workspace, cx| { multi_workspace.activate(workspace.clone(), None, window, cx); }); workspace.update(cx, |workspace, cx| { if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { panel.new_terminal(Some(workspace), AgentThreadSource::Sidebar, window, cx); }); } workspace.focus_panel::(window, cx); }); } fn selected_group_key(&self) -> Option { let ix = self.selection?; match self.contents.entries.get(ix) { Some(ListEntry::ProjectHeader { key, .. }) => Some(key.clone()), Some(ListEntry::Thread(_) | ListEntry::Terminal(_)) => { (0..ix) .rev() .find_map(|i| match self.contents.entries.get(i) { Some(ListEntry::ProjectHeader { key, .. }) => Some(key.clone()), _ => None, }) } _ => None, } } fn workspace_for_group(&self, key: &ProjectGroupKey, cx: &App) -> Option> { let mw = self.multi_workspace.upgrade()?; let mw = mw.read(cx); let active = mw.workspace().clone(); let active_key = active.read(cx).project_group_key(cx); if active_key == *key { Some(active) } else { mw.workspace_for_paths(key.path_list(), key.host().as_ref(), cx) } } pub(crate) fn activate_or_open_workspace_for_group( &mut self, key: &ProjectGroupKey, window: &mut Window, cx: &mut Context, ) { let workspace = self .multi_workspace .upgrade() .and_then(|mw| mw.read(cx).last_active_workspace_for_group(key, cx)) .or_else(|| self.workspace_for_group(key, cx)); if let Some(workspace) = workspace { if self.is_active_workspace(&workspace, cx) { return; } self.activate_workspace(&workspace, window, cx); } else { self.open_workspace_for_group(key, window, cx); } self.selection = None; self.active_entry = None; } fn active_project_group_key(&self, cx: &App) -> Option { let multi_workspace = self.multi_workspace.upgrade()?; let multi_workspace = multi_workspace.read(cx); Some(multi_workspace.project_group_key_for_workspace(multi_workspace.workspace(), cx)) } fn active_project_header_position(&self, cx: &App) -> Option { let active_key = self.active_project_group_key(cx)?; self.contents .project_header_indices .iter() .position(|&entry_ix| { matches!( &self.contents.entries[entry_ix], ListEntry::ProjectHeader { key, .. } if *key == active_key ) }) } fn cycle_project_impl(&mut self, forward: bool, window: &mut Window, cx: &mut Context) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; let header_count = self.contents.project_header_indices.len(); if header_count == 0 { return; } let current_pos = self.active_project_header_position(cx); let next_pos = match current_pos { Some(pos) => { if forward { (pos + 1) % header_count } else { (pos + header_count - 1) % header_count } } None => 0, }; let header_entry_ix = self.contents.project_header_indices[next_pos]; let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(header_entry_ix) else { return; }; let key = key.clone(); // Uncollapse the target group so that threads become visible. self.set_group_expanded(&key, true, cx); if let Some(workspace) = self.multi_workspace.upgrade().and_then(|mw| { mw.read(cx) .workspace_for_paths(key.path_list(), key.host().as_ref(), cx) }) { multi_workspace.update(cx, |multi_workspace, cx| { multi_workspace.activate(workspace, None, window, cx); multi_workspace.retain_active_workspace(cx); }); } else { self.open_workspace_for_group(&key, window, cx); } } fn on_next_project(&mut self, _: &NextProject, window: &mut Window, cx: &mut Context) { self.cycle_project_impl(true, window, cx); } fn on_previous_project( &mut self, _: &PreviousProject, window: &mut Window, cx: &mut Context, ) { self.cycle_project_impl(false, window, cx); } fn cycle_thread_impl(&mut self, forward: bool, window: &mut Window, cx: &mut Context) { let thread_indices: Vec = self .contents .entries .iter() .enumerate() .filter_map(|(ix, entry)| match entry { ListEntry::Thread(_) | ListEntry::Terminal(_) => Some(ix), _ => None, }) .collect(); if thread_indices.is_empty() { return; } let current_thread_pos = self.active_entry.as_ref().and_then(|active| { thread_indices .iter() .position(|&ix| active.matches_entry(&self.contents.entries[ix])) }); let next_pos = match current_thread_pos { Some(pos) => { let count = thread_indices.len(); if forward { (pos + 1) % count } else { (pos + count - 1) % count } } None => 0, }; let entry_ix = thread_indices[next_pos]; match &self.contents.entries[entry_ix] { ListEntry::Thread(thread) => { let metadata = thread.metadata.clone(); match &thread.workspace { ThreadEntryWorkspace::Open(workspace) => { let workspace = workspace.clone(); self.activate_thread(metadata, &workspace, true, window, cx); } ThreadEntryWorkspace::Closed { folder_paths, project_group_key, } => { let folder_paths = folder_paths.clone(); let project_group_key = project_group_key.clone(); self.open_workspace_and_activate_thread( metadata, folder_paths, &project_group_key, window, cx, ); } } } ListEntry::Terminal(terminal) => { let metadata = terminal.metadata.clone(); let workspace = terminal.workspace.clone(); self.activate_terminal_entry(metadata, workspace, true, window, cx); } ListEntry::ProjectHeader { .. } => {} } } fn on_next_thread(&mut self, _: &NextThread, window: &mut Window, cx: &mut Context) { self.cycle_thread_impl(true, window, cx); } fn on_previous_thread( &mut self, _: &PreviousThread, window: &mut Window, cx: &mut Context, ) { self.cycle_thread_impl(false, window, cx); } fn render_no_results(&self, cx: &mut Context) -> impl IntoElement { let has_query = self.has_filter_query(cx); let message = if has_query { "No threads match your search." } else { "No threads yet" }; v_flex() .id("sidebar-no-results") .p_4() .size_full() .items_center() .justify_center() .child( Label::new(message) .size(LabelSize::Small) .color(Color::Muted), ) } fn render_empty_state(&self, cx: &mut Context) -> impl IntoElement { ProjectEmptyState::new( "Threads Sidebar", self.focus_handle(cx), KeyBinding::for_action(&workspace::Open::default(), cx), ) .on_open_project(|_, window, cx| { let side = match AgentSettings::get_global(cx).sidebar_side() { SidebarSide::Left => "left", SidebarSide::Right => "right", }; telemetry::event!("Sidebar Add Project Clicked", side = side); window.dispatch_action( Open { create_new_window: Some(false), } .boxed_clone(), cx, ); }) .on_clone_repo(|_, window, cx| { window.dispatch_action(git::Clone.boxed_clone(), cx); }) } fn render_sidebar_header( &self, no_open_projects: bool, window: &Window, cx: &mut Context, ) -> impl IntoElement { let has_query = self.has_filter_query(cx); let sidebar_on_left = self.side(cx) == SidebarSide::Left; let sidebar_on_right = self.side(cx) == SidebarSide::Right; let not_fullscreen = !window.is_fullscreen(); let traffic_lights = cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left; let left_window_controls = !cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left; let right_window_controls = !cfg!(target_os = "macos") && not_fullscreen && sidebar_on_right; let header_height = platform_title_bar_height(window); h_flex() .h(header_height) .mt_px() .pb_px() .when(left_window_controls, |this| { this.children(Self::render_left_window_controls(window, cx)) }) .map(|this| { if traffic_lights { this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING)) } else if !left_window_controls { this.pl_1p5() } else { this } }) .when(!right_window_controls, |this| this.pr_1p5()) .gap_1() .when(!no_open_projects, |this| { this.border_b_1() .border_color(cx.theme().colors().border) .when(traffic_lights, |this| { this.child(Divider::vertical().color(ui::DividerColor::Border)) }) .child( div().ml_1().child( Icon::new(IconName::MagnifyingGlass) .size(IconSize::Small) .color(Color::Muted), ), ) .child(self.render_filter_input(cx)) .child( h_flex() .gap_1() .when( self.selection.is_some() && !self.filter_editor.focus_handle(cx).is_focused(window), |this| this.child(KeyBinding::for_action(&FocusSidebarFilter, cx)), ) .when(has_query, |this| { this.child( IconButton::new("clear_filter", IconName::Close) .icon_size(IconSize::Small) .tooltip(Tooltip::text("Clear Search")) .on_click(cx.listener(|this, _, window, cx| { this.reset_filter_editor_text(window, cx); this.update_entries(cx); })), ) }), ) }) .when(right_window_controls, |this| { this.children(Self::render_right_window_controls(window, cx)) }) } fn render_left_window_controls(window: &Window, cx: &mut App) -> Option { platform_title_bar::render_left_window_controls( cx.button_layout(), Box::new(CloseWindow), window, ) } fn render_right_window_controls(window: &Window, cx: &mut App) -> Option { platform_title_bar::render_right_window_controls( cx.button_layout(), Box::new(CloseWindow), window, ) } fn render_sidebar_toggle_button(&self, _cx: &mut Context) -> impl IntoElement { let on_right = AgentSettings::get_global(_cx).sidebar_side() == SidebarSide::Right; sidebar_side_context_menu("sidebar-toggle-menu", _cx) .anchor(if on_right { gpui::Anchor::BottomRight } else { gpui::Anchor::BottomLeft }) .attach(if on_right { gpui::Anchor::TopRight } else { gpui::Anchor::TopLeft }) .trigger(move |_is_active, _window, _cx| { let icon = if on_right { IconName::ThreadsSidebarRightOpen } else { IconName::ThreadsSidebarLeftOpen }; IconButton::new("sidebar-close-toggle", icon) .icon_size(IconSize::Small) .tooltip(Tooltip::element(move |_window, cx| { v_flex() .gap_1() .child( h_flex() .gap_2() .justify_between() .child(Label::new("Toggle Sidebar")) .child(KeyBinding::for_action(&ToggleWorkspaceSidebar, cx)), ) .child( h_flex() .pt_1() .gap_2() .border_t_1() .border_color(cx.theme().colors().border_variant) .justify_between() .child(Label::new("Focus Sidebar")) .child(KeyBinding::for_action(&FocusWorkspaceSidebar, cx)), ) .into_any_element() })) .on_click(|_, window, cx| { if let Some(multi_workspace) = window.root::().flatten() { multi_workspace.update(cx, |multi_workspace, cx| { multi_workspace.close_sidebar(window, cx); }); } }) }) } fn render_sidebar_bottom_bar(&mut self, cx: &mut Context) -> impl IntoElement { let is_archive = matches!(self.view, SidebarView::Archive(..)); let on_right = self.side(cx) == SidebarSide::Right; h_flex() .p_1() .gap_1() .when(on_right, |this| this.flex_row_reverse()) .border_t_1() .border_color(cx.theme().colors().border) .child(self.render_sidebar_toggle_button(cx)) .child( IconButton::new("history", IconName::Clock) .icon_size(IconSize::Small) .toggle_state(is_archive) .tooltip(move |_, cx| { let label = if is_archive { "Hide Thread History" } else { "Show Thread History" }; Tooltip::for_action(label, &ToggleThreadHistory, cx) }) .on_click(cx.listener(|this, _, window, cx| { this.toggle_archive(&ToggleThreadHistory, window, cx); })), ) .child(div().flex_1()) .child(self.render_recent_projects_button(cx)) } fn active_workspace(&self, cx: &App) -> Option> { self.multi_workspace .upgrade() .map(|w| w.read(cx).workspace().clone()) } fn show_thread_import_modal( &mut self, source: &'static str, window: &mut Window, cx: &mut Context, ) { telemetry::event!( "Agent Threads Import Clicked", source = source, side = match self.side(cx) { SidebarSide::Left => "left", SidebarSide::Right => "right", } ); let Some(active_workspace) = self.active_workspace(cx) else { return; }; let Some(agent_registry_store) = AgentRegistryStore::try_global(cx) else { return; }; let agent_server_store = active_workspace .read(cx) .project() .read(cx) .agent_server_store() .clone(); let workspace_handle = active_workspace.downgrade(); let multi_workspace = self.multi_workspace.clone(); active_workspace.update(cx, |workspace, cx| { workspace.toggle_modal(window, cx, |window, cx| { ThreadImportModal::new( agent_server_store, agent_registry_store, workspace_handle.clone(), multi_workspace.clone(), window, cx, ) }); }); } fn should_render_acp_import_onboarding(&self, cx: &App) -> bool { let has_external_agents = self .active_workspace(cx) .map(|ws| { ws.read(cx) .project() .read(cx) .agent_server_store() .read(cx) .has_external_agents() }) .unwrap_or(false); has_external_agents && !AcpThreadImportOnboarding::dismissed(cx) } fn render_acp_import_onboarding( &mut self, verbose_labels: bool, cx: &mut Context, ) -> impl IntoElement { let on_import = cx.listener(|this, _, window, cx| { this.show_archive(window, cx); this.show_thread_import_modal("external_agent_onboarding", window, cx); }); render_import_onboarding_banner( "acp", "Looking for threads from external agents?", "Import threads from agents like Claude Agent, Codex, and more, whether started in Zed or another client.", if verbose_labels { "Import Threads from External Agents" } else { "Import Threads" }, |_, _window, cx| AcpThreadImportOnboarding::dismiss(cx), on_import, cx, ) } fn should_render_cross_channel_import_onboarding(&self, cx: &App) -> bool { !CrossChannelImportOnboarding::dismissed(cx) && !self.cross_channel_import_channels.is_empty() } fn render_cross_channel_import_onboarding( &mut self, verbose_labels: bool, cx: &mut Context, ) -> impl IntoElement { let channel_names = self .cross_channel_import_channels .iter() .map(SharedString::as_str) .join(" and "); let description = format!( "Import threads from {} to continue where you left off.", channel_names ); let on_import = cx.listener(|this, _, _window, cx| { telemetry::event!( "Agent Threads Import Clicked", source = "cross_channel_onboarding", side = match this.side(cx) { SidebarSide::Left => "left", SidebarSide::Right => "right", } ); CrossChannelImportOnboarding::dismiss(cx); if let Some(workspace) = this.active_workspace(cx) { workspace.update(cx, |workspace, cx| { import_threads_from_other_channels(workspace, cx); }); } }); render_import_onboarding_banner( "channel", "Threads found from other channels", description, if verbose_labels { "Import Threads from Other Channels" } else { "Import Threads" }, |_, _window, cx| CrossChannelImportOnboarding::dismiss(cx), on_import, cx, ) } fn toggle_archive( &mut self, _: &ToggleThreadHistory, window: &mut Window, cx: &mut Context, ) { match &self.view { SidebarView::ThreadList => { self.show_archive(window, cx); } SidebarView::Archive(_) => self.show_thread_list(window, cx), } } fn show_archive(&mut self, window: &mut Window, cx: &mut Context) { let side = match self.side(cx) { SidebarSide::Left => "left", SidebarSide::Right => "right", }; telemetry::event!("Thread History Viewed", side = side); let Some(active_workspace) = self .multi_workspace .upgrade() .map(|w| w.read(cx).workspace().clone()) else { return; }; let Some(agent_panel) = active_workspace.read(cx).panel::(cx) else { return; }; let agent_server_store = active_workspace .read(cx) .project() .read(cx) .agent_server_store() .downgrade(); let agent_connection_store = agent_panel.read(cx).connection_store().downgrade(); let archive_view = cx.new(|cx| { ThreadsArchiveView::new( active_workspace.downgrade(), agent_connection_store.clone(), agent_server_store.clone(), window, cx, ) }); let subscription = cx.subscribe_in( &archive_view, window, |this, _, event: &ThreadsArchiveViewEvent, window, cx| match event { ThreadsArchiveViewEvent::Close => { this.show_thread_list(window, cx); } ThreadsArchiveViewEvent::Activate { thread } => { this.open_thread_from_archive(thread.clone(), window, cx); } ThreadsArchiveViewEvent::CancelRestore { thread_id } => { this.restoring_tasks.remove(thread_id); } ThreadsArchiveViewEvent::Import => { this.show_thread_import_modal("thread_history", window, cx); } ThreadsArchiveViewEvent::NewThread => { this.show_thread_list(window, cx); if let Some(workspace) = this.active_workspace(cx) { this.create_new_entry(&workspace, window, cx); } } }, ); self._subscriptions.push(subscription); self.view = SidebarView::Archive(archive_view.clone()); archive_view.update(cx, |view, cx| view.focus_filter_editor(window, cx)); self.serialize(cx); cx.notify(); } fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context) { self.view = SidebarView::ThreadList; self._subscriptions.clear(); let handle = self.filter_editor.read(cx).focus_handle(cx); handle.focus(window, cx); self.serialize(cx); cx.notify(); } } fn render_import_onboarding_banner( id: impl Into, title: impl Into, description: impl Into, button_label: impl Into, on_dismiss: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, on_import: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, cx: &App, ) -> impl IntoElement { let id: SharedString = id.into(); let bg = cx.theme().colors().text_accent; v_flex() .min_w_0() .w_full() .p_2() .border_t_1() .border_color(cx.theme().colors().border) .bg(linear_gradient( 360., linear_color_stop(bg.opacity(0.06), 1.), linear_color_stop(bg.opacity(0.), 0.), )) .child( h_flex() .min_w_0() .w_full() .gap_1() .justify_between() .flex_wrap() .child(Label::new(title).size(LabelSize::Small)) .child( IconButton::new( SharedString::from(format!("close-{id}-onboarding")), IconName::Close, ) .icon_size(IconSize::Small) .on_click(on_dismiss), ), ) .child( Label::new(description) .size(LabelSize::Small) .color(Color::Muted) .mb_2(), ) .child( Button::new(SharedString::from(format!("import-{id}")), button_label) .full_width() .style(ButtonStyle::OutlinedCustom(cx.theme().colors().border)) .label_size(LabelSize::Small) .start_icon( Icon::new(IconName::Download) .size(IconSize::Small) .color(Color::Muted), ) .on_click(on_import), ) } impl WorkspaceSidebar for Sidebar { fn width(&self, _cx: &App) -> Pixels { self.width } fn set_width(&mut self, width: Option, cx: &mut Context) { self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH); cx.notify(); } fn has_notifications(&self, _cx: &App) -> bool { !self.contents.notified_threads.is_empty() || !self.contents.notified_terminals.is_empty() } fn is_threads_list_view_active(&self) -> bool { matches!(self.view, SidebarView::ThreadList) } fn side(&self, cx: &App) -> SidebarSide { AgentSettings::get_global(cx).sidebar_side() } fn prepare_for_focus(&mut self, _window: &mut Window, cx: &mut Context) { self.selection = None; cx.notify(); } fn toggle_thread_switcher( &mut self, select_last: bool, window: &mut Window, cx: &mut Context, ) { self.toggle_thread_switcher_impl(select_last, window, cx); } fn cycle_project(&mut self, forward: bool, window: &mut Window, cx: &mut Context) { self.cycle_project_impl(forward, window, cx); } fn cycle_thread(&mut self, forward: bool, window: &mut Window, cx: &mut Context) { self.cycle_thread_impl(forward, window, cx); } fn serialized_state(&self, _cx: &App) -> Option { let serialized = SerializedSidebar { width: Some(f32::from(self.width)), active_view: match self.view { SidebarView::ThreadList => SerializedSidebarView::ThreadList, SidebarView::Archive(_) => SerializedSidebarView::History, }, }; serde_json::to_string(&serialized).ok() } fn restore_serialized_state( &mut self, state: &str, window: &mut Window, cx: &mut Context, ) { if let Some(serialized) = serde_json::from_str::(state).log_err() { if let Some(width) = serialized.width { self.width = px(width).clamp(MIN_WIDTH, MAX_WIDTH); } if serialized.active_view == SerializedSidebarView::History { cx.defer_in(window, |this, window, cx| { this.show_archive(window, cx); }); } } cx.notify(); } } impl gpui::EventEmitter for Sidebar {} impl Focusable for Sidebar { fn focus_handle(&self, _cx: &App) -> FocusHandle { self.focus_handle.clone() } } impl Render for Sidebar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let _titlebar_height = ui::utils::platform_title_bar_height(window); let ui_font = theme_settings::setup_ui_font(window, cx); let sticky_header = self.render_sticky_header(window, cx); let color = cx.theme().colors(); let bg = color .title_bar_background .blend(color.panel_background.opacity(0.25)); let no_open_projects = !self.contents.has_open_projects; let no_search_results = self.contents.entries.is_empty(); v_flex() .id("workspace-sidebar") .key_context(self.dispatch_context(window, cx)) .track_focus(&self.focus_handle) .on_action(cx.listener(Self::select_next)) .on_action(cx.listener(Self::select_previous)) .on_action(cx.listener(Self::editor_move_down)) .on_action(cx.listener(Self::editor_move_up)) .on_action(cx.listener(Self::select_first)) .on_action(cx.listener(Self::select_last)) .on_action(cx.listener(Self::confirm)) .on_action(cx.listener(Self::expand_selected_entry)) .on_action(cx.listener(Self::collapse_selected_entry)) .on_action(cx.listener(Self::toggle_selected_fold)) .on_action(cx.listener(Self::fold_all)) .on_action(cx.listener(Self::unfold_all)) .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::archive_selected_thread)) .on_action(cx.listener(Self::rename_selected_thread)) .on_action(cx.listener(Self::new_thread_in_group)) .on_action(cx.listener(Self::new_terminal_thread)) .on_action(cx.listener(Self::toggle_archive)) .on_action(cx.listener(Self::focus_sidebar_filter)) .on_action(cx.listener(Self::on_toggle_thread_switcher)) .on_action(cx.listener(Self::on_next_project)) .on_action(cx.listener(Self::on_previous_project)) .on_action(cx.listener(Self::on_next_thread)) .on_action(cx.listener(Self::on_previous_thread)) .on_action(cx.listener(|this, _: &OpenRecent, window, cx| { this.recent_projects_popover_handle.toggle(window, cx); })) .font(ui_font) .map(|el| { let on_left = self.side(cx) == SidebarSide::Left; match window.window_decorations() { Decorations::Server => el.h_full().w(self.width), // With client-side decorations the sidebar owns the window // corners on its side, so round them like the title bar and // status bar do. The sidebar is stretched 1px outwards over // the window border on untiled edges (with compensating // padding) so its rounded background lines up exactly with // the window shape, avoiding a transparent gap in the // rounded corners. Decorations::Client { tiling, .. } => el .absolute() .top(if tiling.top { px(0.) } else { px(-1.) }) .bottom(if tiling.bottom { px(0.) } else { px(-1.) }) .when(!tiling.top, |el| el.pt_px()) .when(!tiling.bottom, |el| el.pb_px()) .map(|el| { if on_left { el.right(px(0.)) .left(if tiling.left { px(0.) } else { px(-1.) }) .when(!tiling.left, |el| el.pl(px(1.))) } else { el.left(px(0.)) .right(if tiling.right { px(0.) } else { px(-1.) }) .when(!tiling.right, |el| el.pr(px(1.))) } }) .when(on_left && !(tiling.top || tiling.left), |el| { el.rounded_tl(CLIENT_SIDE_DECORATION_ROUNDING) }) .when(on_left && !(tiling.bottom || tiling.left), |el| { el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING) }) .when(!on_left && !(tiling.top || tiling.right), |el| { el.rounded_tr(CLIENT_SIDE_DECORATION_ROUNDING) }) .when(!on_left && !(tiling.bottom || tiling.right), |el| { el.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING) }), } }) .bg(bg) .when(self.side(cx) == SidebarSide::Left, |el| el.border_r_1()) .when(self.side(cx) == SidebarSide::Right, |el| el.border_l_1()) .border_color(color.border) .map(|this| match &self.view { SidebarView::ThreadList => this .child(self.render_sidebar_header(no_open_projects, window, cx)) .map(|this| { if no_open_projects { this.child(self.render_empty_state(cx)) } else { this.child( v_flex() .relative() .flex_1() .overflow_hidden() .child( list( self.list_state.clone(), cx.processor(Self::render_list_entry), ) .flex_1() .size_full(), ) .when(no_search_results, |this| { this.child(self.render_no_results(cx)) }) .when_some(sticky_header, |this, header| this.child(header)) .custom_scrollbars( Scrollbars::new(ScrollAxes::Vertical) .tracked_scroll_handle(&self.list_state), window, cx, ), ) } }), SidebarView::Archive(archive_view) => this.child(archive_view.clone()), }) .map(|this| { let show_acp = self.should_render_acp_import_onboarding(cx); let show_cross_channel = self.should_render_cross_channel_import_onboarding(cx); let verbose = *self .import_banners_use_verbose_labels .get_or_insert(show_acp && show_cross_channel); this.when(show_acp, |this| { this.child(self.render_acp_import_onboarding(verbose, cx)) }) .when(show_cross_channel, |this| { this.child(self.render_cross_channel_import_onboarding(verbose, cx)) }) }) .child(self.render_sidebar_bottom_bar(cx)) } } fn all_thread_infos_for_workspace( workspace: &Entity, cx: &App, ) -> impl Iterator { let Some(agent_panel) = workspace.read(cx).panel::(cx) else { return None.into_iter().flatten(); }; let agent_panel = agent_panel.read(cx); let threads = agent_panel .conversation_views() .into_iter() .filter_map(|conversation_view| { let has_pending_tool_call = conversation_view .read(cx) .root_thread_has_pending_tool_call(cx); let conversation_thread_id = conversation_view.read(cx).parent_id(); let thread_view = conversation_view.read(cx).root_thread_view()?; let thread_view_ref = thread_view.read(cx); let thread = thread_view_ref.thread.read(cx); let icon = thread_view_ref.agent_icon; let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone(); let title = thread .title() .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into()); let is_title_generating = thread_view_ref .as_native_thread(cx) .is_some_and(|native_thread| native_thread.read(cx).is_generating_title()); let session_id = thread.session_id().clone(); let is_background = agent_panel.is_retained_thread(&conversation_thread_id); let status = if has_pending_tool_call { AgentThreadStatus::WaitingForConfirmation } else if thread.had_error() { AgentThreadStatus::Error } else { match thread.status() { ThreadStatus::Generating => AgentThreadStatus::Running, ThreadStatus::Idle => AgentThreadStatus::Completed, } }; let diff_stats = thread.action_log().read(cx).diff_stats(cx); Some(ActiveThreadInfo { session_id, title, status, icon, icon_from_external_svg, is_background, is_title_generating, diff_stats, }) }); Some(threads).into_iter().flatten() } pub fn dump_workspace_info( workspace: &mut Workspace, _: &DumpWorkspaceInfo, window: &mut gpui::Window, cx: &mut gpui::Context, ) { use std::fmt::Write; let mut output = String::new(); let this_entity = cx.entity(); let multi_workspace = workspace.multi_workspace().and_then(|weak| weak.upgrade()); let workspaces: Vec> = match &multi_workspace { Some(mw) => mw.read(cx).workspaces().cloned().collect(), None => vec![this_entity.clone()], }; let active_workspace = multi_workspace .as_ref() .map(|mw| mw.read(cx).workspace().clone()); writeln!(output, "MultiWorkspace: {} workspace(s)", workspaces.len()).ok(); if let Some(mw) = &multi_workspace { let keys: Vec<_> = mw.read(cx).project_group_keys(); writeln!(output, "Project group keys ({}):", keys.len()).ok(); for key in keys { writeln!(output, " - {key:?}").ok(); } } writeln!(output).ok(); for (index, ws) in workspaces.iter().enumerate() { let is_active = active_workspace.as_ref() == Some(ws); writeln!( output, "--- Workspace {index}{} ---", if is_active { " (active)" } else { "" } ) .ok(); // project_group_key_for_workspace internally reads the workspace, // so we can only call it for workspaces other than this_entity // (which is already being updated). if let Some(mw) = &multi_workspace { if *ws == this_entity { let workspace_key = workspace.project_group_key(cx); writeln!(output, "ProjectGroupKey: {workspace_key:?}").ok(); } else { let effective_key = mw.read(cx).project_group_key_for_workspace(ws, cx); let workspace_key = ws.read(cx).project_group_key(cx); if effective_key != workspace_key { writeln!( output, "ProjectGroupKey (multi_workspace): {effective_key:?}" ) .ok(); writeln!( output, "ProjectGroupKey (workspace, DISAGREES): {workspace_key:?}" ) .ok(); } else { writeln!(output, "ProjectGroupKey: {effective_key:?}").ok(); } } } else { let workspace_key = workspace.project_group_key(cx); writeln!(output, "ProjectGroupKey: {workspace_key:?}").ok(); } // The action handler is already inside an update on `this_entity`, // so we must avoid a nested read/update on that same entity. if *ws == this_entity { dump_single_workspace(workspace, &mut output, cx); } else { ws.read_with(cx, |ws, cx| { dump_single_workspace(ws, &mut output, cx); }); } } let project = workspace.project().clone(); cx.spawn_in(window, async move |_this, cx| { let buffer = project .update(cx, |project, cx| project.create_buffer(None, false, cx)) .await?; buffer.update(cx, |buffer, cx| { buffer.set_text(output, cx); }); let buffer = cx.new(|cx| { editor::MultiBuffer::singleton(buffer, cx).with_title("Workspace Info".into()) }); _this.update_in(cx, |workspace, window, cx| { workspace.add_item_to_active_pane( Box::new(cx.new(|cx| { let mut editor = editor::Editor::for_multibuffer(buffer, Some(project.clone()), window, cx); editor.set_read_only(true); editor.set_should_serialize(false, cx); editor.set_breadcrumb_header("Workspace Info".into()); editor })), None, true, window, cx, ); }) }) .detach_and_log_err(cx); } fn dump_single_workspace(workspace: &Workspace, output: &mut String, cx: &gpui::App) { use std::fmt::Write; let workspace_db_id = workspace.database_id(); match workspace_db_id { Some(id) => writeln!(output, "Workspace DB ID: {id:?}").ok(), None => writeln!(output, "Workspace DB ID: (none)").ok(), }; let project = workspace.project().read(cx); let repos: Vec<_> = project .repositories(cx) .values() .map(|repo| repo.read(cx).snapshot()) .collect(); writeln!(output, "Worktrees:").ok(); for worktree in project.worktrees(cx) { let worktree = worktree.read(cx); let abs_path = worktree.abs_path(); let visible = worktree.is_visible(); let repo_info = repos .iter() .find(|snapshot| abs_path.starts_with(&*snapshot.work_directory_abs_path)); let is_linked = repo_info.map(|s| s.is_linked_worktree()).unwrap_or(false); let main_worktree_path = repo_info.and_then(|s| s.main_worktree_abs_path()); let branch = repo_info.and_then(|s| s.branch.as_ref().map(|b| b.ref_name.clone())); write!(output, " - {}", abs_path.display()).ok(); if !visible { write!(output, " (hidden)").ok(); } if let Some(branch) = &branch { write!(output, " [branch: {branch}]").ok(); } if is_linked { if let Some(main_worktree_path) = main_worktree_path { write!( output, " [linked worktree -> {}]", main_worktree_path.display() ) .ok(); } else { write!(output, " [linked worktree]").ok(); } } writeln!(output).ok(); } if let Some(panel) = workspace.panel::(cx) { let panel = panel.read(cx); let panel_workspace_id = panel.workspace_id(); if panel_workspace_id != workspace_db_id { writeln!( output, " \u{26a0} workspace ID mismatch! panel has {panel_workspace_id:?}, workspace has {workspace_db_id:?}" ) .ok(); } if let Some(thread) = panel.active_agent_thread(cx) { let thread = thread.read(cx); let title = thread.title().unwrap_or_else(|| "(untitled)".into()); let session_id = thread.session_id(); let status = match thread.status() { ThreadStatus::Idle => "idle", ThreadStatus::Generating => "generating", }; let entry_count = thread.entries().len(); write!(output, "Active thread: {title} (session: {session_id})").ok(); write!(output, " [{status}, {entry_count} entries").ok(); if panel .active_conversation_view() .is_some_and(|conversation_view| { conversation_view .read(cx) .root_thread_has_pending_tool_call(cx) }) { write!(output, ", awaiting confirmation").ok(); } writeln!(output, "]").ok(); } else { writeln!(output, "Active thread: (none)").ok(); } let background_threads = panel.retained_threads(); if !background_threads.is_empty() { writeln!( output, "Background threads ({}): ", background_threads.len() ) .ok(); for (session_id, conversation_view) in background_threads { if let Some(thread_view) = conversation_view.read(cx).root_thread_view() { let thread = thread_view.read(cx).thread.read(cx); let title = thread.title().unwrap_or_else(|| "(untitled)".into()); let status = match thread.status() { ThreadStatus::Idle => "idle", ThreadStatus::Generating => "generating", }; let entry_count = thread.entries().len(); write!(output, " - {title} (thread: {session_id:?})").ok(); write!(output, " [{status}, {entry_count} entries").ok(); if conversation_view .read(cx) .root_thread_has_pending_tool_call(cx) { write!(output, ", awaiting confirmation").ok(); } writeln!(output, "]").ok(); } else { writeln!(output, " - (not connected) (thread: {session_id:?})").ok(); } } } } else { writeln!(output, "Agent panel: not loaded").ok(); } writeln!(output).ok(); }