Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 1 addition & 27 deletions ui/desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 14 additions & 1 deletion ui/desktop/src/components/BaseChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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 ? (
Expand Down
122 changes: 122 additions & 0 deletions ui/desktop/src/components/FeedbackBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
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' },
];

const DISCORD_URL = 'https://discord.gg/goose-oss';

export default function FeedbackBanner({ onRate, onDismiss }: FeedbackBannerProps) {
const [submittedRating, setSubmittedRating] = useState<1 | 2 | 3 | 4 | null>(null);

const handleRate = (rating: 1 | 2 | 3 | 4) => {
setSubmittedRating(rating);
if (rating > 1) {
setTimeout(() => onRate(rating), 1000);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why the time out

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the rating is not the worst one, it shows an acknowledgement ("Thanks for the feedback!") briefly and then removes the whole feedback banner so that it doesn't clutter the chat. Without the timeout it would disappear straight away.

If the rating is the worst one it shows "Sorry to hear that. Let us know how we can improve:" with a link to discord, and doesn't remove it automatically -- it removes it when you either click the discord link or click to dismiss the message

definitely open to any UX feedback here

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we have a toast mechanism for that already?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we have one that's inline in the chat? it might be a bit jarring to do the top-right toast which is quite distant from where the interaction is. i'll have a play with it

}
};

const handleDismissAfterLowRating = () => {
if (submittedRating) {
onRate(submittedRating);
}
};

if (submittedRating === 1) {
return (
<div className="flex flex-col items-center gap-1.5 py-2 animate-[fadein_300ms_ease-in_forwards]">
<span className="text-xs text-text-secondary">
Sorry to hear that. Let us know how we can improve:
</span>
<div className="flex items-center gap-3 text-xs">
<a
href={DISCORD_URL}
target="_blank"
rel="noopener noreferrer"
onClick={handleDismissAfterLowRating}
className="text-text-secondary underline hover:text-text-primary transition-colors"
>
Chat on Discord
</a>
<button
onClick={handleDismissAfterLowRating}
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"
>
<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>
</div>
);
}

if (submittedRating) {
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 changes: 87 additions & 0 deletions ui/desktop/src/hooks/useFeedbackPrompt.ts
Original file line number Diff line number Diff line change
@@ -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 = 2 * 60 * 60 * 1000; // 2 hours
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 change: 1 addition & 0 deletions ui/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1289,6 +1289,7 @@ const validSettingKeys: Set<string> = new Set([
'showPricing',
'sessionSharing',
'seenAnnouncementIds',
'lastFeedbackTimestamp',
]);

ipcMain.handle('set-setting', (_event, key: SettingKey, value: unknown) => {
Expand Down
46 changes: 45 additions & 1 deletion ui/desktop/src/utils/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function setTelemetryEnabled(enabled: boolean): void {
telemetryEnabled = enabled;
}

function canTrack(): boolean {
export function canTrack(): boolean {
return telemetryEnabled === true;
}

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 changes: 2 additions & 0 deletions ui/desktop/src/utils/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface Settings {
showPricing: boolean;
sessionSharing: SessionSharingConfig;
seenAnnouncementIds: string[];
lastFeedbackTimestamp: number;
}

export type SettingKey = keyof Settings;
Expand Down Expand Up @@ -85,6 +86,7 @@ export const defaultSettings: Settings = {
baseUrl: '',
},
seenAnnouncementIds: [],
lastFeedbackTimestamp: 0,
};

export function getKeyboardShortcuts(settings: Settings): KeyboardShortcuts {
Expand Down
Loading