diff --git a/apps/web/public/elements/feedback.png b/apps/web/public/elements/feedback.png new file mode 100644 index 000000000..3ee853bd3 Binary files /dev/null and b/apps/web/public/elements/feedback.png differ diff --git a/apps/web/src/app/(authenticated)/home/BottomSheetTabs.client.test.tsx b/apps/web/src/app/(authenticated)/home/BottomSheetTabs.client.test.tsx new file mode 100644 index 000000000..35dce35c7 --- /dev/null +++ b/apps/web/src/app/(authenticated)/home/BottomSheetTabs.client.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from '@testing-library/react'; + +vi.mock('./PullRequestsList', () => ({ + PullRequestsList: () =>
Pull requests
, +})); + +vi.mock('./RecentTasksList', () => ({ + RecentTasksList: () =>
Recent tasks
, +})); + +import { BottomSheetTabs } from './BottomSheetTabs'; + +beforeEach(() => { + window.localStorage.clear(); +}); + +it('renders the task tabs without the feedback prompt', () => { + render(); + + expect( + screen.getByRole('button', { name: 'Recent Tasks' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Recent PRs' }), + ).toBeInTheDocument(); + expect(screen.queryByText('Feedback, please!')).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/app/(authenticated)/home/BottomSheetTabs.tsx b/apps/web/src/app/(authenticated)/home/BottomSheetTabs.tsx index 56e431445..5f0c798e8 100644 --- a/apps/web/src/app/(authenticated)/home/BottomSheetTabs.tsx +++ b/apps/web/src/app/(authenticated)/home/BottomSheetTabs.tsx @@ -108,17 +108,13 @@ export function BottomSheetTabs({ onExpandedChange }: BottomSheetTabsProps) { return (
-
+
- +
+ +
{ expect(mockCreateStandardTaskRun).not.toHaveBeenCalled(); }); + it('renders the feedback prompt below the input and opens its dialog', async () => { + render(); + + fireEvent.click( + await screen.findByRole('button', { name: 'Feedback, please!' }), + ); + + expect( + screen.getByRole('dialog', { + name: 'What do you think of Roomote so far?', + }), + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'Schedule time with the team' }), + ).toHaveAttribute( + 'href', + 'https://calendly.com/d/ctx9-f7q-6vr/roomote-feedback', + ); + expect(screen.getByRole('link', { name: 'Email us' })).toHaveAttribute( + 'href', + 'mailto:help@roomote.dev?subject=My%20thoughts%20on%20Roomote%20so%20far', + ); + }); + + it('persists dismissal of the feedback prompt', async () => { + const { unmount } = render(); + + fireEvent.click( + await screen.findByRole('button', { name: 'Feedback, please!' }), + ); + fireEvent.click( + screen.getByRole('button', { name: 'Dismiss feedback prompt' }), + ); + + expect(window.localStorage.getItem('roomote-home-feedback-dismissed')).toBe( + '1', + ); + expect( + screen.queryByRole('button', { name: 'Feedback, please!' }), + ).not.toBeInTheDocument(); + + unmount(); + render(); + + expect( + screen.queryByRole('button', { name: 'Feedback, please!' }), + ).not.toBeInTheDocument(); + }); + + it('keeps feedback dismissal usable when storage access fails', async () => { + const feedbackStorageKey = 'roomote-home-feedback-dismissed'; + const originalGetItem = Storage.prototype.getItem; + const originalSetItem = Storage.prototype.setItem; + const getItemSpy = vi + .spyOn(Storage.prototype, 'getItem') + .mockImplementation((key) => { + if (key === feedbackStorageKey) { + throw new Error('Storage access blocked'); + } + + return originalGetItem.call(window.localStorage, key); + }); + const setItemSpy = vi + .spyOn(Storage.prototype, 'setItem') + .mockImplementation((key, value) => { + if (key === feedbackStorageKey) { + throw new Error('Storage quota exceeded'); + } + + originalSetItem.call(window.localStorage, key, value); + }); + + try { + render(); + + fireEvent.click( + await screen.findByRole('button', { name: 'Feedback, please!' }), + ); + fireEvent.click( + screen.getByRole('button', { name: 'Dismiss feedback prompt' }), + ); + + expect( + screen.queryByRole('button', { name: 'Feedback, please!' }), + ).not.toBeInTheDocument(); + } finally { + getItemSpy.mockRestore(); + setItemSpy.mockRestore(); + } + }); + it('shows a toast and does not launch a task for platform answers', async () => { mockRouteHomeTask.mockResolvedValue({ status: 'platform_answer', diff --git a/apps/web/src/app/(authenticated)/home/Home.tsx b/apps/web/src/app/(authenticated)/home/Home.tsx index 18dc0b839..0080d888e 100644 --- a/apps/web/src/app/(authenticated)/home/Home.tsx +++ b/apps/web/src/app/(authenticated)/home/Home.tsx @@ -36,7 +36,17 @@ import { useCreateStandardTaskRun, useRouteHomeTask } from '@/hooks/task-runs'; import { Alert, ArrowRight, + Button, + Calendar, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, Loader2, + Mail, + MessageCirclePlus, Select, SelectContent, SelectItem, @@ -54,12 +64,36 @@ import { import { OnboardingCard } from './OnboardingCard'; import { BottomSheetTabs } from './BottomSheetTabs'; +import Image from 'next/image'; +import { DiscordLogoIcon } from '@radix-ui/react-icons'; import { HOME_PROMPT_PLACEHOLDERS, normalizeHomePromptPlaceholderIndex, } from './promptPlaceholders'; const FALLBACK_PROMPT_PLACEHOLDER = 'What do you want to do?'; +const FEEDBACK_DISMISSED_STORAGE_KEY = 'roomote-home-feedback-dismissed'; +const FEEDBACK_CALENDLY_URL = + 'https://calendly.com/d/ctx9-f7q-6vr/roomote-feedback'; +const FEEDBACK_EMAIL_URL = + 'mailto:help@roomote.dev?subject=My%20thoughts%20on%20Roomote%20so%20far'; +const FEEDBACK_DISCORD_URL = 'https://discord.gg/roomote'; + +function isFeedbackPromptDismissed(): boolean { + try { + return window.localStorage.getItem(FEEDBACK_DISMISSED_STORAGE_KEY) === '1'; + } catch { + return false; + } +} + +function persistFeedbackPromptDismissal(): void { + try { + window.localStorage.setItem(FEEDBACK_DISMISSED_STORAGE_KEY, '1'); + } catch { + // Ignore storage failures; the prompt can still be dismissed for this session. + } +} type RoutingFlowState = 'idle' | 'routing_pending' | 'launching'; @@ -147,6 +181,8 @@ export function Home({ const [selectedModelOverrideId, setSelectedModelOverrideId] = useState(); const [isBottomSheetExpanded, setIsBottomSheetExpanded] = useState(false); + const [isFeedbackPromptVisible, setIsFeedbackPromptVisible] = useState(false); + const [isFeedbackDialogOpen, setIsFeedbackDialogOpen] = useState(false); const [isShortViewport, setIsShortViewport] = useState(false); const [placeholderIndex, setPlaceholderIndex] = useState(() => normalizeHomePromptPlaceholderIndex(initialPlaceholderIndex), @@ -167,6 +203,10 @@ export function Home({ useEffect(() => setPromptText(promptParam), [promptParam]); + useEffect(() => { + setIsFeedbackPromptVisible(!isFeedbackPromptDismissed()); + }, []); + useEffect(() => { setPlaceholderIndex( normalizeHomePromptPlaceholderIndex(initialPlaceholderIndex), @@ -714,6 +754,16 @@ export function Home({
+ {isFeedbackPromptVisible ? ( + + ) : null}
@@ -721,6 +771,75 @@ export function Home({
+ + + + + What do you think of Roomote so far? + + We'd love to hear about your experience. Anything helps. + + + +
+ + + + +
+ + + + +
+
); } diff --git a/apps/web/src/components/system/primitives/icons.ts b/apps/web/src/components/system/primitives/icons.ts index 1209e6c5d..2f8868c5e 100644 --- a/apps/web/src/components/system/primitives/icons.ts +++ b/apps/web/src/components/system/primitives/icons.ts @@ -119,6 +119,7 @@ export { Maximize2, Menu, Megaphone, + MessageCirclePlus, MessageCircleQuestionMark, MessageSquareCode, MessageSquareIcon,