diff --git a/desktop/src/components/controls/PermissionModeSelector.test.tsx b/desktop/src/components/controls/PermissionModeSelector.test.tsx index fe242066..c54fbb83 100644 --- a/desktop/src/components/controls/PermissionModeSelector.test.tsx +++ b/desktop/src/components/controls/PermissionModeSelector.test.tsx @@ -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>) + }) 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() + + 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() + + 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() + }) }) diff --git a/desktop/src/components/controls/PermissionModeSelector.tsx b/desktop/src/components/controls/PermissionModeSelector.tsx index 50da59ef..3b0f9b89 100644 --- a/desktop/src/components/controls/PermissionModeSelector.tsx +++ b/desktop/src/components/controls/PermissionModeSelector.tsx @@ -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) { diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index fb8f54c3..0748c757 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index c6524dce..518012f6 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -1640,6 +1640,7 @@ export const jp: Record = { 'permMode.permShell': '任意のシェルコマンドの実行', 'permMode.permPackages': 'パッケージのインストールまたは削除', 'permMode.enableBypassBtn': 'バイパスを有効化', + 'permMode.disabledDuringTurn': 'セッションの実行中は権限を切り替えられません', // Mode labels (compact, for chips) 'permMode.label.default': '権限を確認', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 83524c4e..5dd77dbb 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -1640,6 +1640,7 @@ export const kr: Record = { 'permMode.permShell': '임의의 셸 명령 실행', 'permMode.permPackages': '패키지 설치 또는 제거', 'permMode.enableBypassBtn': '우회 사용', + 'permMode.disabledDuringTurn': '세션이 진행 중일 때는 권한을 변경할 수 없습니다', // Mode labels (compact, for chips) 'permMode.label.default': '권한 확인', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 1db149ab..0d647bef 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -1640,6 +1640,7 @@ export const zh: Record = { 'permMode.permShell': '執行任意 Shell 命令', 'permMode.permPackages': '安裝或移除軟體包', 'permMode.enableBypassBtn': '啟用跳過', + 'permMode.disabledDuringTurn': '工作階段進行中,無法切換權限', // Mode labels (compact, for chips) 'permMode.label.default': '詢問許可權', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 50319e07..1f601d58 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1640,6 +1640,7 @@ export const zh: Record = { 'permMode.permShell': '执行任意 Shell 命令', 'permMode.permPackages': '安装或移除软件包', 'permMode.enableBypassBtn': '启用跳过', + 'permMode.disabledDuringTurn': '会话进行中,无法切换权限', // Mode labels (compact, for chips) 'permMode.label.default': '询问权限', diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 430d5996..32eecfc8 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -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() diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index c220cbff..87181e3f 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -1296,7 +1296,8 @@ export const useChatStore = create((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 }) },