diff --git a/Cargo.lock b/Cargo.lock index 0ae2dd1b468b..c81ab25ef43e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1964,6 +1964,37 @@ dependencies = [ "syn 2.0.99", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.10", + "proc-macro2", + "quote", + "syn 2.0.99", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.99", +] + [[package]] name = "devgen-tree-sitter-swift" version = "0.21.0" @@ -2025,7 +2056,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -2679,6 +2710,7 @@ dependencies = [ "opentelemetry-otlp", "opentelemetry_sdk", "paste", + "posthog-rs", "rand 0.8.5", "regex", "reqwest 0.12.12", @@ -4996,6 +5028,21 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "posthog-rs" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610d236077c94c96194b0d2b40989bda4bf8faf60e15a0da6ef8ffd4c78f6c1c" +dependencies = [ + "chrono", + "derive_builder", + "reqwest 0.11.27", + "semver", + "serde", + "serde_json", + "uuid", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -5545,6 +5592,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots 0.25.4", "winreg 0.50.0", ] @@ -7740,6 +7788,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0f540e3240398cce6128b64ba83fdbdd86129c16a3aa1a3a252efd66eb3d587" dependencies = [ "getrandom 0.3.1", + "serde", ] [[package]] diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index 8c9fc966139a..947e2d334950 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -248,6 +248,8 @@ pub struct SessionSettings { } pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { + goose::posthog::set_session_context("cli", session_config.resume); + let config = Config::global(); let (saved_provider, saved_model_config) = if session_config.resume { diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index fd1b63eec346..e6f8e57474d4 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -105,6 +105,8 @@ async fn start_agent( State(state): State>, Json(payload): Json, ) -> Result, ErrorResponse> { + goose::posthog::set_session_context("desktop", false); + let StartAgentRequest { working_dir, recipe, @@ -197,6 +199,8 @@ async fn resume_agent( State(state): State>, Json(payload): Json, ) -> Result, ErrorResponse> { + goose::posthog::set_session_context("desktop", true); + let session = SessionManager::get_session(&payload.session_id, true) .await .map_err(|err| { diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index b56c1ee61719..454761d81a99 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -111,6 +111,7 @@ shellexpand = "3.1.1" indexmap = "2.12.0" ignore = "0.4.25" which = "8.0.0" +posthog-rs = "0.3.7" [target.'cfg(target_os = "windows")'.dependencies] diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index be04c7100dbe..ef4d5f69197b 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1221,7 +1221,8 @@ impl Agent { no_tools_called = false; } } - Err(ProviderError::ContextLengthExceeded(_error_msg)) => { + Err(ref provider_err @ ProviderError::ContextLengthExceeded(_)) => { + crate::posthog::emit_error(provider_err.telemetry_type()); yield AgentEvent::Message( Message::assistant().with_system_notification( SystemNotificationType::InlineMessage, @@ -1255,11 +1256,12 @@ impl Agent { } } } - Err(e) => { - error!("Error: {}", e); + Err(ref provider_err) => { + crate::posthog::emit_error(provider_err.telemetry_type()); + error!("Error: {}", provider_err); yield AgentEvent::Message( Message::assistant().with_text( - format!("Ran into this error: {e}.\n\nPlease retry if you think this is a transient or recoverable error.") + format!("Ran into this error: {provider_err}.\n\nPlease retry if you think this is a transient or recoverable error.") ) ); break; diff --git a/crates/goose/src/agents/tool_route_manager.rs b/crates/goose/src/agents/tool_route_manager.rs index 670806070210..363a5880e735 100644 --- a/crates/goose/src/agents/tool_route_manager.rs +++ b/crates/goose/src/agents/tool_route_manager.rs @@ -38,9 +38,9 @@ impl ToolRouteManager { pub async fn record_tool_requests(&self, requests: &[ToolRequest]) { let selector = self.router_tool_selector.lock().await.clone(); - if let Some(selector) = selector { - for request in requests { - if let Ok(tool_call) = &request.tool_call { + for request in requests { + if let Ok(tool_call) = &request.tool_call { + if let Some(ref selector) = selector { if let Err(e) = selector.record_tool_call(&tool_call.name).await { error!("Failed to record tool call: {}", e); } diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs index 9c21c95dce5f..5e88df237344 100644 --- a/crates/goose/src/lib.rs +++ b/crates/goose/src/lib.rs @@ -10,6 +10,7 @@ pub mod mcp_utils; pub mod model; pub mod oauth; pub mod permission; +pub mod posthog; pub mod prompt_template; pub mod providers; pub mod recipe; diff --git a/crates/goose/src/posthog.rs b/crates/goose/src/posthog.rs new file mode 100644 index 000000000000..54b5aa8f881c --- /dev/null +++ b/crates/goose/src/posthog.rs @@ -0,0 +1,303 @@ +//! PostHog telemetry - fires once per session creation. + +use crate::config::paths::Paths; +use crate::config::{get_enabled_extensions, Config}; +use crate::session::SessionManager; +use chrono::{DateTime, Utc}; +use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; +use uuid::Uuid; + +const POSTHOG_API_KEY: &str = "phc_RyX5CaY01VtZJCQyhSR5KFh6qimUy81YwxsEpotAftT"; + +/// Config key for telemetry opt-out preference +pub const TELEMETRY_ENABLED_KEY: &str = "GOOSE_TELEMETRY_ENABLED"; + +static TELEMETRY_DISABLED_BY_ENV: Lazy = Lazy::new(|| { + std::env::var("GOOSE_TELEMETRY_OFF") + .map(|v| v == "1" || v.to_lowercase() == "true") + .unwrap_or(false) + .into() +}); + +/// Check if telemetry is enabled. +/// +/// Returns false if: +/// - GOOSE_TELEMETRY_OFF environment variable is set to "1" or "true" +/// - GOOSE_TELEMETRY_ENABLED config value is set to false +/// +/// Returns true otherwise (telemetry is opt-out, enabled by default) +pub fn is_telemetry_enabled() -> bool { + if TELEMETRY_DISABLED_BY_ENV.load(Ordering::Relaxed) { + return false; + } + + let config = Config::global(); + config + .get_param::(TELEMETRY_ENABLED_KEY) + .unwrap_or(true) +} + +// ============================================================================ +// Installation Tracking +// ============================================================================ + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct InstallationData { + installation_id: String, + first_seen: DateTime, + session_count: u32, +} + +impl Default for InstallationData { + fn default() -> Self { + Self { + installation_id: Uuid::new_v4().to_string(), + first_seen: Utc::now(), + session_count: 0, + } + } +} + +fn installation_file_path() -> std::path::PathBuf { + Paths::state_dir().join("telemetry_installation.json") +} + +fn load_or_create_installation() -> InstallationData { + let path = installation_file_path(); + + if let Ok(contents) = fs::read_to_string(&path) { + if let Ok(data) = serde_json::from_str::(&contents) { + return data; + } + } + + let data = InstallationData::default(); + save_installation(&data); + data +} + +fn save_installation(data: &InstallationData) { + let path = installation_file_path(); + + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + + if let Ok(json) = serde_json::to_string_pretty(data) { + let _ = fs::write(path, json); + } +} + +fn increment_session_count() -> InstallationData { + let mut data = load_or_create_installation(); + data.session_count += 1; + save_installation(&data); + data +} + +// ============================================================================ +// Platform Info +// ============================================================================ + +fn get_platform_version() -> Option { + #[cfg(target_os = "macos")] + { + std::process::Command::new("sw_vers") + .arg("-productVersion") + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + } + #[cfg(target_os = "linux")] + { + fs::read_to_string("/etc/os-release") + .ok() + .and_then(|content| { + content + .lines() + .find(|line| line.starts_with("VERSION_ID=")) + .map(|line| { + line.trim_start_matches("VERSION_ID=") + .trim_matches('"') + .to_string() + }) + }) + } + #[cfg(target_os = "windows")] + { + std::process::Command::new("cmd") + .args(["/C", "ver"]) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + None + } +} + +fn detect_install_method() -> String { + let exe_path = std::env::current_exe().ok(); + + if let Some(path) = exe_path { + let path_str = path.to_string_lossy().to_lowercase(); + + if path_str.contains("homebrew") || path_str.contains("/opt/homebrew") { + return "homebrew".to_string(); + } + if path_str.contains(".cargo") { + return "cargo".to_string(); + } + if path_str.contains("applications") || path_str.contains(".app") { + return "desktop".to_string(); + } + } + + if std::env::var("GOOSE_DESKTOP").is_ok() { + return "desktop".to_string(); + } + + "binary".to_string() +} + +// ============================================================================ +// Session Context (set by CLI/Desktop at startup) +// ============================================================================ + +static SESSION_INTERFACE: Lazy>> = Lazy::new(|| Mutex::new(None)); +static SESSION_IS_RESUMED: AtomicBool = AtomicBool::new(false); + +pub fn set_session_context(interface: &str, is_resumed: bool) { + if let Ok(mut iface) = SESSION_INTERFACE.lock() { + *iface = Some(interface.to_string()); + } + SESSION_IS_RESUMED.store(is_resumed, Ordering::Relaxed); +} + +fn get_session_interface() -> String { + SESSION_INTERFACE + .lock() + .ok() + .and_then(|i| i.clone()) + .unwrap_or_else(|| "unknown".to_string()) +} + +fn get_session_is_resumed() -> bool { + SESSION_IS_RESUMED.load(Ordering::Relaxed) +} + +// ============================================================================ +// Telemetry Events +// ============================================================================ + +pub fn emit_session_started() { + if !is_telemetry_enabled() { + return; + } + + let installation = increment_session_count(); + + tokio::spawn(async move { + let _ = send_session_event(&installation).await; + }); +} + +pub fn emit_error(error_type: &str) { + if !is_telemetry_enabled() { + return; + } + + let installation = load_or_create_installation(); + let error_type = error_type.to_string(); + + tokio::spawn(async move { + let _ = send_error_event(&installation, &error_type).await; + }); +} + +async fn send_error_event(installation: &InstallationData, error_type: &str) -> Result<(), String> { + let client = posthog_rs::client(POSTHOG_API_KEY).await; + let mut event = posthog_rs::Event::new("error", &installation.installation_id); + + event.insert_prop("error_type", error_type).ok(); + event.insert_prop("version", env!("CARGO_PKG_VERSION")).ok(); + event.insert_prop("interface", get_session_interface()).ok(); + event.insert_prop("os", std::env::consts::OS).ok(); + event.insert_prop("arch", std::env::consts::ARCH).ok(); + + if let Some(platform_version) = get_platform_version() { + event.insert_prop("platform_version", platform_version).ok(); + } + + let config = Config::global(); + if let Ok(provider) = config.get_param::("GOOSE_PROVIDER") { + event.insert_prop("provider", provider).ok(); + } + if let Ok(model) = config.get_param::("GOOSE_MODEL") { + event.insert_prop("model", model).ok(); + } + + client.capture(event).await.map_err(|e| format!("{:?}", e)) +} + +async fn send_session_event(installation: &InstallationData) -> Result<(), String> { + let client = posthog_rs::client(POSTHOG_API_KEY).await; + let mut event = posthog_rs::Event::new("session_started", &installation.installation_id); + + event.insert_prop("os", std::env::consts::OS).ok(); + event.insert_prop("arch", std::env::consts::ARCH).ok(); + event.insert_prop("version", env!("CARGO_PKG_VERSION")).ok(); + + if let Some(platform_version) = get_platform_version() { + event.insert_prop("platform_version", platform_version).ok(); + } + + event + .insert_prop("install_method", detect_install_method()) + .ok(); + + event.insert_prop("interface", get_session_interface()).ok(); + + event + .insert_prop("is_resumed", get_session_is_resumed()) + .ok(); + + event + .insert_prop("session_number", installation.session_count) + .ok(); + let days_since_install = (Utc::now() - installation.first_seen).num_days(); + event + .insert_prop("days_since_install", days_since_install) + .ok(); + + let config = Config::global(); + if let Ok(provider) = config.get_param::("GOOSE_PROVIDER") { + event.insert_prop("provider", provider).ok(); + } + if let Ok(model) = config.get_param::("GOOSE_MODEL") { + event.insert_prop("model", model).ok(); + } + + let extensions = get_enabled_extensions(); + event.insert_prop("extensions_count", extensions.len()).ok(); + let extension_names: Vec = extensions.iter().map(|e| e.name()).collect(); + event.insert_prop("extensions", extension_names).ok(); + + if let Ok(insights) = SessionManager::get_insights().await { + event + .insert_prop("total_sessions", insights.total_sessions) + .ok(); + event + .insert_prop("total_tokens", insights.total_tokens) + .ok(); + } + + client.capture(event).await.map_err(|e| format!("{:?}", e)) +} diff --git a/crates/goose/src/providers/errors.rs b/crates/goose/src/providers/errors.rs index b6ee4e7431fe..b72605d944aa 100644 --- a/crates/goose/src/providers/errors.rs +++ b/crates/goose/src/providers/errors.rs @@ -32,6 +32,21 @@ pub enum ProviderError { NotImplemented(String), } +impl ProviderError { + pub fn telemetry_type(&self) -> &'static str { + match self { + ProviderError::Authentication(_) => "auth", + ProviderError::ContextLengthExceeded(_) => "context_length", + ProviderError::RateLimitExceeded { .. } => "rate_limit", + ProviderError::ServerError(_) => "server", + ProviderError::RequestFailed(_) => "request", + ProviderError::ExecutionError(_) => "execution", + ProviderError::UsageError(_) => "usage", + ProviderError::NotImplemented(_) => "not_implemented", + } + } +} + impl From for ProviderError { fn from(error: anyhow::Error) -> Self { if let Some(reqwest_err) = error.downcast_ref::() { diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 427dc0c40939..11ce210ff737 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -120,8 +120,8 @@ pub struct SessionUpdateBuilder { #[derive(Serialize, ToSchema, Debug)] #[serde(rename_all = "camelCase")] pub struct SessionInsights { - total_sessions: usize, - total_tokens: i64, + pub total_sessions: usize, + pub total_tokens: i64, } impl SessionUpdateBuilder { @@ -887,6 +887,7 @@ impl SessionStorage { .await?; tx.commit().await?; + crate::posthog::emit_session_started(); Ok(session) } diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 414b9943c6dc..13ca9aaef7c1 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -14,6 +14,7 @@ import { ErrorUI } from './components/ErrorBoundary'; import { ExtensionInstallModal } from './components/ExtensionInstallModal'; import { ToastContainer } from 'react-toastify'; import AnnouncementModal from './components/AnnouncementModal'; +import TelemetryOptOutModal from './components/TelemetryOptOutModal'; import ProviderGuard from './components/ProviderGuard'; import { createSession } from './sessions'; @@ -702,6 +703,7 @@ export default function App() { + ); } diff --git a/ui/desktop/src/components/ProviderGuard.tsx b/ui/desktop/src/components/ProviderGuard.tsx index 227be70e49bd..7daf9cc70203 100644 --- a/ui/desktop/src/components/ProviderGuard.tsx +++ b/ui/desktop/src/components/ProviderGuard.tsx @@ -10,6 +10,7 @@ import { OllamaSetup } from './OllamaSetup'; import ApiKeyTester from './ApiKeyTester'; import { SwitchModelModal } from './settings/models/subcomponents/SwitchModelModal'; import { createNavigationHandler } from '../utils/navigationUtils'; +import TelemetrySettings from './settings/app/TelemetrySettings'; import { Goose, OpenRouter, Tetrate } from './icons'; @@ -306,6 +307,9 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG Go to Provider Settings → +
+ +
diff --git a/ui/desktop/src/components/TelemetryOptOutModal.tsx b/ui/desktop/src/components/TelemetryOptOutModal.tsx new file mode 100644 index 000000000000..ed7d3b2323c3 --- /dev/null +++ b/ui/desktop/src/components/TelemetryOptOutModal.tsx @@ -0,0 +1,131 @@ +import { useState, useEffect } from 'react'; +import { BaseModal } from './ui/BaseModal'; +import { Button } from './ui/button'; +import { Goose } from './icons/Goose'; +import { TELEMETRY_UI_ENABLED } from '../updates'; +import { toastService } from '../toasts'; +import { useConfig } from './ConfigContext'; + +const TELEMETRY_CONFIG_KEY = 'GOOSE_TELEMETRY_ENABLED'; + +type TelemetryOptOutModalProps = + | { controlled: false } + | { controlled: true; isOpen: boolean; onClose: () => void }; + +export default function TelemetryOptOutModal(props: TelemetryOptOutModalProps) { + const { read, upsert } = useConfig(); + const isControlled = props.controlled; + const controlledIsOpen = isControlled ? props.isOpen : undefined; + const onClose = isControlled ? props.onClose : undefined; + const [showModal, setShowModal] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + // Only check telemetry choice on first launch in uncontrolled mode + useEffect(() => { + if (isControlled) return; + + const checkTelemetryChoice = async () => { + try { + const provider = await read('GOOSE_PROVIDER', false); + + if (!provider || provider === '') { + return; + } + + const telemetryEnabled = await read(TELEMETRY_CONFIG_KEY, false); + + if (telemetryEnabled === null) { + setShowModal(true); + } + } catch (error) { + console.error('Failed to check telemetry config:', error); + toastService.error({ + title: 'Configuration Error', + msg: 'Failed to check telemetry configuration.', + traceback: error instanceof Error ? error.stack || '' : '', + }); + } + }; + + checkTelemetryChoice(); + }, [isControlled, read]); + + const handleChoice = async (enabled: boolean) => { + setIsLoading(true); + try { + await upsert(TELEMETRY_CONFIG_KEY, enabled, false); + setShowModal(false); + onClose?.(); + } catch (error) { + console.error('Failed to set telemetry preference:', error); + setShowModal(false); + onClose?.(); + } finally { + setIsLoading(false); + } + }; + + if (!TELEMETRY_UI_ENABLED) { + return null; + } + + const isModalOpen = controlledIsOpen !== undefined ? controlledIsOpen : showModal; + + if (!isModalOpen) { + return null; + } + + return ( + + + + + } + > +
+
+ +
+

+ Help improve goose +

+

+ Would you like to help improve goose by sharing anonymous usage data? This helps us + understand how goose is used and identify areas for improvement. +

+
+

What we collect:

+
    +
  • Operating system, version, and architecture
  • +
  • goose version and install method
  • +
  • Provider and model used
  • +
  • Extensions and tool usage counts (names only)
  • +
  • Session metrics (duration, interaction count, token usage)
  • +
  • Error types (e.g., "rate_limit", "auth" - no details)
  • +
+

+ We never collect your conversations, code, tool arguments, error messages, or any + personal data. You can change this setting anytime in Settings → App. +

+
+
+
+ ); +} diff --git a/ui/desktop/src/components/icons/Anthropic.tsx b/ui/desktop/src/components/icons/Anthropic.tsx index 7af92a4172e6..2d2ca8a28456 100644 --- a/ui/desktop/src/components/icons/Anthropic.tsx +++ b/ui/desktop/src/components/icons/Anthropic.tsx @@ -9,8 +9,8 @@ export default function Anthropic({ className = '' }) { aria-hidden="true" className={className} > - diff --git a/ui/desktop/src/components/icons/Key.tsx b/ui/desktop/src/components/icons/Key.tsx index 5b7f559f6705..e7ff465db93b 100644 --- a/ui/desktop/src/components/icons/Key.tsx +++ b/ui/desktop/src/components/icons/Key.tsx @@ -22,7 +22,12 @@ export function Key({ className = '' }: KeyProps) { - + diff --git a/ui/desktop/src/components/icons/Tetrate.tsx b/ui/desktop/src/components/icons/Tetrate.tsx index 011253c93796..7a088f2d2312 100644 --- a/ui/desktop/src/components/icons/Tetrate.tsx +++ b/ui/desktop/src/components/icons/Tetrate.tsx @@ -9,12 +9,12 @@ export default function Tetrate({ className = '' }) { aria-hidden="true" className={className} > - - diff --git a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx index f5564521d80c..c0b82f9e56a3 100644 --- a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx +++ b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx @@ -11,6 +11,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../.. import ThemeSelector from '../../GooseSidebar/ThemeSelector'; import BlockLogoBlack from './icons/block-lockup_black.png'; import BlockLogoWhite from './icons/block-lockup_white.png'; +import TelemetrySettings from './TelemetrySettings'; interface AppSettingsSectionProps { scrollToSection?: string; @@ -288,7 +289,7 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti

Prevent Sleep

- Keep your computer awake while Goose is running a task (screen can still lock) + Keep your computer awake while goose is running a task (screen can still lock)

@@ -397,6 +398,8 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti + + Help & feedback diff --git a/ui/desktop/src/components/settings/app/TelemetrySettings.tsx b/ui/desktop/src/components/settings/app/TelemetrySettings.tsx new file mode 100644 index 000000000000..6f24a528c955 --- /dev/null +++ b/ui/desktop/src/components/settings/app/TelemetrySettings.tsx @@ -0,0 +1,128 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Switch } from '../../ui/switch'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card'; +import { useConfig } from '../../ConfigContext'; +import { TELEMETRY_UI_ENABLED } from '../../../updates'; +import TelemetryOptOutModal from '../../TelemetryOptOutModal'; +import { toastService } from '../../../toasts'; + +const TELEMETRY_CONFIG_KEY = 'GOOSE_TELEMETRY_ENABLED'; + +interface TelemetrySettingsProps { + isWelcome: boolean; +} + +export default function TelemetrySettings({ isWelcome = false }: TelemetrySettingsProps) { + const { read, upsert } = useConfig(); + const [telemetryEnabled, setTelemetryEnabled] = useState(true); + const [isLoading, setIsLoading] = useState(true); + const [showModal, setShowModal] = useState(false); + + const loadTelemetryStatus = useCallback(async () => { + try { + const value = await read(TELEMETRY_CONFIG_KEY, false); + setTelemetryEnabled(value === null ? true : Boolean(value)); + } catch (error) { + console.error('Failed to load telemetry status:', error); + toastService.error({ + title: 'Configuration Error', + msg: 'Failed to load telemetry settings.', + traceback: error instanceof Error ? error.stack || '' : '', + }); + } finally { + setIsLoading(false); + } + }, [read]); + + useEffect(() => { + loadTelemetryStatus(); + }, [loadTelemetryStatus]); + + const handleTelemetryToggle = async (checked: boolean) => { + try { + await upsert(TELEMETRY_CONFIG_KEY, checked, false); + setTelemetryEnabled(checked); + } catch (error) { + console.error('Failed to update telemetry status:', error); + toastService.error({ + title: 'Configuration Error', + msg: 'Failed to update telemetry settings.', + traceback: error instanceof Error ? error.stack || '' : '', + }); + } + }; + + const handleModalClose = () => { + setShowModal(false); + loadTelemetryStatus(); + }; + + if (!TELEMETRY_UI_ENABLED) { + return null; + } + + const title = 'Privacy'; + const description = 'Control how your data is used'; + const toggleLabel = 'Anonymous usage data'; + const toggleDescription = 'Help improve goose by sharing anonymous usage statistics.'; + + const learnMoreLink = ( + + ); + + const toggle = ( + + ); + + const modal = ; + + const toggleRow = ( +
+
+

+ {toggleLabel} +

+

+ {toggleDescription} {learnMoreLink} +

+
+
{toggle}
+
+ ); + + if (isWelcome) { + return ( + <> +
+

{title}

+

{description}

+ {toggleRow} +
+ {modal} + + ); + } + + return ( + <> + + + {title} + {description} + + {toggleRow} + + {modal} + + ); +} diff --git a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx index 73bb0c8bcb1a..a1c346359093 100644 --- a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx +++ b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx @@ -92,6 +92,7 @@ export const SwitchModelModal = ({ const [selectedPredefinedModel, setSelectedPredefinedModel] = useState(null); const [predefinedModels, setPredefinedModels] = useState([]); const [loadingModels, setLoadingModels] = useState(false); + const [userClearedModel, setUserClearedModel] = useState(false); // Validate form data const validateForm = useCallback(() => { @@ -265,7 +266,8 @@ export const SwitchModelModal = ({ : []; useEffect(() => { - if (!provider || loadingModels || model || isCustomModel) return; + // Don't auto-select if user explicitly cleared the model + if (!provider || loadingModels || model || isCustomModel || userClearedModel) return; const providerModels = modelOptions .filter((group) => group.options[0]?.provider === provider) @@ -277,7 +279,7 @@ export const SwitchModelModal = ({ setModel(preferredModel); } } - }, [provider, modelOptions, loadingModels, model, isCustomModel]); + }, [provider, modelOptions, loadingModels, model, isCustomModel, userClearedModel]); // Handle model selection change const handleModelChange = (newValue: unknown) => { @@ -285,9 +287,16 @@ export const SwitchModelModal = ({ if (selectedOption?.value === 'custom') { setIsCustomModel(true); setModel(''); + setUserClearedModel(false); + } else if (selectedOption === null) { + // User cleared the selection + setIsCustomModel(false); + setModel(''); + setUserClearedModel(true); } else { setIsCustomModel(false); setModel(selectedOption?.value || ''); + setUserClearedModel(false); } }; @@ -428,6 +437,7 @@ export const SwitchModelModal = ({ setProvider(option?.value || null); setModel(''); setIsCustomModel(false); + setUserClearedModel(false); } }} placeholder="Provider, type to search" @@ -445,26 +455,19 @@ export const SwitchModelModal = ({