diff --git a/Cargo.lock b/Cargo.lock index 42649b137f35cff3bcc9622229d1b396dd9d1d87..5ceb015927208d8dda15d4fe10b8f92968a45a26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20793,6 +20793,7 @@ dependencies = [ "language_model", "log", "menu", + "multi_buffer", "postage", "project", "rand 0.8.5", diff --git a/crates/cloud_llm_client/src/cloud_llm_client.rs b/crates/cloud_llm_client/src/cloud_llm_client.rs index 791f9488f160aa1d1f41862fcdca87f937629452..4d037d3598f50b7dab1801873295b394e6b3191b 100644 --- a/crates/cloud_llm_client/src/cloud_llm_client.rs +++ b/crates/cloud_llm_client/src/cloud_llm_client.rs @@ -202,6 +202,15 @@ pub struct PredictEditsRecentFile { pub cursor_point: Point, /// Milliseconds between the editor for this file being active and the request time. pub active_to_now_ms: u32, + /// Number of times the editor for this file was activated. + pub activation_count: u32, + /// Rough estimate of milliseconds the user was editing the file. + pub cumulative_time_editing_ms: u32, + /// Rough estimate of milliseconds the user was navigating within the file. + pub cumulative_time_navigating_ms: u32, + /// Whether the file is a multibuffer. + #[serde(skip_serializing_if = "is_default", default)] + pub is_multibuffer: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -373,6 +382,10 @@ pub struct UsageData { pub limit: UsageLimit, } +fn is_default(value: &T) -> bool { + *value == T::default() +} + #[cfg(test)] mod tests { use pretty_assertions::assert_eq; diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 7d5670f4710fd4f2777f3d953d0972d9a93aded2..29e009fdf8d5a8c06d12e36253db59886dd0b9be 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -2746,11 +2746,6 @@ impl Editor { self.buffer.read(cx).read(cx).file_at(point).cloned() } - pub fn cursor_buffer_point(&self, cx: &mut App) -> Option<(Entity, Point, ExcerptId)> { - let cursor = self.selections.newest::(cx).head(); - self.buffer.read(cx).point_to_buffer_point(cursor, cx) - } - pub fn active_excerpt( &self, cx: &App, diff --git a/crates/zeta/Cargo.toml b/crates/zeta/Cargo.toml index ee76308ff38089b9553f9a6ba87998ce74480181..ebe785e3973ab95f1bc1e90fc56419b07c1eff0e 100644 --- a/crates/zeta/Cargo.toml +++ b/crates/zeta/Cargo.toml @@ -21,6 +21,7 @@ ai_onboarding.workspace = true anyhow.workspace = true arrayvec.workspace = true client.workspace = true +clock.workspace = true cloud_llm_client.workspace = true collections.workspace = true command_palette_hooks.workspace = true @@ -38,6 +39,7 @@ language.workspace = true language_model.workspace = true log.workspace = true menu.workspace = true +multi_buffer.workspace = true postage.workspace = true project.workspace = true rand.workspace = true diff --git a/crates/zeta/src/zeta.rs b/crates/zeta/src/zeta.rs index da90e8e312c94a81e3d69e62f6a4d4aa9baeafc2..b26ac42b3b25c8af7e2fe36b896eb8628958b756 100644 --- a/crates/zeta/src/zeta.rs +++ b/crates/zeta/src/zeta.rs @@ -35,6 +35,7 @@ use language::{ Anchor, Buffer, BufferSnapshot, EditPreview, File, OffsetRangeExt, ToOffset, ToPoint, text_diff, }; use language_model::{LlmApiToken, RefreshLlmTokenListener}; +use multi_buffer::MultiBufferPoint; use project::{Project, ProjectPath}; use release_channel::AppVersion; use settings::WorktreeId; @@ -87,6 +88,12 @@ const MAX_DIAGNOSTICS_BYTES: usize = 4096; /// Maximum number of edit predictions to store for feedback. const MAX_SHOWN_COMPLETION_COUNT: usize = 50; +/// Interval between polls tracking time editing files. +const ACTIVITY_POLL_INTERVAL: Duration = Duration::from_secs(10); + +/// Interval between polls of whether data collection is enabled, when it is disabled. +const DISABLED_ACTIVITY_POLL_INTERVAL: Duration = Duration::from_secs(60 * 5); + actions!( edit_prediction, [ @@ -242,11 +249,23 @@ pub struct Zeta { user_store: Entity, license_detection_watchers: HashMap>, recent_editors: VecDeque, + last_activity_state: Option, + _activity_poll_task: Option>>, } struct RecentEditor { editor: WeakEntity, last_active_at: Instant, + activation_count: u32, + cumulative_time_editing: Duration, + cumulative_time_navigating: Duration, +} + +#[derive(Debug)] +struct ActivityState { + scroll_position: gpui::Point, + cursor_point: MultiBufferPoint, + singleton_version: Option, } impl Zeta { @@ -298,14 +317,40 @@ impl Zeta { let data_collection_choice = Self::load_data_collection_choices(); let data_collection_choice = cx.new(|_| data_collection_choice); + let mut activity_poll_task = None; + if let Some(workspace) = &workspace { - cx.subscribe(workspace, |this, _workspace, event, cx| match event { - workspace::Event::ActiveItemChanged => { - this.handle_active_workspace_item_changed(cx) + let project = workspace.read(cx).project().clone(); + cx.subscribe(&project, |this, _project, event, cx| match event { + project::Event::ActiveEntryChanged(entry_id) => { + this.handle_active_project_entry_changed(cx) } _ => {} }) .detach(); + + // TODO: ideally this would attend to window focus when tracking time, and pause the + // loop for efficiency when not focused. + activity_poll_task = Some(cx.spawn(async move |this, cx| { + let mut instant_before_delay = None; + loop { + let data_collection_is_enabled = this.read_with(cx, |this, cx| { + this.data_collection_choice.read(cx).is_enabled() + })?; + let interval = if data_collection_is_enabled { + ACTIVITY_POLL_INTERVAL + } else { + instant_before_delay = None; + DISABLED_ACTIVITY_POLL_INTERVAL + }; + cx.background_executor().timer(interval).await; + this.update(cx, |this, cx| { + let now = Instant::now(); + this.handle_activity_poll(instant_before_delay, now, cx); + instant_before_delay = Some(now); + })? + } + })); } Self { @@ -336,6 +381,8 @@ impl Zeta { license_detection_watchers: HashMap::default(), user_store, recent_editors: VecDeque::new(), + last_activity_state: None, + _activity_poll_task: activity_poll_task, } } @@ -1268,9 +1315,10 @@ and then another }) } - fn handle_active_workspace_item_changed(&mut self, cx: &Context) { + fn handle_active_project_entry_changed(&mut self, cx: &mut Context) { if !self.data_collection_choice.read(cx).is_enabled() { self.recent_editors.clear(); + self.last_activity_state = None; return; } if let Some(active_editor) = self @@ -1284,20 +1332,35 @@ and then another .flatten() { let now = Instant::now(); + let editor = active_editor.downgrade(); + let existing_recent_editor = if let Some(existing_ix) = self + .recent_editors + .iter() + .rposition(|recent| &recent.editor == &editor) + { + if existing_ix + 1 != self.recent_editors.len() { + self.last_activity_state = None; + } + self.recent_editors.remove(existing_ix) + } else { + None + }; let new_recent = RecentEditor { editor: active_editor.downgrade(), last_active_at: now, + activation_count: existing_recent_editor + .as_ref() + .map_or(0, |recent| recent.activation_count + 1), + cumulative_time_navigating: existing_recent_editor + .as_ref() + .map_or(Duration::ZERO, |recent| recent.cumulative_time_navigating), + cumulative_time_editing: existing_recent_editor + .map_or(Duration::ZERO, |recent| recent.cumulative_time_editing), }; - if let Some(existing_ix) = self - .recent_editors - .iter() - .rposition(|recent| &recent.editor == &new_recent.editor) - { - self.recent_editors.remove(existing_ix); - } // filter out rapid changes in active item, particularly since this can happen rapidly when // a workspace is loaded. if let Some(previous_recent) = self.recent_editors.back_mut() + && previous_recent.activation_count == 1 && now.duration_since(previous_recent.last_active_at) < MIN_TIME_BETWEEN_RECENT_FILES { @@ -1311,6 +1374,80 @@ and then another } } + fn handle_activity_poll( + &mut self, + instant_before_delay: Option, + now: Instant, + cx: &mut Context, + ) { + if !self.data_collection_choice.read(cx).is_enabled() { + self.last_activity_state = None; + return; + } + if let Some(recent_editor) = self.recent_editors.back() + && let Some(editor) = recent_editor.editor.upgrade() + { + let (scroll_position, cursor_point, singleton_version) = + editor.update(cx, |editor, cx| { + let scroll_position = editor.scroll_position(cx); + let cursor_point = editor.selections.newest(cx).head(); + let singleton_version = editor + .buffer() + .read(cx) + .as_singleton() + .map(|singleton_buffer| singleton_buffer.read(cx).version()); + (scroll_position, cursor_point, singleton_version) + }); + + let navigated = if let Some(last_activity_state) = &self.last_activity_state { + last_activity_state.scroll_position != scroll_position + || last_activity_state.cursor_point != cursor_point + } else { + false + }; + + let edited = if let Some(singleton_version) = &singleton_version + && let Some(last_activity_state) = &self.last_activity_state + && let Some(last_singleton_version) = &last_activity_state.singleton_version + { + singleton_version.changed_since(last_singleton_version) + } else { + false + }; + + self.last_activity_state = Some(ActivityState { + scroll_position, + cursor_point, + singleton_version, + }); + + let prior_recent_editor = if self.recent_editors.len() > 1 { + Some(&self.recent_editors[self.recent_editors.len() - 2]) + } else { + None + }; + let additional_time: Option = + instant_before_delay.map(|instant_before_delay| { + now.duration_since(prior_recent_editor.map_or( + instant_before_delay, + |prior_recent_editor| { + prior_recent_editor.last_active_at.max(instant_before_delay) + }, + )) + }); + + if let Some(additional_time) = additional_time { + let recent_editor = self.recent_editors.back_mut().unwrap(); + if navigated { + recent_editor.cumulative_time_navigating += additional_time; + } + if edited { + recent_editor.cumulative_time_editing += additional_time; + } + } + } + } + fn recent_files( &mut self, now: &Instant, @@ -1330,7 +1467,10 @@ and then another .editor .update(cx, |editor, cx| { maybe!({ - let (buffer, cursor_point, _) = editor.cursor_buffer_point(cx)?; + let cursor = editor.selections.newest::(cx).head(); + let multibuffer = editor.buffer().read(cx); + let (buffer, cursor_point, _) = + multibuffer.point_to_buffer_point(cursor, cx)?; let file = buffer.read(cx).file()?; if !file_is_eligible_for_collection(file.as_ref()) { return None; @@ -1359,10 +1499,24 @@ and then another .as_millis() .try_into() .ok()?; + let cumulative_time_editing_ms = recent_editor + .cumulative_time_editing + .as_millis() + .try_into() + .ok()?; + let cumulative_time_navigating_ms = recent_editor + .cumulative_time_navigating + .as_millis() + .try_into() + .ok()?; results.push(PredictEditsRecentFile { path: repo_path_str.to_string(), cursor_point: to_cloud_llm_client_point(cursor_point), active_to_now_ms, + activation_count: recent_editor.activation_count, + cumulative_time_editing_ms, + cumulative_time_navigating_ms, + is_multibuffer: !multibuffer.is_singleton(), }); Some(()) })