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 <noreply@anthropic.com>
This commit is contained in:
jh-block
2026-03-09 16:08:05 +01:00
parent 9ab5c45e3a
commit fda42daa4f
7 changed files with 220 additions and 29 deletions
+1 -27
View File
@@ -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",
+14 -1
View File
@@ -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({
/>
</SearchView>
{showFeedback && chatState === ChatState.Idle && (
<FeedbackBanner onRate={onRate} onDismiss={onDismiss} />
)}
<div className="block h-8" />
</>
) : !recipe && showPopularTopics ? (
@@ -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 (
<div className="flex items-center justify-center py-2 text-xs text-text-secondary animate-[fadein_300ms_ease-in_forwards]">
Thanks for the feedback!
</div>
);
}
return (
<div className="flex items-center justify-center gap-3 py-2 animate-[fadein_300ms_ease-in_forwards]">
<span className="text-xs text-text-secondary">How&apos;s Goose doing?</span>
<div className="flex items-center gap-1">
{ratingOptions.map(({ rating, emoji, label }) => (
<button
key={rating}
onClick={() => handleRate(rating)}
className="p-1 rounded hover:bg-background-secondary transition-colors cursor-pointer"
title={label}
aria-label={label}
>
<span className="text-base">{emoji}</span>
</button>
))}
</div>
<button
onClick={onDismiss}
className="ml-1 p-0.5 rounded text-text-secondary hover:text-text-primary hover:bg-background-secondary transition-colors cursor-pointer"
title="Dismiss"
aria-label="Dismiss feedback prompt"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
);
}
+87
View File
@@ -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>(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 };
}
+1
View File
@@ -1289,6 +1289,7 @@ const validSettingKeys: Set<string> = new Set([
'showPricing',
'sessionSharing',
'seenAnnouncementIds',
'lastFeedbackTimestamp',
]);
ipcMain.handle('set-setting', (_event, key: SettingKey, value: unknown) => {
+45 -1
View File
@@ -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 },
});
}
+2
View File
@@ -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 {