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:
程序员阿江(Relakkes) 2026-05-30 21:25:31 +08:00
parent 66a0d45627
commit 6ca7ee48a8
15 changed files with 367 additions and 189 deletions

View File

@ -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

View File

@ -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()
})

View File

@ -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>
)
}

View File

@ -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()

View File

@ -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>
)
}

View 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>
)
}

View File

@ -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,
},
]}
/>
)
}

View 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>
)
}

View File

@ -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>
)
}

View File

@ -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.',

View File

@ -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。',

View File

@ -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)
})
})
})

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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 () => {