mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
fix: unify desktop confirmation dialogs
Desktop destructive confirmations had split implementations: shared ConfirmDialog, hand-rolled modals, inline task popovers, and a diagnostics window.confirm path. This adds shared ActionDialog and ConfirmPopover primitives, keeps ConfirmDialog as the simple two-action wrapper, and migrates the known desktop confirmation flows onto those shared surfaces. Constraint: Running-tab close needs three actions, so the two-button ConfirmDialog API was not enough. Rejected: Force every confirmation through ConfirmDialog | would either lose the keep-running action or make ConfirmDialog too broad for inline popovers. Confidence: high Scope-risk: moderate Directive: Add new desktop confirmation flows through ActionDialog, ConfirmDialog, or ConfirmPopover instead of hand-rolled overlays. Tested: cd desktop && bun run test -- AdapterSettings.test.tsx mcpSettings.test.tsx TabBar.test.tsx PermissionModeSelector.test.tsx diagnosticsSettings.test.tsx Tested: bun run check:desktop Tested: git diff --check Tested: Chrome DevTools smoke for provider delete and diagnostics clear-log confirmation dialogs Not-tested: Agent Browser smoke because the local agent-browser CLI hung on open/snapshot/doctor/close commands.
This commit is contained in:
parent
66a0d45627
commit
6ca7ee48a8
@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
import { Settings } from '../pages/Settings'
|
||||
@ -90,6 +90,7 @@ vi.mock('../components/chat/CodeViewer', () => ({
|
||||
|
||||
describe('Settings > Diagnostics tab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
diagnosticsApiMock.getStatus.mockResolvedValue({
|
||||
logDir: '/tmp/claude/cc-haha/diagnostics',
|
||||
diagnosticsPath: '/tmp/claude/cc-haha/diagnostics/diagnostics.jsonl',
|
||||
@ -168,6 +169,36 @@ describe('Settings > Diagnostics tab', () => {
|
||||
expect(await screen.findByText('/tmp/claude/cc-haha/diagnostics/exports/cc-haha-diagnostics.tar.gz')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('asks with the shared confirm dialog before clearing diagnostics', async () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => {
|
||||
throw new Error('window.confirm should not be used')
|
||||
})
|
||||
|
||||
try {
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('Diagnostics'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Clear Logs/i }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Clear Logs' })
|
||||
expect(within(dialog).getByText('Clear all local diagnostic logs and exported bundles?')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /Cancel/i }))
|
||||
expect(diagnosticsApiMock.clear).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Clear Logs/i }))
|
||||
const confirmDialog = await screen.findByRole('dialog', { name: 'Clear Logs' })
|
||||
fireEvent.click(within(confirmDialog).getByRole('button', { name: /Clear Logs/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(diagnosticsApiMock.clear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(confirmSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
confirmSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('copies the recent error summary with the legacy clipboard fallback', async () => {
|
||||
const originalClipboard = navigator.clipboard
|
||||
const originalExecCommand = document.execCommand
|
||||
|
||||
@ -154,6 +154,7 @@ describe('PermissionModeSelector', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Ask permissions' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /Bypass permissions/ }))
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Enable bypass mode' })).toBeInTheDocument()
|
||||
expect(screen.getByText('C:\\Users\\LinTan\\MyScript\\test5')).toBeInTheDocument()
|
||||
expect(screen.queryByText('C:\\Users\\LinTan')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
@ -10,6 +9,7 @@ import type { PermissionMode } from '../../types/settings'
|
||||
import { useMobileViewport } from '../../hooks/useMobileViewport'
|
||||
import { isTauriRuntime } from '../../lib/desktopRuntime'
|
||||
import { MobileBottomSheet } from '../shared/MobileBottomSheet'
|
||||
import { ActionDialog } from '../shared/ActionDialog'
|
||||
|
||||
const MODE_ICONS: Record<PermissionMode, string> = {
|
||||
default: 'verified_user',
|
||||
@ -213,73 +213,60 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Bypass confirmation dialog */}
|
||||
{confirmDialog && createPortal(
|
||||
<div className={`fixed inset-0 z-[100] flex items-center justify-center bg-black/40 ${isMobile ? 'px-4' : 'pl-[var(--sidebar-width)] pr-4'}`} onClick={() => setConfirmDialog(false)}>
|
||||
<div
|
||||
className={`${isMobile ? 'w-full max-w-md' : 'w-[420px]'} rounded-2xl bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)] shadow-[var(--shadow-dropdown)] overflow-hidden`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-5 py-4 bg-[var(--color-error)]/8 border-b border-[var(--color-error)]/15">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-[var(--color-error)]/12">
|
||||
<span className="material-symbols-outlined text-[22px] text-[var(--color-error)]">warning</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-[var(--color-text-primary)]">{t('permMode.enableBypassTitle')}</div>
|
||||
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{t('permMode.enableBypassSubtitle')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-xs text-[var(--color-text-secondary)] leading-relaxed mb-3" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(t('permMode.enableBypassBody')) }} />
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--color-surface-container)] border border-[var(--color-border)]" title={workDir}>
|
||||
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)] shrink-0">folder</span>
|
||||
<code className="text-xs font-[var(--font-mono)] text-[var(--color-text-primary)] truncate">{workDir}</code>
|
||||
</div>
|
||||
<ul className="mt-3 space-y-1.5 text-xs text-[var(--color-text-secondary)]">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-error)] mt-0.5">check</span>
|
||||
{t('permMode.permReadWrite')}
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-error)] mt-0.5">check</span>
|
||||
{t('permMode.permShell')}
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-error)] mt-0.5">check</span>
|
||||
{t('permMode.permPackages')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
|
||||
<button
|
||||
onClick={() => setConfirmDialog(false)}
|
||||
className="px-4 py-2 text-xs font-semibold text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isControlled) {
|
||||
onChange?.('bypassPermissions')
|
||||
} else {
|
||||
if (activeTabId) setSessionPermissionMode(activeTabId, 'bypassPermissions')
|
||||
}
|
||||
setConfirmDialog(false)
|
||||
}}
|
||||
className="px-4 py-2 text-xs font-semibold text-white bg-[var(--color-error)] hover:opacity-90 rounded-lg transition-colors"
|
||||
>
|
||||
{t('permMode.enableBypassBtn')}
|
||||
</button>
|
||||
<ActionDialog
|
||||
open={confirmDialog}
|
||||
onClose={() => setConfirmDialog(false)}
|
||||
title={t('permMode.enableBypassTitle')}
|
||||
width={420}
|
||||
body={(
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-medium text-[var(--color-error)]">
|
||||
{t('permMode.enableBypassSubtitle')}
|
||||
</p>
|
||||
<p
|
||||
className="text-xs leading-relaxed text-[var(--color-text-secondary)]"
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(t('permMode.enableBypassBody')) }}
|
||||
/>
|
||||
<div className="flex items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container)] px-3 py-2" title={workDir}>
|
||||
<span className="material-symbols-outlined shrink-0 text-[16px] text-[var(--color-text-tertiary)]">folder</span>
|
||||
<code className="truncate text-xs font-[var(--font-mono)] text-[var(--color-text-primary)]">{workDir}</code>
|
||||
</div>
|
||||
<ul className="space-y-1.5 text-xs text-[var(--color-text-secondary)]">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined mt-0.5 text-[14px] text-[var(--color-error)]">check</span>
|
||||
{t('permMode.permReadWrite')}
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined mt-0.5 text-[14px] text-[var(--color-error)]">check</span>
|
||||
{t('permMode.permShell')}
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined mt-0.5 text-[14px] text-[var(--color-error)]">check</span>
|
||||
{t('permMode.permPackages')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
)}
|
||||
actions={[
|
||||
{
|
||||
label: t('common.cancel'),
|
||||
onClick: () => setConfirmDialog(false),
|
||||
variant: 'secondary',
|
||||
},
|
||||
{
|
||||
label: t('permMode.enableBypassBtn'),
|
||||
onClick: () => {
|
||||
if (isControlled) {
|
||||
onChange?.('bypassPermissions')
|
||||
} else if (activeTabId) {
|
||||
setSessionPermissionMode(activeTabId, 'bypassPermissions')
|
||||
}
|
||||
setConfirmDialog(false)
|
||||
},
|
||||
variant: 'danger',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -940,6 +940,7 @@ describe('TabBar', () => {
|
||||
fireEvent.click(screen.getByText('Close All'))
|
||||
|
||||
expect(screen.getByText('Sessions Running')).toBeInTheDocument()
|
||||
expect(screen.getByRole('dialog', { name: 'Sessions Running' })).toBeInTheDocument()
|
||||
expect(screen.getByText('2 sessions still running')).toBeInTheDocument()
|
||||
expect(useTabStore.getState().tabs.map((tab) => tab.sessionId)).toEqual(['tab-running', 'tab-thinking', 'tab-idle'])
|
||||
expect(disconnectSession).not.toHaveBeenCalled()
|
||||
|
||||
@ -16,6 +16,7 @@ import { useTranslation } from '../../i18n'
|
||||
import { WindowControls, showWindowControls } from './WindowControls'
|
||||
import { OpenProjectMenu } from './OpenProjectMenu'
|
||||
import { Folder, FolderOpen, Globe, SquareTerminal } from 'lucide-react'
|
||||
import { ActionDialog } from '../shared/ActionDialog'
|
||||
|
||||
const TAB_WIDTH = 180
|
||||
const DRAG_START_THRESHOLD = 4
|
||||
@ -487,47 +488,43 @@ export function TabBar() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingCloseRequest && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/30">
|
||||
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-6 max-w-sm w-full mx-4" style={{ boxShadow: 'var(--shadow-dropdown)' }}>
|
||||
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] mb-2">
|
||||
{pendingCloseRequest.runningSessionIds.length > 1
|
||||
? t('tabs.closeAllConfirmTitle')
|
||||
: t('tabs.closeConfirmTitle')}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--color-text-secondary)] mb-4">
|
||||
{pendingCloseRequest.runningSessionIds.length > 1
|
||||
? t('tabs.closeAllConfirmMessage', { count: pendingCloseRequest.runningSessionIds.length })
|
||||
: t('tabs.closeConfirmMessage')}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={() => setPendingCloseRequest(null)} className="px-3 py-1.5 text-xs rounded-lg border border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
closeTabsWithPolicy(pendingCloseRequest.tabs, pendingCloseRequest.runningSessionIds, false)
|
||||
setPendingCloseRequest(null)
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs rounded-lg border border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
{t('tabs.closeConfirmKeep')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
closeTabsWithPolicy(pendingCloseRequest.tabs, pendingCloseRequest.runningSessionIds, true)
|
||||
setPendingCloseRequest(null)
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs rounded-lg bg-[var(--color-brand)] text-white hover:opacity-90"
|
||||
>
|
||||
{pendingCloseRequest.runningSessionIds.length > 1
|
||||
? t('tabs.closeAllConfirmStop')
|
||||
: t('tabs.closeConfirmStop')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ActionDialog
|
||||
open={pendingCloseRequest !== null}
|
||||
onClose={() => setPendingCloseRequest(null)}
|
||||
title={pendingCloseRequest && pendingCloseRequest.runningSessionIds.length > 1
|
||||
? t('tabs.closeAllConfirmTitle')
|
||||
: t('tabs.closeConfirmTitle')}
|
||||
body={pendingCloseRequest && pendingCloseRequest.runningSessionIds.length > 1
|
||||
? t('tabs.closeAllConfirmMessage', { count: pendingCloseRequest.runningSessionIds.length })
|
||||
: t('tabs.closeConfirmMessage')}
|
||||
actions={[
|
||||
{
|
||||
label: t('common.cancel'),
|
||||
onClick: () => setPendingCloseRequest(null),
|
||||
variant: 'secondary',
|
||||
},
|
||||
{
|
||||
label: t('tabs.closeConfirmKeep'),
|
||||
onClick: () => {
|
||||
if (!pendingCloseRequest) return
|
||||
closeTabsWithPolicy(pendingCloseRequest.tabs, pendingCloseRequest.runningSessionIds, false)
|
||||
setPendingCloseRequest(null)
|
||||
},
|
||||
variant: 'secondary',
|
||||
},
|
||||
{
|
||||
label: pendingCloseRequest && pendingCloseRequest.runningSessionIds.length > 1
|
||||
? t('tabs.closeAllConfirmStop')
|
||||
: t('tabs.closeConfirmStop'),
|
||||
onClick: () => {
|
||||
if (!pendingCloseRequest) return
|
||||
closeTabsWithPolicy(pendingCloseRequest.tabs, pendingCloseRequest.runningSessionIds, true)
|
||||
setPendingCloseRequest(null)
|
||||
},
|
||||
variant: 'danger',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
66
desktop/src/components/shared/ActionDialog.tsx
Normal file
66
desktop/src/components/shared/ActionDialog.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button } from './Button'
|
||||
import { Modal } from './Modal'
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'
|
||||
|
||||
export type ActionDialogAction = {
|
||||
label: string
|
||||
onClick: () => void | Promise<void>
|
||||
variant?: ButtonVariant
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
type ActionDialogProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
body: ReactNode
|
||||
actions: ActionDialogAction[]
|
||||
width?: number
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function ActionDialog({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
body,
|
||||
actions,
|
||||
width = 460,
|
||||
loading = false,
|
||||
}: ActionDialogProps) {
|
||||
const busy = loading || actions.some((action) => action.loading)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={busy ? () => {} : onClose}
|
||||
title={title}
|
||||
width={width}
|
||||
footer={(
|
||||
<>
|
||||
{actions.map((action) => (
|
||||
<Button
|
||||
key={action.label}
|
||||
type="button"
|
||||
variant={action.variant ?? 'secondary'}
|
||||
onClick={() => void action.onClick()}
|
||||
loading={action.loading}
|
||||
disabled={busy || action.disabled}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{typeof body === 'string' ? (
|
||||
<p className="text-sm leading-6 text-[var(--color-text-secondary)]">
|
||||
{body}
|
||||
</p>
|
||||
) : body}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@ -1,6 +1,5 @@
|
||||
import { Modal } from './Modal'
|
||||
import { Button } from './Button'
|
||||
import type { ReactNode } from 'react'
|
||||
import { ActionDialog } from './ActionDialog'
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
open: boolean
|
||||
@ -26,27 +25,25 @@ export function ConfirmDialog({
|
||||
loading = false,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Modal
|
||||
<ActionDialog
|
||||
open={open}
|
||||
onClose={loading ? () => {} : onClose}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
width={460}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={loading}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button variant={confirmVariant} onClick={() => void onConfirm()} loading={loading}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{typeof body === 'string' ? (
|
||||
<p className="text-sm leading-6 text-[var(--color-text-secondary)]">
|
||||
{body}
|
||||
</p>
|
||||
) : body}
|
||||
</Modal>
|
||||
body={body}
|
||||
loading={loading}
|
||||
actions={[
|
||||
{
|
||||
label: cancelLabel,
|
||||
onClick: onClose,
|
||||
variant: 'secondary',
|
||||
},
|
||||
{
|
||||
label: confirmLabel,
|
||||
onClick: onConfirm,
|
||||
variant: confirmVariant,
|
||||
loading,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
33
desktop/src/components/shared/ConfirmPopover.tsx
Normal file
33
desktop/src/components/shared/ConfirmPopover.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { Button } from './Button'
|
||||
|
||||
type ConfirmPopoverProps = {
|
||||
message: string
|
||||
confirmLabel: string
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
cancelLabel: string
|
||||
confirmVariant?: 'primary' | 'danger'
|
||||
}
|
||||
|
||||
export function ConfirmPopover({
|
||||
message,
|
||||
confirmLabel,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
cancelLabel,
|
||||
confirmVariant = 'primary',
|
||||
}: ConfirmPopoverProps) {
|
||||
return (
|
||||
<div className="absolute right-0 top-full mt-1.5 z-50 w-52 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-lg p-3">
|
||||
<p className="mb-2.5 text-xs text-[var(--color-text-secondary)]">{message}</p>
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button type="button" variant={confirmVariant} size="sm" onClick={onConfirm}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -5,6 +5,7 @@ import { useTranslation } from '../../i18n'
|
||||
import { describeCron } from '../../lib/cronDescribe'
|
||||
import { TaskRunsPanel } from './TaskRunsPanel'
|
||||
import { NewTaskModal } from './NewTaskModal'
|
||||
import { ConfirmPopover } from '../shared/ConfirmPopover'
|
||||
|
||||
type Props = {
|
||||
task: CronTask
|
||||
@ -193,7 +194,7 @@ export function TaskRow({ task, showLogs, onToggleLogs }: Props) {
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => { setConfirmAction(null); setShowMenu(false) }}
|
||||
cancelLabel={t('common.cancel')}
|
||||
variant="error"
|
||||
confirmVariant="danger"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -216,38 +217,3 @@ export function TaskRow({ task, showLogs, onToggleLogs }: Props) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Confirm Popover ─────────────────────────────────────────────────────────
|
||||
|
||||
function ConfirmPopover({ message, confirmLabel, onConfirm, onCancel, cancelLabel, variant = 'brand' }: {
|
||||
message: string
|
||||
confirmLabel: string
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
cancelLabel: string
|
||||
variant?: 'brand' | 'error'
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute right-0 top-full mt-1.5 z-50 w-52 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-lg p-3">
|
||||
<p className="text-xs text-[var(--color-text-secondary)] mb-2.5">{message}</p>
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-2.5 py-1 text-xs rounded-[var(--radius-sm)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] transition-colors"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`px-2.5 py-1 text-xs rounded-[var(--radius-sm)] hover:opacity-90 transition-opacity ${
|
||||
variant === 'error'
|
||||
? 'bg-[var(--color-error-container)] text-[var(--color-on-error-container)]'
|
||||
: 'bg-[image:var(--gradient-btn-primary)] text-[var(--color-btn-primary-fg)]'
|
||||
}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -472,6 +472,7 @@ export const en = {
|
||||
'settings.adapters.dingtalkAuthFailed': 'DingTalk QR authorization failed.',
|
||||
'settings.adapters.dingtalkAuthExpired': 'DingTalk QR authorization expired. Generate a new QR code.',
|
||||
'settings.adapters.dingtalkUnbindFailed': 'Failed to unbind DingTalk.',
|
||||
'settings.adapters.dingtalkUnbindBotConfirm': 'Unbind this DingTalk bot account? You will need to scan again before DingTalk can receive or send messages.',
|
||||
'settings.adapters.dingtalkClientId': 'Client ID',
|
||||
'settings.adapters.dingtalkClientIdPlaceholder': 'Filled by QR auth, or enter manually',
|
||||
'settings.adapters.dingtalkClientSecret': 'Client Secret',
|
||||
@ -490,6 +491,7 @@ export const en = {
|
||||
'settings.adapters.wechatWaiting': 'Waiting for WeChat confirmation...',
|
||||
'settings.adapters.wechatBindSuccess': 'WeChat bound successfully.',
|
||||
'settings.adapters.wechatUnbindAccount': 'Unbind WeChat account',
|
||||
'settings.adapters.wechatUnbindAccountConfirm': 'Unbind this WeChat account? You will need to scan again before WeChat can receive or send messages.',
|
||||
'settings.adapters.wechatUnbound': 'WeChat account unbound.',
|
||||
'settings.adapters.wechatUnbindFailed': 'Failed to unbind WeChat.',
|
||||
'settings.adapters.wechatAllowedUsersHint': 'QR binding enables the account only. Users still need to send a pairing code, or you can add allowed WeChat user IDs here.',
|
||||
|
||||
@ -474,6 +474,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.adapters.dingtalkAuthFailed': '钉钉扫码授权失败。',
|
||||
'settings.adapters.dingtalkAuthExpired': '钉钉扫码授权已过期,请重新生成二维码。',
|
||||
'settings.adapters.dingtalkUnbindFailed': '钉钉解绑失败。',
|
||||
'settings.adapters.dingtalkUnbindBotConfirm': '确定解除这个钉钉机器人绑定吗?解除后需要重新扫码,钉钉才能继续收发消息。',
|
||||
'settings.adapters.dingtalkClientId': 'Client ID',
|
||||
'settings.adapters.dingtalkClientIdPlaceholder': '扫码后自动填写,也可手动输入',
|
||||
'settings.adapters.dingtalkClientSecret': 'Client Secret',
|
||||
@ -492,6 +493,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.adapters.wechatWaiting': '等待微信扫码确认...',
|
||||
'settings.adapters.wechatBindSuccess': '微信绑定成功。',
|
||||
'settings.adapters.wechatUnbindAccount': '解除微信绑定',
|
||||
'settings.adapters.wechatUnbindAccountConfirm': '确定解除这个微信账号绑定吗?解除后需要重新扫码,微信才能继续收发消息。',
|
||||
'settings.adapters.wechatUnbound': '微信绑定已解除。',
|
||||
'settings.adapters.wechatUnbindFailed': '微信解绑失败。',
|
||||
'settings.adapters.wechatAllowedUsersHint': '微信扫码只绑定账号能力;用户仍需发送配对码,或在这里额外填写允许的微信用户 ID。',
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
import { AdapterSettings } from './AdapterSettings'
|
||||
@ -9,12 +9,27 @@ import type { AdapterFileConfig } from '../types/adapter'
|
||||
|
||||
const FEISHU_CREATE_BOT_URL = 'https://open.feishu.cn/page/openclaw?form=multiAgent'
|
||||
|
||||
function renderAdapterSettings(config: AdapterFileConfig) {
|
||||
function renderAdapterSettings(
|
||||
config: AdapterFileConfig,
|
||||
overrides: Partial<ReturnType<typeof useAdapterStore.getState>> = {},
|
||||
) {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useAdapterStore.setState({
|
||||
config,
|
||||
isLoading: false,
|
||||
fetchConfig: vi.fn(async () => {}),
|
||||
updateConfig: vi.fn(async () => {}),
|
||||
unbindWechatAccount: vi.fn(async () => {}),
|
||||
unbindDingtalkBot: vi.fn(async () => {}),
|
||||
removePairedUser: vi.fn(async () => {}),
|
||||
beginDingtalkRegistration: vi.fn(async () => ({
|
||||
deviceCode: 'device-code',
|
||||
verificationUriComplete: 'https://example.com/auth',
|
||||
intervalSeconds: 1,
|
||||
expiresInSeconds: 60,
|
||||
})),
|
||||
pollDingtalkRegistration: vi.fn(async () => ({ status: 'PENDING' })),
|
||||
...overrides,
|
||||
} as Partial<ReturnType<typeof useAdapterStore.getState>>)
|
||||
|
||||
render(<AdapterSettings />)
|
||||
@ -52,3 +67,51 @@ describe('AdapterSettings Feishu onboarding', () => {
|
||||
expect(screen.queryByText('Need a Feishu bot?')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AdapterSettings account unbind confirmation', () => {
|
||||
it('confirms before unbinding a WeChat account', async () => {
|
||||
const unbindWechatAccount = vi.fn(async () => {})
|
||||
renderAdapterSettings(
|
||||
{ wechat: { accountId: 'wx-account' } },
|
||||
{ unbindWechatAccount },
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'WeChat' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Unbind WeChat account' }))
|
||||
|
||||
expect(unbindWechatAccount).not.toHaveBeenCalled()
|
||||
const dialog = screen.getByRole('dialog', { name: 'Unbind WeChat account' })
|
||||
expect(within(dialog).getByText(/You will need to scan again/)).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }))
|
||||
expect(unbindWechatAccount).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Unbind WeChat account' }))
|
||||
fireEvent.click(within(screen.getByRole('dialog', { name: 'Unbind WeChat account' })).getByRole('button', { name: 'Unbind WeChat account' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(unbindWechatAccount).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('confirms before unbinding a DingTalk bot account', async () => {
|
||||
const unbindDingtalkBot = vi.fn(async () => {})
|
||||
renderAdapterSettings(
|
||||
{ dingtalk: { clientId: 'dt-client' } },
|
||||
{ unbindDingtalkBot },
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'DingTalk' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Unbind bot account' }))
|
||||
|
||||
expect(unbindDingtalkBot).not.toHaveBeenCalled()
|
||||
const dialog = screen.getByRole('dialog', { name: 'Unbind bot account' })
|
||||
expect(within(dialog).getByText(/You will need to scan again/)).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Unbind bot account' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(unbindDingtalkBot).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -9,6 +9,7 @@ import QRCode from 'qrcode'
|
||||
|
||||
type ImTab = 'feishu' | 'wechat' | 'dingtalk' | 'telegram'
|
||||
type ImPlatform = 'telegram' | 'feishu' | 'wechat' | 'dingtalk'
|
||||
type AdapterUnbindTarget = 'wechatAccount' | 'dingtalkBot'
|
||||
|
||||
const FEISHU_CREATE_BOT_URL = 'https://open.feishu.cn/page/openclaw?form=multiAgent'
|
||||
|
||||
@ -82,6 +83,7 @@ export function AdapterSettings() {
|
||||
const [pairingCode, setPairingCode] = useState<string | null>(null)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [pendingUnbind, setPendingUnbind] = useState<{ platform: ImPlatform; userId: string | number } | null>(null)
|
||||
const [pendingAdapterUnbind, setPendingAdapterUnbind] = useState<AdapterUnbindTarget | null>(null)
|
||||
const [isUnbinding, setIsUnbinding] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@ -330,6 +332,7 @@ export function AdapterSettings() {
|
||||
} finally {
|
||||
setIsUnbindingWechatAccount(false)
|
||||
setIsWechatBinding(false)
|
||||
setPendingAdapterUnbind(null)
|
||||
}
|
||||
}, [unbindWechatAccount, fetchConfig, t])
|
||||
|
||||
@ -346,6 +349,7 @@ export function AdapterSettings() {
|
||||
setDtAuthError(err instanceof Error ? err.message : t('settings.adapters.dingtalkUnbindFailed'))
|
||||
} finally {
|
||||
setIsUnbindingDtBot(false)
|
||||
setPendingAdapterUnbind(null)
|
||||
}
|
||||
}, [unbindDingtalkBot, fetchConfig, t])
|
||||
|
||||
@ -606,7 +610,7 @@ export function AdapterSettings() {
|
||||
{config.wechat?.accountId ? t('settings.adapters.wechatRebind') : t('settings.adapters.wechatBind')}
|
||||
</Button>
|
||||
{config.wechat?.accountId && (
|
||||
<Button onClick={handleUnbindWechatAccount} loading={isUnbindingWechatAccount} size="sm" variant="danger">
|
||||
<Button onClick={() => setPendingAdapterUnbind('wechatAccount')} loading={isUnbindingWechatAccount} size="sm" variant="danger">
|
||||
{t('settings.adapters.wechatUnbindAccount')}
|
||||
</Button>
|
||||
)}
|
||||
@ -656,7 +660,7 @@ export function AdapterSettings() {
|
||||
{t('settings.adapters.dingtalkStartAuth')}
|
||||
</Button>
|
||||
{(config.dingtalk?.clientId || dtClientId) && (
|
||||
<Button onClick={handleUnbindDingtalkBot} loading={isUnbindingDtBot} size="sm" variant="danger">
|
||||
<Button onClick={() => setPendingAdapterUnbind('dingtalkBot')} loading={isUnbindingDtBot} size="sm" variant="danger">
|
||||
{t('settings.adapters.dingtalkUnbindBot')}
|
||||
</Button>
|
||||
)}
|
||||
@ -791,6 +795,26 @@ export function AdapterSettings() {
|
||||
confirmVariant="danger"
|
||||
loading={isUnbinding}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={pendingAdapterUnbind !== null}
|
||||
onClose={() => {
|
||||
if (isUnbindingWechatAccount || isUnbindingDtBot) return
|
||||
setPendingAdapterUnbind(null)
|
||||
}}
|
||||
onConfirm={pendingAdapterUnbind === 'wechatAccount' ? handleUnbindWechatAccount : handleUnbindDingtalkBot}
|
||||
title={pendingAdapterUnbind === 'wechatAccount'
|
||||
? t('settings.adapters.wechatUnbindAccount')
|
||||
: t('settings.adapters.dingtalkUnbindBot')}
|
||||
body={pendingAdapterUnbind === 'wechatAccount'
|
||||
? t('settings.adapters.wechatUnbindAccountConfirm')
|
||||
: t('settings.adapters.dingtalkUnbindBotConfirm')}
|
||||
confirmLabel={pendingAdapterUnbind === 'wechatAccount'
|
||||
? t('settings.adapters.wechatUnbindAccount')
|
||||
: t('settings.adapters.dingtalkUnbindBot')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
confirmVariant="danger"
|
||||
loading={isUnbindingWechatAccount || isUnbindingDtBot}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import { useTranslation } from '../i18n'
|
||||
import { formatBytes } from '../lib/formatBytes'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
import { DoctorPanel } from '../components/doctor/DoctorPanel'
|
||||
import { ConfirmDialog } from '../components/shared/ConfirmDialog'
|
||||
|
||||
export function DiagnosticsSettings() {
|
||||
const t = useTranslation()
|
||||
@ -15,6 +16,7 @@ export function DiagnosticsSettings() {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [isClearing, setIsClearing] = useState(false)
|
||||
const [clearConfirmOpen, setClearConfirmOpen] = useState(false)
|
||||
const [lastExportPath, setLastExportPath] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@ -90,13 +92,13 @@ export function DiagnosticsSettings() {
|
||||
}
|
||||
|
||||
const handleClear = async () => {
|
||||
if (!window.confirm(t('settings.diagnostics.confirmClear'))) return
|
||||
setIsClearing(true)
|
||||
try {
|
||||
await diagnosticsApi.clear()
|
||||
setEvents([])
|
||||
setStatus(await diagnosticsApi.getStatus())
|
||||
setLastExportPath(null)
|
||||
setClearConfirmOpen(false)
|
||||
addToast({ type: 'success', message: t('settings.diagnostics.cleared') })
|
||||
} catch (error) {
|
||||
addToast({
|
||||
@ -152,7 +154,7 @@ export function DiagnosticsSettings() {
|
||||
<span className="material-symbols-outlined text-[16px]">content_copy</span>
|
||||
{t('settings.diagnostics.copySummary')}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={handleClear} loading={isClearing}>
|
||||
<Button variant="danger" size="sm" onClick={() => setClearConfirmOpen(true)} loading={isClearing}>
|
||||
<span className="material-symbols-outlined text-[16px]">delete</span>
|
||||
{t('settings.diagnostics.clearLogs')}
|
||||
</Button>
|
||||
@ -186,6 +188,20 @@ export function DiagnosticsSettings() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={clearConfirmOpen}
|
||||
onClose={() => {
|
||||
if (!isClearing) setClearConfirmOpen(false)
|
||||
}}
|
||||
onConfirm={handleClear}
|
||||
title={t('settings.diagnostics.clearLogs')}
|
||||
body={t('settings.diagnostics.confirmClear')}
|
||||
confirmLabel={t('settings.diagnostics.clearLogs')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
confirmVariant="danger"
|
||||
loading={isClearing}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button } from '../components/shared/Button'
|
||||
import { DirectoryPicker } from '../components/shared/DirectoryPicker'
|
||||
import { Input } from '../components/shared/Input'
|
||||
import { Modal } from '../components/shared/Modal'
|
||||
import { ConfirmDialog } from '../components/shared/ConfirmDialog'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
import { useMcpStore } from '../stores/mcpStore'
|
||||
@ -667,28 +667,20 @@ export function McpSettings() {
|
||||
}
|
||||
|
||||
const deleteModal = (
|
||||
<Modal
|
||||
<ConfirmDialog
|
||||
open={pendingDeleteServer !== null}
|
||||
onClose={() => {
|
||||
if (isDeleting) return
|
||||
setPendingDeleteServer(null)
|
||||
}}
|
||||
title={t('settings.mcp.form.deleteTitle')}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setPendingDeleteServer(null)} disabled={isDeleting}>
|
||||
{t('settings.mcp.form.cancel')}
|
||||
</Button>
|
||||
<Button variant="danger" onClick={confirmDelete} loading={isDeleting}>
|
||||
{t('settings.mcp.form.confirmDelete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<p className="text-sm leading-6 text-[var(--color-text-secondary)]">
|
||||
{pendingDeleteServer ? t('settings.mcp.form.deleteConfirmBody', { name: pendingDeleteServer.name }) : ''}
|
||||
</p>
|
||||
</Modal>
|
||||
body={pendingDeleteServer ? t('settings.mcp.form.deleteConfirmBody', { name: pendingDeleteServer.name }) : ''}
|
||||
confirmLabel={t('settings.mcp.form.confirmDelete')}
|
||||
cancelLabel={t('settings.mcp.form.cancel')}
|
||||
confirmVariant="danger"
|
||||
loading={isDeleting}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
)
|
||||
|
||||
const handleSave = async () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user