diff --git a/crates/goose/src/posthog.rs b/crates/goose/src/posthog.rs index c0401160af33..93bef449d0b3 100644 --- a/crates/goose/src/posthog.rs +++ b/crates/goose/src/posthog.rs @@ -7,15 +7,37 @@ use std::sync::atomic::{AtomicBool, Ordering}; const POSTHOG_API_KEY: &str = "phc_RyX5CaY01VtZJCQyhSR5KFh6qimUy81YwxsEpotAftT"; -static TELEMETRY_DISABLED: Lazy = Lazy::new(|| { +/// 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 { + // Environment variable takes precedence + if TELEMETRY_DISABLED_BY_ENV.load(Ordering::Relaxed) { + return false; + } + + let config = Config::global(); + config + .get_param::(TELEMETRY_ENABLED_KEY) + .unwrap_or(true) +} + pub fn emit_session_started() { - if TELEMETRY_DISABLED.load(Ordering::Relaxed) { + if !is_telemetry_enabled() { return; } 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..f1dbf198dd2b --- /dev/null +++ b/ui/desktop/src/components/TelemetryOptOutModal.tsx @@ -0,0 +1,130 @@ +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 and architecture
  • +
  • goose version
  • +
  • Provider and model used
  • +
  • Number of extensions enabled
  • +
  • Session count and token usage (aggregated)
  • +
+

+ We never collect your conversations, code, 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 = ({