diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 324e70e3..9414c42e 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -16,6 +16,7 @@ const MOCK_GET_SETTINGS = vi.fn() const MOCK_UPDATE_SETTINGS = vi.fn() const desktopNotificationsMock = vi.hoisted(() => ({ getDesktopNotificationPermission: vi.fn(), + getDesktopNotificationPlatform: vi.fn(), notifyDesktop: vi.fn(), requestDesktopNotificationPermission: vi.fn(), openDesktopNotificationSettings: vi.fn(), @@ -165,10 +166,12 @@ describe('Settings > General tab', () => { vi.useRealTimers() MOCK_DELETE_PROVIDER.mockReset() desktopNotificationsMock.getDesktopNotificationPermission.mockReset() + desktopNotificationsMock.getDesktopNotificationPlatform.mockReset() desktopNotificationsMock.notifyDesktop.mockReset() desktopNotificationsMock.requestDesktopNotificationPermission.mockReset() desktopNotificationsMock.openDesktopNotificationSettings.mockReset() desktopNotificationsMock.getDesktopNotificationPermission.mockResolvedValue('default') + desktopNotificationsMock.getDesktopNotificationPlatform.mockReturnValue('darwin') desktopNotificationsMock.notifyDesktop.mockResolvedValue(true) desktopNotificationsMock.requestDesktopNotificationPermission.mockResolvedValue('granted') desktopNotificationsMock.openDesktopNotificationSettings.mockResolvedValue(true) @@ -207,6 +210,7 @@ describe('Settings > General tab', () => { autoDreamEnabled: false, skipWebFetchPreflight: true, desktopNotificationsEnabled: true, + traceCapture: { enabled: true, storageDir: '/Users/test/.claude/cc-haha/traces' }, chatSendBehavior: 'enter', responseLanguage: '', uiZoom: 1, @@ -255,6 +259,10 @@ describe('Settings > General tab', () => { setDesktopNotificationsEnabled: vi.fn().mockImplementation(async (enabled: boolean) => { useSettingsStore.setState({ desktopNotificationsEnabled: enabled }) }), + setTraceCaptureEnabled: vi.fn().mockImplementation(async (enabled: boolean) => { + const current = useSettingsStore.getState().traceCapture + useSettingsStore.setState({ traceCapture: { ...current, enabled } }) + }), setChatSendBehavior: vi.fn().mockImplementation(async (chatSendBehavior: ChatSendBehavior) => { useSettingsStore.setState({ chatSendBehavior }) }), @@ -757,6 +765,43 @@ describe('Settings > General tab', () => { expect(useSettingsStore.getState().setAutoDreamEnabled).toHaveBeenCalledWith(false) }) + it('keeps General checkbox inputs anchored inside their visible rows', () => { + render() + + fireEvent.click(screen.getByText('General')) + + for (const label of [ + 'Enable thinking mode', + 'Enable Auto-dream', + 'Collect agent traces', + 'Enable system notifications', + 'Skip WebFetch domain preflight', + ]) { + const toggle = screen.getByLabelText(label) + const row = toggle.closest('label') as HTMLElement | null + expect(toggle).toHaveClass('settings-checkbox-input') + expect(toggle).not.toHaveClass('sr-only') + expect(row).not.toBeNull() + expect(row!).toHaveClass('relative') + } + }) + + it('lets the user disable Agent Trace collection without leaving General settings', async () => { + render() + + fireEvent.click(screen.getByText('General')) + expect(screen.getByLabelText('Collect agent traces')).toBeChecked() + + await act(async () => { + fireEvent.click(screen.getByLabelText('Collect agent traces')) + }) + + expect(useSettingsStore.getState().setTraceCaptureEnabled).toHaveBeenCalledWith(false) + expect(screen.getByLabelText('Collect agent traces')).not.toBeChecked() + expect(screen.getByText('Agent trace')).toBeInTheDocument() + expect(screen.getByText('Message Sending')).toBeInTheDocument() + }) + it('uses the shared dropdown for response language', () => { render() @@ -805,7 +850,25 @@ describe('Settings > General tab', () => { }) }) - it('opens system settings when enabling notifications finds system denial', async () => { + it('does not fire the enable smoke notification on Windows Electron', async () => { + useSettingsStore.setState({ desktopNotificationsEnabled: false }) + desktopNotificationsMock.getDesktopNotificationPlatform.mockReturnValue('win32') + render() + + fireEvent.click(screen.getByText('General')) + await act(async () => { + fireEvent.click(screen.getByLabelText('Enable system notifications')) + }) + + expect(useSettingsStore.getState().setDesktopNotificationsEnabled).toHaveBeenCalledWith(true) + await vi.waitFor(() => { + expect(desktopNotificationsMock.requestDesktopNotificationPermission).toHaveBeenCalledTimes(1) + }) + expect(desktopNotificationsMock.notifyDesktop).not.toHaveBeenCalled() + expect(desktopNotificationsMock.openDesktopNotificationSettings).not.toHaveBeenCalled() + }) + + it('shows the system settings action when enabling notifications finds system denial', async () => { useSettingsStore.setState({ desktopNotificationsEnabled: false }) desktopNotificationsMock.requestDesktopNotificationPermission.mockResolvedValue('denied') render() @@ -816,8 +879,15 @@ describe('Settings > General tab', () => { }) await vi.waitFor(() => { - expect(desktopNotificationsMock.openDesktopNotificationSettings).toHaveBeenCalledTimes(1) + expect(screen.getByText('Permission: Blocked by system settings')).toBeInTheDocument() }) + expect(desktopNotificationsMock.openDesktopNotificationSettings).not.toHaveBeenCalled() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Open Settings' })) + }) + + expect(desktopNotificationsMock.openDesktopNotificationSettings).toHaveBeenCalledTimes(1) }) it('moves H5 access out of General into its own Settings tab', () => { diff --git a/desktop/src/components/layout/ContentRouter.test.tsx b/desktop/src/components/layout/ContentRouter.test.tsx index 5947f6e6..c31ac291 100644 --- a/desktop/src/components/layout/ContentRouter.test.tsx +++ b/desktop/src/components/layout/ContentRouter.test.tsx @@ -1,7 +1,15 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import '@testing-library/jest-dom' import { afterEach, describe, expect, it, vi } from 'vitest' +const { previewBridgeMock } = vi.hoisted(() => ({ + previewBridgeMock: { + close: vi.fn().mockResolvedValue(undefined), + }, +})) + +vi.mock('../../lib/previewBridge', () => ({ previewBridge: previewBridgeMock })) + vi.mock('../../pages/EmptySession', () => ({ EmptySession: () =>
, })) @@ -40,6 +48,7 @@ import { useTabStore } from '../../stores/tabStore' describe('ContentRouter terminal tabs', () => { afterEach(() => { cleanup() + previewBridgeMock.close.mockClear() useTabStore.setState({ tabs: [], activeTabId: null }) }) @@ -138,4 +147,27 @@ describe('ContentRouter terminal tabs', () => { expect(screen.getByTestId('trace-list')).toBeInTheDocument() expect(screen.queryByTestId('active-session')).not.toBeInTheDocument() }) + + it('closes the native preview when switching from a chat session to settings', async () => { + useTabStore.setState({ + tabs: [ + { sessionId: 'session-1', title: 'Chat', type: 'session', status: 'idle' }, + { sessionId: '__settings__', title: 'Settings', type: 'settings', status: 'idle' }, + ], + activeTabId: 'session-1', + }) + + render() + expect(screen.getByTestId('active-session')).toBeInTheDocument() + previewBridgeMock.close.mockClear() + + act(() => { + useTabStore.setState({ activeTabId: '__settings__' }) + }) + + expect(screen.getByTestId('settings-page')).toBeInTheDocument() + await waitFor(() => { + expect(previewBridgeMock.close).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/desktop/src/components/layout/ContentRouter.tsx b/desktop/src/components/layout/ContentRouter.tsx index 0dd3958f..7fbf5321 100644 --- a/desktop/src/components/layout/ContentRouter.tsx +++ b/desktop/src/components/layout/ContentRouter.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react' +import { useEffect, type ReactNode } from 'react' import { useTabStore } from '../../stores/tabStore' import { EmptySession } from '../../pages/EmptySession' import { ActiveSession } from '../../pages/ActiveSession' @@ -7,6 +7,7 @@ import { Settings } from '../../pages/Settings' import { TerminalSettings } from '../../pages/TerminalSettings' import { TraceList } from '../../pages/TraceList' import { TraceSession } from '../../pages/TraceSession' +import { previewBridge } from '../../lib/previewBridge' export function ContentRouter() { const activeTabId = useTabStore((s) => s.activeTabId) @@ -14,6 +15,11 @@ export function ContentRouter() { const activeTabType = tabs.find((t) => t.sessionId === activeTabId)?.type const terminalTabs = tabs.filter((tab) => tab.type === 'terminal') + useEffect(() => { + if (activeTabType === 'session') return + void previewBridge.close() + }, [activeTabType]) + let page: ReactNode = null if (!activeTabId || !activeTabType) { page = diff --git a/desktop/src/lib/desktopNotifications.ts b/desktop/src/lib/desktopNotifications.ts index 058e12b9..963cf7ac 100644 --- a/desktop/src/lib/desktopNotifications.ts +++ b/desktop/src/lib/desktopNotifications.ts @@ -57,6 +57,10 @@ function detectPlatform(): 'darwin' | 'win32' | 'linux' | 'unknown' { return 'unknown' } +export function getDesktopNotificationPlatform(): 'darwin' | 'win32' | 'linux' | 'unknown' { + return detectPlatform() +} + function getNotificationSettingsUrl(): string | null { switch (detectPlatform()) { case 'darwin': diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index a848095c..36e64a91 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -44,6 +44,7 @@ import { publicAssetPath } from '../lib/publicAsset' import { getDesktopNotificationPermission, notifyDesktop, + getDesktopNotificationPlatform, openDesktopNotificationSettings, requestDesktopNotificationPermission, type DesktopNotificationPermission, @@ -59,6 +60,7 @@ import { copyTextToClipboard } from '../components/chat/clipboard' const NETWORK_TIMEOUT_MIN_SECONDS = 5 const NETWORK_TIMEOUT_MAX_SECONDS = 600 const NETWORK_TIMEOUT_STEP_SECONDS = 30 +const SETTINGS_CHECKBOX_INPUT_CLASS = 'settings-checkbox-input peer' function buildH5LaunchUrl(baseUrl: string | null, token: string | null): string | null { if (!baseUrl) return null @@ -1652,15 +1654,12 @@ export function GeneralSettings() { try { const permission = await requestDesktopNotificationPermission() setNotificationPermission(permission) - if (permission === 'granted') { + if (permission === 'granted' && getDesktopNotificationPlatform() !== 'win32') { void notifyDesktop({ title: t('settings.general.notificationsTestTitle'), body: t('settings.general.notificationsTestBody'), }) } - if (permission === 'denied') { - await openDesktopNotificationSettings() - } } finally { setNotificationActionRunning(false) } @@ -2096,13 +2095,13 @@ export function GeneralSettings() {

{t('settings.general.thinkingTitle')}

{t('settings.general.thinkingDescription')}

-