From 60fc0fc5daea4fe0294e0c3cf8d1916cc5335317 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Wed, 4 Feb 2026 17:33:54 -0600 Subject: [PATCH] Add Windows support notice modal for Windows users on startup Shows a modal on first launch for Windows users explaining that Windows support is actively being improved. Features: - Inline toggle to enable beta updates for latest bug fixes - Link to report issues on GitHub (with note that vetted PRs welcome) - Link to join Discord Windows-specific channel for community support - Create Debug Package button for easy bug reporting - Checkbox to suppress the modal for future sessions - Console debug function: window.__showWindowsWarningModal() --- .../components/WindowsWarningModal.test.tsx | 250 +++++++++++++++++ src/renderer/App.tsx | 44 +++ .../components/WindowsWarningModal.tsx | 254 ++++++++++++++++++ src/renderer/constants/modalPriorities.ts | 3 + src/renderer/contexts/ModalContext.tsx | 13 + src/renderer/hooks/settings/useSettings.ts | 23 ++ 6 files changed, 587 insertions(+) create mode 100644 src/__tests__/renderer/components/WindowsWarningModal.test.tsx create mode 100644 src/renderer/components/WindowsWarningModal.tsx diff --git a/src/__tests__/renderer/components/WindowsWarningModal.test.tsx b/src/__tests__/renderer/components/WindowsWarningModal.test.tsx new file mode 100644 index 00000000..6be6f124 --- /dev/null +++ b/src/__tests__/renderer/components/WindowsWarningModal.test.tsx @@ -0,0 +1,250 @@ +/** + * Tests for WindowsWarningModal component + * + * Tests the core behavior of the Windows warning dialog: + * - Rendering with correct content + * - Button click handlers + * - Suppress checkbox functionality + * - Debug function exposure + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { WindowsWarningModal, exposeWindowsWarningModalDebug } from '../../../renderer/components/WindowsWarningModal'; +import { LayerStackProvider } from '../../../renderer/contexts/LayerStackContext'; +import type { Theme } from '../../../renderer/types'; + +// Mock lucide-react +vi.mock('lucide-react', () => ({ + X: () => , + AlertTriangle: () => , + Bug: () => , + Wrench: () => , + ExternalLink: () => , + Command: () => , + Check: () => , + MessageCircle: () => , +})); + +// Create a test theme +const testTheme: Theme = { + id: 'test-theme', + name: 'Test Theme', + mode: 'dark', + colors: { + bgMain: '#1e1e1e', + bgSidebar: '#252526', + bgActivity: '#333333', + textMain: '#d4d4d4', + textDim: '#808080', + accent: '#007acc', + border: '#404040', + error: '#f14c4c', + warning: '#cca700', + success: '#89d185', + info: '#3794ff', + textInverse: '#000000', + accentForeground: '#ffffff', + }, +}; + +// Helper to render with LayerStackProvider +const renderWithLayerStack = (ui: React.ReactElement) => { + return render({ui}); +}; + +describe('WindowsWarningModal', () => { + const defaultProps = { + theme: testTheme, + isOpen: true, + onClose: vi.fn(), + onSuppressFuture: vi.fn(), + onOpenDebugPackage: vi.fn(), + useBetaChannel: false, + onSetUseBetaChannel: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + // Mock shell.openExternal for this test suite + vi.mocked(window.maestro.shell.openExternal).mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('rendering', () => { + it('renders when isOpen is true', () => { + renderWithLayerStack(); + + expect(screen.getByText('Windows Support Notice')).toBeInTheDocument(); + }); + + it('does not render when isOpen is false', () => { + renderWithLayerStack(); + + expect(screen.queryByText('Windows Support Notice')).not.toBeInTheDocument(); + }); + + it('displays recommendations section', () => { + renderWithLayerStack(); + + expect(screen.getByText('Recommendations')).toBeInTheDocument(); + expect(screen.getByText('Enable Beta Updates')).toBeInTheDocument(); + expect(screen.getByText('Report Issues')).toBeInTheDocument(); + expect(screen.getByText('Join Discord')).toBeInTheDocument(); + expect(screen.getByText('Create Debug Package')).toBeInTheDocument(); + }); + + it('displays suppress checkbox', () => { + renderWithLayerStack(); + + expect(screen.getByText("Don't show this message again")).toBeInTheDocument(); + }); + + it('displays Got it! button', () => { + renderWithLayerStack(); + + expect(screen.getByRole('button', { name: 'Got it!' })).toBeInTheDocument(); + }); + }); + + describe('button handlers', () => { + it('calls onClose when Got it! button is clicked', () => { + const onClose = vi.fn(); + renderWithLayerStack(); + + fireEvent.click(screen.getByRole('button', { name: 'Got it!' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when X button is clicked', () => { + const onClose = vi.fn(); + renderWithLayerStack(); + + // Find the X icon and click its parent button + const closeButton = screen.getByTestId('x-icon').closest('button'); + fireEvent.click(closeButton!); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onSetUseBetaChannel with true when Enable Beta Updates is clicked', () => { + const onSetUseBetaChannel = vi.fn(); + renderWithLayerStack(); + + fireEvent.click(screen.getByText('Enable Beta Updates')); + expect(onSetUseBetaChannel).toHaveBeenCalledWith(true); + }); + + it('calls onSetUseBetaChannel with false when Beta Updates toggle is clicked while enabled', () => { + const onSetUseBetaChannel = vi.fn(); + renderWithLayerStack(); + + fireEvent.click(screen.getByText('Enable Beta Updates')); + expect(onSetUseBetaChannel).toHaveBeenCalledWith(false); + }); + + it('opens GitHub issues when Report Issues is clicked', () => { + renderWithLayerStack(); + + fireEvent.click(screen.getByText('Report Issues')); + expect(window.maestro.shell.openExternal).toHaveBeenCalledWith('https://github.com/pedramamini/Maestro/issues'); + }); + + it('opens Discord when Join Discord is clicked', () => { + renderWithLayerStack(); + + fireEvent.click(screen.getByText('Join Discord')); + expect(window.maestro.shell.openExternal).toHaveBeenCalledWith('https://discord.gg/FCAh4EWzfD'); + }); + + it('calls onOpenDebugPackage when Create Debug Package is clicked', () => { + const onOpenDebugPackage = vi.fn(); + renderWithLayerStack(); + + fireEvent.click(screen.getByText('Create Debug Package')); + expect(onOpenDebugPackage).toHaveBeenCalledTimes(1); + }); + }); + + describe('suppress checkbox', () => { + it('checkbox is unchecked by default', () => { + renderWithLayerStack(); + + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).not.toBeChecked(); + }); + + it('checkbox can be toggled', () => { + renderWithLayerStack(); + + const checkbox = screen.getByRole('checkbox'); + fireEvent.click(checkbox); + expect(checkbox).toBeChecked(); + }); + + it('calls onSuppressFuture with false when closed without checking', () => { + const onSuppressFuture = vi.fn(); + renderWithLayerStack(); + + fireEvent.click(screen.getByRole('button', { name: 'Got it!' })); + expect(onSuppressFuture).toHaveBeenCalledWith(false); + }); + + it('calls onSuppressFuture with true when closed with checkbox checked', () => { + const onSuppressFuture = vi.fn(); + renderWithLayerStack(); + + // Check the checkbox first + fireEvent.click(screen.getByRole('checkbox')); + // Then close + fireEvent.click(screen.getByRole('button', { name: 'Got it!' })); + expect(onSuppressFuture).toHaveBeenCalledWith(true); + }); + }); + + describe('focus management', () => { + it('focuses Got it! button on mount', async () => { + renderWithLayerStack(); + + await waitFor(() => { + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Got it!' })); + }); + }); + }); + + describe('accessibility', () => { + it('has correct ARIA attributes on dialog', () => { + renderWithLayerStack(); + + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + expect(dialog).toHaveAttribute('aria-label', 'Windows Support Notice'); + }); + + it('has tabIndex on dialog for focus', () => { + renderWithLayerStack(); + + expect(screen.getByRole('dialog')).toHaveAttribute('tabIndex', '-1'); + }); + }); + + describe('exposeWindowsWarningModalDebug', () => { + it('exposes __showWindowsWarningModal function to window', () => { + const setShowModal = vi.fn(); + exposeWindowsWarningModalDebug(setShowModal); + + expect((window as any).__showWindowsWarningModal).toBeDefined(); + }); + + it('calls setShowModal with true when __showWindowsWarningModal is invoked', () => { + const setShowModal = vi.fn(); + exposeWindowsWarningModalDebug(setShowModal); + + (window as any).__showWindowsWarningModal(); + expect(setShowModal).toHaveBeenCalledWith(true); + }); + }); +}); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index c39143c7..1ae69f95 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -29,6 +29,7 @@ import { AppOverlays } from './components/AppOverlays'; import { PlaygroundPanel } from './components/PlaygroundPanel'; import { DebugWizardModal } from './components/DebugWizardModal'; import { DebugPackageModal } from './components/DebugPackageModal'; +import { WindowsWarningModal, exposeWindowsWarningModalDebug } from './components/WindowsWarningModal'; import { GistPublishModal, type GistInfo } from './components/GistPublishModal'; import { MaestroWizard, @@ -312,6 +313,9 @@ function MaestroConsoleInner() { // Debug Package Modal debugPackageModalOpen, setDebugPackageModalOpen, + // Windows Warning Modal + windowsWarningModalOpen, + setWindowsWarningModalOpen, // Confirmation Modal confirmModalOpen, setConfirmModalOpen, @@ -571,6 +575,10 @@ function MaestroConsoleInner() { // File tab refresh settings fileTabAutoRefreshEnabled, + + // Windows warning suppression + suppressWindowsWarning, + setSuppressWindowsWarning, } = settings; // --- KEYBOARD SHORTCUT HELPERS --- @@ -1392,6 +1400,31 @@ function MaestroConsoleInner() { }); }, []); + // Show Windows warning modal on startup for Windows users (if not suppressed) + // Also expose a debug function to trigger the modal from console for testing + useEffect(() => { + // Expose debug function regardless of platform (for testing) + exposeWindowsWarningModalDebug(setWindowsWarningModalOpen); + + // Only check platform when settings have loaded (so we know suppress preference) + if (!settingsLoaded) return; + + // Skip if user has suppressed the warning + if (suppressWindowsWarning) return; + + // Check if running on Windows using the power API (has platform info) + window.maestro.power + .getStatus() + .then((status) => { + if (status.platform === 'win32') { + setWindowsWarningModalOpen(true); + } + }) + .catch((error) => { + console.error('[App] Failed to detect platform for Windows warning:', error); + }); + }, [settingsLoaded, suppressWindowsWarning, setWindowsWarningModalOpen]); + // Load file gist URLs from settings on startup useEffect(() => { window.maestro.settings @@ -13746,6 +13779,17 @@ You are taking over this conversation. Based on the context above, provide a bri onClose={handleCloseDebugPackage} /> + {/* --- WINDOWS WARNING MODAL --- */} + setWindowsWarningModalOpen(false)} + onSuppressFuture={setSuppressWindowsWarning} + onOpenDebugPackage={() => setDebugPackageModalOpen(true)} + useBetaChannel={enableBetaUpdates} + onSetUseBetaChannel={setEnableBetaUpdates} + /> + {/* --- CELEBRATION OVERLAYS --- */} void; + onSuppressFuture: (suppress: boolean) => void; + onOpenDebugPackage: () => void; + useBetaChannel: boolean; + onSetUseBetaChannel: (enabled: boolean) => void; +} + +export function WindowsWarningModal({ + theme, + isOpen, + onClose, + onSuppressFuture, + onOpenDebugPackage, + useBetaChannel, + onSetUseBetaChannel, +}: WindowsWarningModalProps) { + const [suppressChecked, setSuppressChecked] = useState(false); + const continueButtonRef = useRef(null); + + // Handle close with suppress preference + const handleClose = useCallback(() => { + onSuppressFuture(suppressChecked); + onClose(); + }, [suppressChecked, onSuppressFuture, onClose]); + + // Handle toggling beta channel + const handleToggleBetaChannel = useCallback(() => { + onSetUseBetaChannel(!useBetaChannel); + }, [useBetaChannel, onSetUseBetaChannel]); + + // Handle opening debug package modal + const handleOpenDebugPackage = useCallback(() => { + onSuppressFuture(suppressChecked); + onOpenDebugPackage(); + onClose(); + }, [suppressChecked, onSuppressFuture, onOpenDebugPackage, onClose]); + + if (!isOpen) return null; + + return ( + } + priority={MODAL_PRIORITIES.WINDOWS_WARNING} + onClose={handleClose} + width={520} + initialFocusRef={continueButtonRef} + footer={ + + } + > +
+ {/* Main message */} +
+

+ Windows support in Maestro is actively being improved. You may encounter more bugs + compared to Mac and Linux versions. We're working on it! +

+
+ + {/* Recommendations */} +
+

+ Recommendations +

+ + {/* Beta channel toggle */} + + + {/* Report issues */} + + + {/* Join Discord */} + + + {/* Create Debug Package */} + +
+ + {/* Suppress checkbox */} + +
+
+ ); +} + +/** + * Debug function to show the Windows warning modal from the console. + * Usage: window.__showWindowsWarningModal() + */ +export function exposeWindowsWarningModalDebug( + setShowWindowsWarning: (show: boolean) => void +): void { + (window as any).__showWindowsWarningModal = () => { + setShowWindowsWarning(true); + console.log('[WindowsWarningModal] Modal triggered via console command'); + }; +} + +export default WindowsWarningModal; diff --git a/src/renderer/constants/modalPriorities.ts b/src/renderer/constants/modalPriorities.ts index 5a80294f..f19988b8 100644 --- a/src/renderer/constants/modalPriorities.ts +++ b/src/renderer/constants/modalPriorities.ts @@ -179,6 +179,9 @@ export const MODAL_PRIORITIES = { /** Debug package generation modal */ DEBUG_PACKAGE: 605, + /** Windows warning modal - shown on startup for Windows users */ + WINDOWS_WARNING: 615, + /** About/info modal */ ABOUT: 600, diff --git a/src/renderer/contexts/ModalContext.tsx b/src/renderer/contexts/ModalContext.tsx index 219a4886..0783eaf7 100644 --- a/src/renderer/contexts/ModalContext.tsx +++ b/src/renderer/contexts/ModalContext.tsx @@ -264,6 +264,10 @@ export interface ModalContextValue { // Symphony Modal symphonyModalOpen: boolean; setSymphonyModalOpen: (open: boolean) => void; + + // Windows Warning Modal + windowsWarningModalOpen: boolean; + setWindowsWarningModalOpen: (open: boolean) => void; } // Create context with null as default (will throw if used outside provider) @@ -446,6 +450,9 @@ export function ModalProvider({ children }: ModalProviderProps) { // Symphony Modal const [symphonyModalOpen, setSymphonyModalOpen] = useState(false); + // Windows Warning Modal + const [windowsWarningModalOpen, setWindowsWarningModalOpen] = useState(false); + // Convenience methods const openSettings = useCallback((tab?: SettingsTab) => { if (tab) setSettingsTab(tab); @@ -696,6 +703,10 @@ export function ModalProvider({ children }: ModalProviderProps) { // Symphony Modal symphonyModalOpen, setSymphonyModalOpen, + + // Windows Warning Modal + windowsWarningModalOpen, + setWindowsWarningModalOpen, }), [ // Settings Modal @@ -811,6 +822,8 @@ export function ModalProvider({ children }: ModalProviderProps) { tourFromWizard, // Symphony Modal symphonyModalOpen, + // Windows Warning Modal + windowsWarningModalOpen, ] ); diff --git a/src/renderer/hooks/settings/useSettings.ts b/src/renderer/hooks/settings/useSettings.ts index 07ac9848..d18bb4ef 100644 --- a/src/renderer/hooks/settings/useSettings.ts +++ b/src/renderer/hooks/settings/useSettings.ts @@ -350,6 +350,10 @@ export interface UseSettingsReturn { // File tab auto-refresh settings fileTabAutoRefreshEnabled: boolean; setFileTabAutoRefreshEnabled: (value: boolean) => void; + + // Windows warning suppression + suppressWindowsWarning: boolean; + setSuppressWindowsWarning: (value: boolean) => void; } export function useSettings(): UseSettingsReturn { @@ -508,6 +512,9 @@ export function useSettings(): UseSettingsReturn { // File tab auto-refresh settings const [fileTabAutoRefreshEnabled, setFileTabAutoRefreshEnabledState] = useState(false); // Default: disabled + // Windows warning suppression + const [suppressWindowsWarning, setSuppressWindowsWarningState] = useState(false); // Default: show warning + // Wrapper functions that persist to electron-store // PERF: All wrapped in useCallback to prevent re-renders const setLlmProvider = useCallback((value: LLMProvider) => { @@ -1322,6 +1329,12 @@ export function useSettings(): UseSettingsReturn { window.maestro.settings.set('fileTabAutoRefreshEnabled', value); }, []); + // Windows warning suppression toggle + const setSuppressWindowsWarning = useCallback((value: boolean) => { + setSuppressWindowsWarningState(value); + window.maestro.settings.set('suppressWindowsWarning', value); + }, []); + // Load settings from electron-store // This function is called on mount and on system resume (after sleep/suspend) // PERF: Use batch loading to reduce IPC calls from ~60 to 3 @@ -1396,6 +1409,7 @@ export function useSettings(): UseSettingsReturn { const savedSshRemoteHonorGitignore = allSettings['sshRemoteHonorGitignore']; const savedAutomaticTabNamingEnabled = allSettings['automaticTabNamingEnabled']; const savedFileTabAutoRefreshEnabled = allSettings['fileTabAutoRefreshEnabled']; + const savedSuppressWindowsWarning = allSettings['suppressWindowsWarning']; if (savedEnterToSendAI !== undefined) setEnterToSendAIState(savedEnterToSendAI as boolean); if (savedEnterToSendTerminal !== undefined) @@ -1760,6 +1774,11 @@ export function useSettings(): UseSettingsReturn { if (savedFileTabAutoRefreshEnabled !== undefined) { setFileTabAutoRefreshEnabledState(savedFileTabAutoRefreshEnabled as boolean); } + + // Windows warning suppression + if (savedSuppressWindowsWarning !== undefined) { + setSuppressWindowsWarningState(savedSuppressWindowsWarning as boolean); + } } catch (error) { console.error('[Settings] Failed to load settings:', error); } finally { @@ -1938,6 +1957,8 @@ export function useSettings(): UseSettingsReturn { setAutomaticTabNamingEnabled, fileTabAutoRefreshEnabled, setFileTabAutoRefreshEnabled, + suppressWindowsWarning, + setSuppressWindowsWarning, }), [ // State values @@ -2083,6 +2104,8 @@ export function useSettings(): UseSettingsReturn { setAutomaticTabNamingEnabled, fileTabAutoRefreshEnabled, setFileTabAutoRefreshEnabled, + suppressWindowsWarning, + setSuppressWindowsWarning, ] ); }