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 {