From fda42daa4ff6704d8cf41e255e91b5ca07e033e5 Mon Sep 17 00:00:00 2001 From: jh-block Date: Mon, 9 Mar 2026 16:08:05 +0100 Subject: [PATCH] feat: Add in-app feedback collection widget to desktop GUI Implement a low-interruption periodic feedback prompt that appears inline in the chat. After every 5th message exchange, users see a "How's Goose doing?" banner with 4 emoji-face rating buttons (1-4 scale). Ratings are sent via the existing PostHog telemetry pipeline. - New hook `useFeedbackPrompt` manages trigger heuristics: every 5th exchange, 20-minute in-session cooldown, 24-hour cross-session cooldown - New component `FeedbackBanner` renders inline within chat scroll area with emoji ratings and dismiss button - Analytics events `feedback_submitted` and `feedback_dismissed` track user sentiment - Settings persistence for cross-session cooldown via `lastFeedbackTimestamp` - Respects telemetry opt-out: banner never shows if telemetry is disabled Co-Authored-By: Claude Haiku 4.5 --- ui/desktop/package-lock.json | 28 +------ ui/desktop/src/components/BaseChat.tsx | 15 +++- ui/desktop/src/components/FeedbackBanner.tsx | 70 ++++++++++++++++ ui/desktop/src/hooks/useFeedbackPrompt.ts | 87 ++++++++++++++++++++ ui/desktop/src/main.ts | 1 + ui/desktop/src/utils/analytics.ts | 46 ++++++++++- ui/desktop/src/utils/settings.ts | 2 + 7 files changed, 220 insertions(+), 29 deletions(-) create mode 100644 ui/desktop/src/components/FeedbackBanner.tsx create mode 100644 ui/desktop/src/hooks/useFeedbackPrompt.ts diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index 7ea56dbf18..294d05ec95 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -123,7 +123,7 @@ } }, "../acp": { - "name": "goose-acp-types", + "name": "@block/goose-acp", "version": "0.1.0", "dependencies": { "zod": "^3.25.76" @@ -10330,32 +10330,6 @@ "node": ">= 0.8" } }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index cee981fb93..5a47d434f8 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -42,6 +42,8 @@ import { Recipe } from '../recipe'; import { useAutoSubmit } from '../hooks/useAutoSubmit'; import { Goose } from './icons'; import EnvironmentBadge from './GooseSidebar/EnvironmentBadge'; +import FeedbackBanner from './FeedbackBanner'; +import { useFeedbackPrompt } from '../hooks/useFeedbackPrompt'; const CurrentModelContext = createContext<{ model: string; mode: string } | null>(null); export const useCurrentModelInfo = () => useContext(CurrentModelContext); @@ -182,7 +184,7 @@ export default function BaseChat({ session, }); - const { setProviderAndModel } = useModelAndProvider(); + const { setProviderAndModel, currentModel, currentProvider } = useModelAndProvider(); useEffect(() => { if (session?.provider_name && session?.model_config?.model_name) { @@ -233,6 +235,13 @@ export default function BaseChat({ const toolCount = useToolCount(sessionId); + const { showFeedback, onRate, onDismiss } = useFeedbackPrompt({ + messageCount: messages.length, + chatState, + provider: currentProvider, + model: currentModel, + }); + // Listen for global scroll-to-bottom requests (e.g., from MCP UI prompt actions) useEffect(() => { const handleGlobalScrollRequest = () => { @@ -450,6 +459,10 @@ export default function BaseChat({ /> + {showFeedback && chatState === ChatState.Idle && ( + + )} +
) : !recipe && showPopularTopics ? ( diff --git a/ui/desktop/src/components/FeedbackBanner.tsx b/ui/desktop/src/components/FeedbackBanner.tsx new file mode 100644 index 0000000000..af0b8c5915 --- /dev/null +++ b/ui/desktop/src/components/FeedbackBanner.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react'; + +interface FeedbackBannerProps { + onRate: (rating: 1 | 2 | 3 | 4) => void; + onDismiss: () => void; +} + +const ratingOptions: Array<{ rating: 1 | 2 | 3 | 4; emoji: string; label: string }> = [ + { rating: 1, emoji: '\u{1F623}', label: 'Frustrating' }, + { rating: 2, emoji: '\u{1F615}', label: 'Poor' }, + { rating: 3, emoji: '\u{1F642}', label: 'Good' }, + { rating: 4, emoji: '\u{1F929}', label: 'Great' }, +]; + +export default function FeedbackBanner({ onRate, onDismiss }: FeedbackBannerProps) { + const [submitted, setSubmitted] = useState(false); + + const handleRate = (rating: 1 | 2 | 3 | 4) => { + setSubmitted(true); + setTimeout(() => onRate(rating), 1000); + }; + + if (submitted) { + return ( +
+ Thanks for the feedback! +
+ ); + } + + return ( +
+ How's Goose doing? +
+ {ratingOptions.map(({ rating, emoji, label }) => ( + + ))} +
+ +
+ ); +} diff --git a/ui/desktop/src/hooks/useFeedbackPrompt.ts b/ui/desktop/src/hooks/useFeedbackPrompt.ts new file mode 100644 index 0000000000..7ff4e47b7d --- /dev/null +++ b/ui/desktop/src/hooks/useFeedbackPrompt.ts @@ -0,0 +1,87 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { canTrack, trackFeedbackSubmitted, trackFeedbackDismissed } from '../utils/analytics'; +import { ChatState } from '../types/chatState'; + +const EXCHANGE_INTERVAL = 5; // Show after every Nth exchange +const IN_SESSION_COOLDOWN_MS = 20 * 60 * 1000; // 20 minutes +const CROSS_SESSION_COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24 hours + +interface UseFeedbackPromptOptions { + messageCount: number; + chatState: ChatState; + provider?: string | null; + model?: string | null; +} + +export function useFeedbackPrompt({ + messageCount, + chatState, + provider, + model, +}: UseFeedbackPromptOptions) { + const [showFeedback, setShowFeedback] = useState(false); + const exchangeCountRef = useRef(0); + const lastPromptTimeRef = useRef(0); + const prevChatStateRef = useRef(chatState); + + useEffect(() => { + const wasStreaming = prevChatStateRef.current !== ChatState.Idle; + const isNowIdle = chatState === ChatState.Idle; + prevChatStateRef.current = chatState; + + // Only trigger when transitioning from streaming to idle + if (!wasStreaming || !isNowIdle) return; + + // Check telemetry at event time (it can change during the session) + if (!canTrack()) return; + + exchangeCountRef.current += 1; + const count = exchangeCountRef.current; + + // Only show on every Nth exchange, never before the first interval + if (count < EXCHANGE_INTERVAL || count % EXCHANGE_INTERVAL !== 0) return; + + // In-session cooldown + const now = Date.now(); + if (now - lastPromptTimeRef.current < IN_SESSION_COOLDOWN_MS) return; + + // Cross-session cooldown (async check) + (async () => { + try { + const lastTimestamp = + (await window.electron.getSetting('lastFeedbackTimestamp')) as number; + if (lastTimestamp && now - lastTimestamp < CROSS_SESSION_COOLDOWN_MS) return; + } catch { + // If settings read fails, proceed anyway + } + + lastPromptTimeRef.current = now; + setShowFeedback(true); + })(); + }, [chatState]); + + const onRate = useCallback( + (rating: 1 | 2 | 3 | 4) => { + setShowFeedback(false); + trackFeedbackSubmitted(rating, messageCount, provider || undefined, model || undefined); + try { + window.electron.setSetting('lastFeedbackTimestamp', Date.now()); + } catch { + // Best-effort persistence + } + }, + [messageCount, provider, model] + ); + + const onDismiss = useCallback(() => { + setShowFeedback(false); + trackFeedbackDismissed(messageCount); + try { + window.electron.setSetting('lastFeedbackTimestamp', Date.now()); + } catch { + // Best-effort persistence + } + }, [messageCount]); + + return { showFeedback, onRate, onDismiss }; +} diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index bfba32cb8d..6819a1aa52 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -1289,6 +1289,7 @@ const validSettingKeys: Set = new Set([ 'showPricing', 'sessionSharing', 'seenAnnouncementIds', + 'lastFeedbackTimestamp', ]); ipcMain.handle('set-setting', (_event, key: SettingKey, value: unknown) => { diff --git a/ui/desktop/src/utils/analytics.ts b/ui/desktop/src/utils/analytics.ts index 4e85690291..1d66fc26d2 100644 --- a/ui/desktop/src/utils/analytics.ts +++ b/ui/desktop/src/utils/analytics.ts @@ -26,7 +26,7 @@ export function setTelemetryEnabled(enabled: boolean): void { telemetryEnabled = enabled; } -function canTrack(): boolean { +export function canTrack(): boolean { return telemetryEnabled === true; } @@ -234,6 +234,22 @@ export type AnalyticsEvent = method: 'electron-updater' | 'github-fallback'; action: 'quit_and_install' | 'open_folder_and_quit' | 'open_folder_only'; }; + } + // In-session feedback + | { + name: 'feedback_submitted'; + properties: { + rating: 1 | 2 | 3 | 4; + session_message_count: number; + provider?: string; + model?: string; + }; + } + | { + name: 'feedback_dismissed'; + properties: { + session_message_count: number; + }; }; // NOTE: slash_command_used is tracked by the backend (posthog.rs) with command_type info @@ -752,3 +768,31 @@ export function trackUpdateInstallInitiated( properties: { version, method, action }, }); } + +// ============================================================================ +// In-Session Feedback Tracking +// ============================================================================ + +export function trackFeedbackSubmitted( + rating: 1 | 2 | 3 | 4, + sessionMessageCount: number, + provider?: string, + model?: string +): void { + trackEvent({ + name: 'feedback_submitted', + properties: { + rating, + session_message_count: sessionMessageCount, + provider, + model, + }, + }); +} + +export function trackFeedbackDismissed(sessionMessageCount: number): void { + trackEvent({ + name: 'feedback_dismissed', + properties: { session_message_count: sessionMessageCount }, + }); +} diff --git a/ui/desktop/src/utils/settings.ts b/ui/desktop/src/utils/settings.ts index 31226f0685..0648015463 100644 --- a/ui/desktop/src/utils/settings.ts +++ b/ui/desktop/src/utils/settings.ts @@ -44,6 +44,7 @@ export interface Settings { showPricing: boolean; sessionSharing: SessionSharingConfig; seenAnnouncementIds: string[]; + lastFeedbackTimestamp: number; } export type SettingKey = keyof Settings; @@ -85,6 +86,7 @@ export const defaultSettings: Settings = { baseUrl: '', }, seenAnnouncementIds: [], + lastFeedbackTimestamp: 0, }; export function getKeyboardShortcuts(settings: Settings): KeyboardShortcuts {