fix(desktop): complete active-turn permission guard

Close open permission overlays when a turn starts, reject stale permission-mode writes in the chat store, and add the missing localized tooltip strings and transition regressions.\n\nFollow-up-to: #999\nTested: bun run check:desktop\nConfidence: high\nScope-risk: narrow
This commit is contained in:
程序员阿江(Relakkes) 2026-07-10 18:10:57 +08:00
parent 745112a136
commit a6964f1932
9 changed files with 126 additions and 5 deletions

View File

@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
@ -45,15 +45,43 @@ vi.mock('../../i18n', () => ({
}))
import { PermissionModeSelector } from './PermissionModeSelector'
import { useChatStore } from '../../stores/chatStore'
import { useChatStore, type PerSessionState } from '../../stores/chatStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useTabStore } from '../../stores/tabStore'
const initialSetSessionPermissionMode = useChatStore.getState().setSessionPermissionMode
function makeChatSession(chatState: PerSessionState['chatState']): PerSessionState {
return {
messages: [],
chatState,
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
streamingResponseChars: 0,
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
}
}
describe('PermissionModeSelector', () => {
beforeEach(() => {
viewportMocks.isMobile = false
useSettingsStore.setState({ permissionMode: 'default' })
useChatStore.setState({
sessions: {},
setSessionPermissionMode: initialSetSessionPermissionMode,
})
useSessionStore.setState({ sessions: [], activeSessionId: null })
useTabStore.setState({ activeTabId: null, tabs: [] })
})
@ -166,9 +194,9 @@ describe('PermissionModeSelector', () => {
useChatStore.setState({
setSessionPermissionMode,
sessions: {
'current-tab': { chatState: 'thinking' },
'current-tab': makeChatSession('thinking'),
},
} as Partial<ReturnType<typeof useChatStore.getState>>)
})
useSessionStore.setState({
activeSessionId: 'current-tab',
sessions: [
@ -202,4 +230,61 @@ describe('PermissionModeSelector', () => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(setSessionPermissionMode).not.toHaveBeenCalled()
})
it('closes an open permission menu when the session turn starts', () => {
useChatStore.setState({
sessions: {
'current-tab': makeChatSession('idle'),
},
})
useTabStore.setState({
activeTabId: 'current-tab',
tabs: [{ sessionId: 'current-tab', title: 'Current', type: 'session', status: 'idle' }],
})
render(<PermissionModeSelector />)
const trigger = screen.getByRole('button', { name: 'Ask permissions' })
fireEvent.click(trigger)
expect(screen.getByRole('menuitem', { name: /Auto accept edits/ })).toBeInTheDocument()
act(() => {
useChatStore.setState({
sessions: {
'current-tab': makeChatSession('thinking'),
},
})
})
expect(trigger).toBeDisabled()
expect(screen.queryByRole('menuitem', { name: /Auto accept edits/ })).not.toBeInTheDocument()
})
it('closes an open bypass confirmation when the session turn starts', () => {
useChatStore.setState({
sessions: {
'current-tab': makeChatSession('idle'),
},
})
useTabStore.setState({
activeTabId: 'current-tab',
tabs: [{ sessionId: 'current-tab', title: 'Current', type: 'session', status: 'idle' }],
})
render(<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()
act(() => {
useChatStore.setState({
sessions: {
'current-tab': makeChatSession('tool_executing'),
},
})
})
expect(screen.queryByRole('dialog', { name: 'Enable bypass mode' })).not.toBeInTheDocument()
})
})

View File

@ -106,6 +106,13 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
: 'bottom-full mb-2'
const menuId = 'permission-mode-menu'
useEffect(() => {
if (isTurnActive) {
setOpen(false)
setConfirmDialog(false)
}
}, [isTurnActive])
useEffect(() => {
if (!open) return
const handleClick = (e: MouseEvent) => {
@ -136,6 +143,10 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
key={item.value}
role="menuitem"
onClick={() => {
if (isTurnActive) {
setOpen(false)
return
}
if (item.value === 'bypassPermissions') {
setOpen(false)
setConfirmDialog(true)
@ -265,6 +276,10 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
{
label: t('permMode.enableBypassBtn'),
onClick: () => {
if (isTurnActive) {
setConfirmDialog(false)
return
}
if (isControlled) {
onChange?.('bypassPermissions')
} else if (activeTabId) {

View File

@ -1638,6 +1638,7 @@ export const en = {
'permMode.permShell': 'Execute arbitrary shell commands',
'permMode.permPackages': 'Install or remove packages',
'permMode.enableBypassBtn': 'Enable bypass',
'permMode.disabledDuringTurn': 'Cannot switch permissions while session is active',
// Mode labels (compact, for chips)
'permMode.label.default': 'Ask permissions',

View File

@ -1640,6 +1640,7 @@ export const jp: Record<TranslationKey, string> = {
'permMode.permShell': '任意のシェルコマンドの実行',
'permMode.permPackages': 'パッケージのインストールまたは削除',
'permMode.enableBypassBtn': 'バイパスを有効化',
'permMode.disabledDuringTurn': 'セッションの実行中は権限を切り替えられません',
// Mode labels (compact, for chips)
'permMode.label.default': '権限を確認',

View File

@ -1640,6 +1640,7 @@ export const kr: Record<TranslationKey, string> = {
'permMode.permShell': '임의의 셸 명령 실행',
'permMode.permPackages': '패키지 설치 또는 제거',
'permMode.enableBypassBtn': '우회 사용',
'permMode.disabledDuringTurn': '세션이 진행 중일 때는 권한을 변경할 수 없습니다',
// Mode labels (compact, for chips)
'permMode.label.default': '권한 확인',

View File

@ -1640,6 +1640,7 @@ export const zh: Record<TranslationKey, string> = {
'permMode.permShell': '執行任意 Shell 命令',
'permMode.permPackages': '安裝或移除軟體包',
'permMode.enableBypassBtn': '啟用跳過',
'permMode.disabledDuringTurn': '工作階段進行中,無法切換權限',
// Mode labels (compact, for chips)
'permMode.label.default': '詢問許可權',

View File

@ -1640,6 +1640,7 @@ export const zh: Record<TranslationKey, string> = {
'permMode.permShell': '执行任意 Shell 命令',
'permMode.permPackages': '安装或移除软件包',
'permMode.enableBypassBtn': '启用跳过',
'permMode.disabledDuringTurn': '会话进行中,无法切换权限',
// Mode labels (compact, for chips)
'permMode.label.default': '询问权限',

View File

@ -2835,6 +2835,21 @@ describe('chatStore history mapping', () => {
expect(updateSessionPermissionModeMock).toHaveBeenCalledWith('session-1', 'acceptEdits')
})
it('does not send permission mode updates while the session turn is active', () => {
useChatStore.setState({
sessions: {
'session-1': makeSession({ chatState: 'thinking' }),
},
})
useChatStore.getState().setSessionPermissionMode('session-1', 'acceptEdits')
expect(sendMock).not.toHaveBeenCalledWith('session-1', {
type: 'set_permission_mode',
mode: 'acceptEdits',
})
})
it('mirrors CLI permission-mode broadcasts locally without echoing back to the server', () => {
sendMock.mockReset()
updateSessionPermissionModeMock.mockReset()

View File

@ -1296,7 +1296,8 @@ export const useChatStore = create<ChatStore>((set, get) => ({
},
setSessionPermissionMode: (sessionId, mode) => {
if (!get().sessions[sessionId]) return
const session = get().sessions[sessionId]
if (!session || session.chatState !== 'idle') return
wsManager.send(sessionId, { type: 'set_permission_mode', mode })
},