mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
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:
Generated
+1
-27
@@ -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",
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1289,6 +1289,7 @@ const validSettingKeys: Set<string> = new Set([
|
||||
'showPricing',
|
||||
'sessionSharing',
|
||||
'seenAnnouncementIds',
|
||||
'lastFeedbackTimestamp',
|
||||
]);
|
||||
|
||||
ipcMain.handle('set-setting', (_event, key: SettingKey, value: unknown) => {
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user