diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index c53c2996..a101cbcb 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -1,11 +1,30 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, within } from '@testing-library/react' import '@testing-library/jest-dom' import { Settings } from '../pages/Settings' import { useSettingsStore } from '../stores/settingsStore' import { useUIStore } from '../stores/uiStore' import { useUpdateStore } from '../stores/updateStore' +import type { SavedProvider } from '../types/provider' + +const MOCK_DELETE_PROVIDER = vi.fn() +const providerStoreState = { + providers: [] as SavedProvider[], + activeId: null, + presets: [], + isLoading: false, + isPresetsLoading: false, + fetchProviders: vi.fn(), + fetchPresets: vi.fn(), + deleteProvider: MOCK_DELETE_PROVIDER, + activateProvider: vi.fn(), + activateOfficial: vi.fn(), + testProvider: vi.fn(), + createProvider: vi.fn(), + updateProvider: vi.fn(), + testConfig: vi.fn(), +} vi.mock('../api/agents', () => ({ agentsApi: { @@ -14,19 +33,7 @@ vi.mock('../api/agents', () => ({ })) vi.mock('../stores/providerStore', () => ({ - useProviderStore: () => ({ - providers: [], - activeId: null, - isLoading: false, - fetchProviders: vi.fn(), - deleteProvider: vi.fn(), - activateProvider: vi.fn(), - activateOfficial: vi.fn(), - testProvider: vi.fn(), - createProvider: vi.fn(), - updateProvider: vi.fn(), - testConfig: vi.fn(), - }), + useProviderStore: () => providerStoreState, })) vi.mock('../pages/AdapterSettings', () => ({ @@ -64,6 +71,21 @@ vi.mock('../components/chat/CodeViewer', () => ({ describe('Settings > General tab', () => { beforeEach(() => { + MOCK_DELETE_PROVIDER.mockReset() + providerStoreState.providers = [] + providerStoreState.activeId = null + providerStoreState.presets = [] + providerStoreState.isLoading = false + providerStoreState.isPresetsLoading = false + providerStoreState.fetchProviders = vi.fn() + providerStoreState.fetchPresets = vi.fn() + providerStoreState.activateProvider = vi.fn() + providerStoreState.activateOfficial = vi.fn() + providerStoreState.testProvider = vi.fn() + providerStoreState.createProvider = vi.fn() + providerStoreState.updateProvider = vi.fn() + providerStoreState.testConfig = vi.fn() + useSettingsStore.setState({ locale: 'en', skipWebFetchPreflight: true, @@ -111,6 +133,44 @@ describe('Settings > General tab', () => { }) }) +describe('Settings > Providers tab', () => { + beforeEach(() => { + MOCK_DELETE_PROVIDER.mockReset() + providerStoreState.providers = [ + { + id: 'provider-1', + name: 'MiniMax-M2.7-highspeed(openai)', + presetId: 'custom', + apiKey: '***', + baseUrl: 'https://api.minimaxi.com', + apiFormat: 'openai_chat', + models: { + main: 'MiniMax-M2.7-highspeed', + haiku: '', + sonnet: '', + opus: '', + }, + notes: '', + }, + ] + }) + + it('requires confirmation before deleting a provider', async () => { + render() + + fireEvent.click(screen.getAllByText('Delete')[0]!) + + expect(MOCK_DELETE_PROVIDER).not.toHaveBeenCalled() + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('Delete provider "MiniMax-M2.7-highspeed(openai)"? This cannot be undone.')).toBeInTheDocument() + + const dialog = screen.getByRole('dialog') + fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' })) + + expect(MOCK_DELETE_PROVIDER).toHaveBeenCalledWith('provider-1') + }) +}) + describe('Settings > About tab', () => { beforeEach(() => { useUIStore.setState({ pendingSettingsTab: 'about' }) diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index c98ef8fe..f380c64a 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import '@testing-library/jest-dom' vi.mock('./ProjectFilter', () => ({ @@ -17,6 +17,7 @@ vi.mock('../../i18n', () => ({ 'sidebar.noMatching': 'No matching sessions', 'sidebar.sessionListFailed': 'Session list failed', 'common.retry': 'Retry', + 'common.cancel': 'Cancel', 'common.delete': 'Delete', 'common.rename': 'Rename', 'sidebar.timeGroup.today': 'Today', @@ -25,6 +26,7 @@ vi.mock('../../i18n', () => ({ 'sidebar.timeGroup.last30days': 'Last 30 Days', 'sidebar.timeGroup.older': 'Older', 'sidebar.missingDir': 'Missing', + 'sidebar.confirmDelete': 'Delete this session? This cannot be undone.', 'sidebar.collapse': 'Collapse sidebar', 'sidebar.expand': 'Expand sidebar', } @@ -121,7 +123,7 @@ describe('Sidebar', () => { expect(useTabStore.getState().tabs).toEqual([]) }) - it('removes the matching tab when deleting a session from the sidebar', async () => { + it('requires confirmation before deleting a session from the sidebar', async () => { deleteSession.mockResolvedValue(undefined) useSessionStore.setState({ sessions: [ @@ -146,8 +148,15 @@ describe('Sidebar', () => { fireEvent.contextMenu(screen.getByRole('button', { name: /Open Session/ })) + fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + + expect(deleteSession).not.toHaveBeenCalled() + const dialog = screen.getByRole('dialog') + expect(dialog).toBeInTheDocument() + expect(screen.getByText('Delete this session? This cannot be undone.')).toBeInTheDocument() + await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' })) }) await waitFor(() => { diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index 85ad50b8..50c0eb65 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -3,6 +3,7 @@ import { useSessionStore } from '../../stores/sessionStore' import { useUIStore } from '../../stores/uiStore' import { useTranslation } from '../../i18n' import { ProjectFilter } from './ProjectFilter' +import { ConfirmDialog } from '../shared/ConfirmDialog' import type { SessionListItem } from '../../types/session' import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID } from '../../stores/tabStore' import { useChatStore } from '../../stores/chatStore' @@ -29,6 +30,7 @@ export function Sidebar() { const disconnectSession = useChatStore((s) => s.disconnectSession) const [searchQuery, setSearchQuery] = useState('') const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null) + const [pendingDeleteSessionId, setPendingDeleteSessionId] = useState(null) const [renamingId, setRenamingId] = useState(null) const [renameValue, setRenameValue] = useState('') @@ -67,12 +69,18 @@ export function Sidebar() { setContextMenu({ id, x: e.clientX, y: e.clientY }) }, []) - const handleDelete = useCallback(async (id: string) => { + const handleDelete = useCallback((id: string) => { setContextMenu(null) - await deleteSession(id) - disconnectSession(id) - closeTab(id) - }, [closeTab, deleteSession, disconnectSession]) + setPendingDeleteSessionId(id) + }, []) + + const confirmDelete = useCallback(async () => { + if (!pendingDeleteSessionId) return + await deleteSession(pendingDeleteSessionId) + disconnectSession(pendingDeleteSessionId) + closeTab(pendingDeleteSessionId) + setPendingDeleteSessionId(null) + }, [closeTab, deleteSession, disconnectSession, pendingDeleteSessionId]) const handleStartRename = useCallback((id: string, currentTitle: string) => { setContextMenu(null) @@ -351,6 +359,17 @@ export function Sidebar() { )} + + setPendingDeleteSessionId(null)} + onConfirm={confirmDelete} + title={t('common.delete')} + body={pendingDeleteSessionId ? t('sidebar.confirmDelete') : ''} + confirmLabel={t('common.delete')} + cancelLabel={t('common.cancel')} + confirmVariant="danger" + /> ) } diff --git a/desktop/src/components/plugins/PluginDetail.tsx b/desktop/src/components/plugins/PluginDetail.tsx index ef0acfe2..ab91091f 100644 --- a/desktop/src/components/plugins/PluginDetail.tsx +++ b/desktop/src/components/plugins/PluginDetail.tsx @@ -4,6 +4,7 @@ import { useSessionStore } from '../../stores/sessionStore' import { useTranslation } from '../../i18n' import { useUIStore } from '../../stores/uiStore' import { Button } from '../shared/Button' +import { ConfirmDialog } from '../shared/ConfirmDialog' import type { PluginCapabilityKey } from '../../types/plugin' import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore' import { useSkillStore } from '../../stores/skillStore' @@ -36,6 +37,7 @@ export function PluginDetail() { const selectServer = useMcpStore((s) => s.selectServer) const t = useTranslation() const [actionKey, setActionKey] = useState(null) + const [showUninstallDialog, setShowUninstallDialog] = useState(false) const activeSession = sessions.find((session) => session.id === activeSessionId) const currentWorkDir = activeSession?.workDir || undefined @@ -98,11 +100,6 @@ export function PluginDetail() { } } - const confirmUninstall = () => { - const label = t('settings.plugins.confirmUninstall', { name: selectedPlugin.name }) - return window.confirm(label) - } - const openSettingsTab = (tab: 'skills' | 'agents' | 'mcp') => { useUIStore.getState().setPendingSettingsTab(tab) useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings') @@ -290,8 +287,7 @@ export function PluginDetail() { size="sm" loading={isApplying && actionKey === 'uninstall'} onClick={() => { - if (!confirmUninstall()) return - void runAction('uninstall', () => uninstallPlugin(selectedPlugin.id, selectedPlugin.scope, false, currentWorkDir)) + setShowUninstallDialog(true) }} > {t('settings.plugins.uninstall')} @@ -483,6 +479,24 @@ export function PluginDetail() { + + { + if (isApplying && actionKey === 'uninstall') return + setShowUninstallDialog(false) + }} + onConfirm={async () => { + setShowUninstallDialog(false) + await runAction('uninstall', () => uninstallPlugin(selectedPlugin.id, selectedPlugin.scope, false, currentWorkDir)) + }} + title={t('settings.plugins.uninstall')} + body={t('settings.plugins.confirmUninstall', { name: selectedPlugin.name })} + confirmLabel={t('settings.plugins.uninstall')} + cancelLabel={t('common.cancel')} + confirmVariant="danger" + loading={isApplying && actionKey === 'uninstall'} + /> ) } diff --git a/desktop/src/components/shared/ConfirmDialog.tsx b/desktop/src/components/shared/ConfirmDialog.tsx new file mode 100644 index 00000000..6284547d --- /dev/null +++ b/desktop/src/components/shared/ConfirmDialog.tsx @@ -0,0 +1,49 @@ +import { Modal } from './Modal' +import { Button } from './Button' + +type ConfirmDialogProps = { + open: boolean + onClose: () => void + onConfirm: () => void | Promise + title: string + body: string + confirmLabel: string + cancelLabel: string + confirmVariant?: 'primary' | 'danger' + loading?: boolean +} + +export function ConfirmDialog({ + open, + onClose, + onConfirm, + title, + body, + confirmLabel, + cancelLabel, + confirmVariant = 'danger', + loading = false, +}: ConfirmDialogProps) { + return ( + {} : onClose} + title={title} + width={460} + footer={( + <> + + + + )} + > +

+ {body} +

+
+ ) +} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 3a129ac9..33004574 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -24,6 +24,7 @@ export const en = { 'sidebar.noMatching': 'No matching sessions', 'sidebar.sessionListFailed': 'Session list failed to load', 'sidebar.missingDir': 'missing dir', + 'sidebar.confirmDelete': 'Delete this session? This cannot be undone.', 'sidebar.allProjects': 'All projects', 'sidebar.other': 'Other', 'sidebar.timeGroup.today': 'Today', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 30b7b962..ca792fbb 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -26,6 +26,7 @@ export const zh: Record = { 'sidebar.noMatching': '没有匹配的会话', 'sidebar.sessionListFailed': '会话列表加载失败', 'sidebar.missingDir': '目录缺失', + 'sidebar.confirmDelete': '确定要删除这个会话吗?此操作不可撤销。', 'sidebar.allProjects': '所有项目', 'sidebar.other': '其他', 'sidebar.timeGroup.today': '今天', diff --git a/desktop/src/pages/AdapterSettings.tsx b/desktop/src/pages/AdapterSettings.tsx index 5526234f..bc7e6769 100644 --- a/desktop/src/pages/AdapterSettings.tsx +++ b/desktop/src/pages/AdapterSettings.tsx @@ -4,6 +4,7 @@ import { useTranslation } from '../i18n' import { Input } from '../components/shared/Input' import { Button } from '../components/shared/Button' import { DirectoryPicker } from '../components/shared/DirectoryPicker' +import { ConfirmDialog } from '../components/shared/ConfirmDialog' type ImTab = 'feishu' | 'telegram' @@ -37,6 +38,8 @@ export function AdapterSettings() { // Pairing const [pairingCode, setPairingCode] = useState(null) const [isGenerating, setIsGenerating] = useState(false) + const [pendingUnbind, setPendingUnbind] = useState<{ platform: 'telegram' | 'feishu'; userId: string | number } | null>(null) + const [isUnbinding, setIsUnbinding] = useState(false) useEffect(() => { fetchConfig() @@ -114,10 +117,20 @@ export function AdapterSettings() { }, [generatePairingCode]) const handleUnbind = useCallback(async (platform: 'telegram' | 'feishu', userId: string | number) => { - if (!confirm(t('settings.adapters.unbindConfirm'))) return - await removePairedUser(platform, userId) - await fetchConfig() - }, [removePairedUser, fetchConfig, t]) + setPendingUnbind({ platform, userId }) + }, []) + + const confirmUnbind = useCallback(async () => { + if (!pendingUnbind) return + setIsUnbinding(true) + try { + await removePairedUser(pendingUnbind.platform, pendingUnbind.userId) + await fetchConfig() + setPendingUnbind(null) + } finally { + setIsUnbinding(false) + } + }, [pendingUnbind, removePairedUser, fetchConfig]) // Collect all paired users across platforms const allPairedUsers = [ @@ -344,6 +357,21 @@ export function AdapterSettings() { )} + + { + if (isUnbinding) return + setPendingUnbind(null) + }} + onConfirm={confirmUnbind} + title={t('settings.adapters.unbind')} + body={t('settings.adapters.unbindConfirm')} + confirmLabel={t('settings.adapters.unbind')} + cancelLabel={t('common.cancel')} + confirmVariant="danger" + loading={isUnbinding} + /> ) } diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index dfc85afc..643d894c 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -3,6 +3,7 @@ import { useSettingsStore } from '../stores/settingsStore' import { useProviderStore } from '../stores/providerStore' import { useTranslation } from '../i18n' import { Modal } from '../components/shared/Modal' +import { ConfirmDialog } from '../components/shared/ConfirmDialog' import { Input } from '../components/shared/Input' import { Button } from '../components/shared/Button' import type { PermissionMode, EffortLevel, ThemeMode } from '../types/settings' @@ -116,6 +117,8 @@ function ProviderSettings() { const t = useTranslation() const [editingProvider, setEditingProvider] = useState(null) const [showCreateModal, setShowCreateModal] = useState(false) + const [pendingDeleteProvider, setPendingDeleteProvider] = useState(null) + const [isDeletingProvider, setIsDeletingProvider] = useState(false) const [testResults, setTestResults] = useState>({}) useEffect(() => { @@ -130,8 +133,20 @@ function ProviderSettings() { const handleDelete = async (provider: SavedProvider) => { if (activeId === provider.id) return - if (!window.confirm(t('settings.providers.confirmDelete', { name: provider.name }))) return - await deleteProvider(provider.id).catch(console.error) + setPendingDeleteProvider(provider) + } + + const confirmDelete = async () => { + if (!pendingDeleteProvider) return + setIsDeletingProvider(true) + try { + await deleteProvider(pendingDeleteProvider.id) + setPendingDeleteProvider(null) + } catch (error) { + console.error(error) + } finally { + setIsDeletingProvider(false) + } } const handleTest = async (provider: SavedProvider) => { @@ -281,6 +296,21 @@ function ProviderSettings() { {editingProvider && ( setEditingProvider(null)} mode="edit" provider={editingProvider} presets={presets} /> )} + + { + if (isDeletingProvider) return + setPendingDeleteProvider(null) + }} + onConfirm={confirmDelete} + title={t('common.delete')} + body={pendingDeleteProvider ? t('settings.providers.confirmDelete', { name: pendingDeleteProvider.name }) : ''} + confirmLabel={t('common.delete')} + cancelLabel={t('common.cancel')} + confirmVariant="danger" + loading={isDeletingProvider} + /> ) }