feat(desktop): add default session permission setting

Add a General settings control for the default permission mode used by newly created desktop chat sessions, while preserving per-session overrides.

Tested: cd desktop && bun run check:desktop
Not-tested: bun run verify and coverage gates were not run for this local desktop-scoped handoff.
Confidence: high
Scope-risk: narrow
This commit is contained in:
程序员阿江(Relakkes) 2026-07-02 20:11:37 +08:00
parent 96a5842a01
commit 82be8f45dd
12 changed files with 147 additions and 11 deletions

View File

@ -8,7 +8,7 @@ import { useUIStore } from '../stores/uiStore'
import { useUpdateStore } from '../stores/updateStore'
import type { SavedProvider } from '../types/provider'
import type { ProviderPreset } from '../types/providerPreset'
import type { AppMode, ChatSendBehavior, ThemeMode, UpdateProxySettings } from '../types/settings'
import type { AppMode, ChatSendBehavior, PermissionMode, ThemeMode, UpdateProxySettings } from '../types/settings'
import { browserHost } from '../lib/desktopHost/browserHost'
const MOCK_DELETE_PROVIDER = vi.fn()
@ -208,6 +208,7 @@ describe('Settings > General tab', () => {
useSettingsStore.setState({
locale: 'en',
theme: 'light',
permissionMode: 'default',
thinkingEnabled: true,
autoDreamEnabled: false,
skipWebFetchPreflight: true,
@ -258,6 +259,9 @@ describe('Settings > General tab', () => {
setTheme: vi.fn().mockImplementation(async (theme: ThemeMode) => {
useSettingsStore.setState({ theme })
}),
setPermissionMode: vi.fn().mockImplementation(async (permissionMode: PermissionMode) => {
useSettingsStore.setState({ permissionMode })
}),
setSkipWebFetchPreflight: vi.fn().mockImplementation(async (enabled: boolean) => {
useSettingsStore.setState({ skipWebFetchPreflight: enabled })
}),
@ -750,6 +754,25 @@ describe('Settings > General tab', () => {
expect(useSettingsStore.getState().setThinkingEnabled).toHaveBeenCalledWith(false)
})
it('lets the user choose a default permission mode for new sessions', async () => {
render(<Settings />)
fireEvent.click(screen.getByText('General'))
await act(async () => {
await Promise.resolve()
})
fireEvent.click(screen.getByRole('button', { name: 'Ask permissions' }))
fireEvent.click(screen.getByRole('menuitem', { name: /Bypass permissions/ }))
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Enable bypass' }))
await Promise.resolve()
})
expect(useSettingsStore.getState().setPermissionMode).toHaveBeenCalledWith('bypassPermissions')
expect(useSettingsStore.getState().permissionMode).toBe('bypassPermissions')
})
it('keeps Auto-dream disabled by default and confirms before enabling it', async () => {
render(<Settings />)

View File

@ -11,6 +11,7 @@ import { useSettingsStore } from '../../stores/settingsStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useTabStore } from '../../stores/tabStore'
import { useUIStore } from '../../stores/uiStore'
import { formatExactMessageTimestamp, formatMessageHoverTime } from '../../lib/formatMessageTimestamp'
import type { UIMessage } from '../../types/chat'
import type { PerSessionState } from '../../stores/chatStore'
@ -3315,8 +3316,9 @@ describe('MessageList nested tool calls', () => {
})
it('keeps user actions anchored to the right bubble and assistant actions to the left bubble', () => {
const now = new Date('2026-05-29T16:00:00+08:00').getTime()
vi.spyOn(Date, 'now').mockReturnValue(now)
const now = Date.now()
const userTimestamp = now - 5 * 60_000
const assistantTimestamp = now - 2 * 60 * 60_000
useChatStore.setState({
sessions: {
@ -3326,13 +3328,13 @@ describe('MessageList nested tool calls', () => {
id: 'user-1',
type: 'user_text',
content: '请把这条 prompt 放在右侧',
timestamp: now - 5 * 60_000,
timestamp: userTimestamp,
},
{
id: 'assistant-1',
type: 'assistant_text',
content: '这条回复应该停在左侧。',
timestamp: now - 2 * 60 * 60_000,
timestamp: assistantTimestamp,
},
],
}),
@ -3345,6 +3347,8 @@ describe('MessageList nested tool calls', () => {
const assistantShell = screen.getByText('这条回复应该停在左侧。').closest('[data-message-shell="assistant"]')
const userActions = screen.getByRole('button', { name: 'Copy prompt' }).closest('[data-message-actions]')
const assistantActions = screen.getByRole('button', { name: 'Copy reply' }).closest('[data-message-actions]')
const userTime = within(userActions as HTMLElement).getByText(formatMessageHoverTime(userTimestamp, 'en'))
const assistantTime = within(assistantActions as HTMLElement).getByText(formatMessageHoverTime(assistantTimestamp, 'en'))
expect(userShell).toBeTruthy()
expect(userShell?.className).toContain('items-end')
@ -3362,8 +3366,8 @@ describe('MessageList nested tool calls', () => {
expect(userActions?.className).not.toContain('h-0')
expect(userActions?.className).not.toContain('group-hover:h-7')
expect(userActions?.className).not.toContain('invisible')
expect(within(userActions as HTMLElement).getByText('5m ago')).toBeTruthy()
expect(within(assistantActions as HTMLElement).getByText('2h ago')).toBeTruthy()
expect(userTime.getAttribute('title')).toBe(formatExactMessageTimestamp(userTimestamp, 'en'))
expect(assistantTime.getAttribute('title')).toBe(formatExactMessageTimestamp(assistantTimestamp, 'en'))
})
it('uses the document column for markdown-heavy assistant replies', () => {

View File

@ -22,13 +22,14 @@ const MODE_ICONS: Record<PermissionMode, string> = {
type Props = {
workDir?: string
compact?: boolean
menuPlacement?: 'top' | 'bottom'
/** Controlled mode: override current value */
value?: PermissionMode
/** Controlled mode: called on change instead of updating global store */
onChange?: (mode: PermissionMode) => void
}
export function PermissionModeSelector({ workDir: workDirProp, compact = false, value, onChange }: Props = {}) {
export function PermissionModeSelector({ workDir: workDirProp, compact = false, menuPlacement = 'top', value, onChange }: Props = {}) {
const t = useTranslation()
const isMobile = useMobileViewport() && !isDesktopRuntime()
const { permissionMode: storeMode } = useSettingsStore()
@ -96,6 +97,9 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
? 'h-11 w-11 justify-center rounded-xl p-0'
: 'h-8 w-8 justify-center rounded-full p-0'
: 'gap-1.5 rounded-full px-2.5 py-1.5 text-xs'
const menuPlacementClass = menuPlacement === 'bottom'
? 'top-full mt-2'
: 'bottom-full mb-2'
const menuId = 'permission-mode-menu'
useEffect(() => {
@ -207,7 +211,7 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
{permissionOptions}
</MobileBottomSheet>
) : (
<div id={menuId} ref={menuRef} role="menu" className="absolute left-0 bottom-full mb-2 w-[320px] rounded-xl bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)] shadow-[var(--shadow-dropdown)] z-50 py-2">
<div id={menuId} ref={menuRef} role="menu" className={`absolute left-0 ${menuPlacementClass} w-[320px] rounded-xl bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)] shadow-[var(--shadow-dropdown)] z-50 py-2`}>
{menuContent}
</div>
)

View File

@ -13,6 +13,7 @@ const windowControlsMock = vi.hoisted(() => ({
show: true,
}))
const scrollIntoViewMock = vi.hoisted(() => vi.fn())
const deleteSessionMock = vi.hoisted(() => vi.fn())
const openProjectMenuMock = vi.hoisted(() => ({
paths: [] as Array<string | null | undefined>,
}))
@ -47,6 +48,17 @@ vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: getCurrentWindowMock,
}))
vi.mock('../../api/sessions', () => ({
sessionsApi: {
batchDelete: vi.fn(),
branch: vi.fn(),
create: vi.fn(),
delete: deleteSessionMock,
list: vi.fn(),
rename: vi.fn(),
},
}))
vi.mock('../../i18n', () => ({
useTranslation: () => (key: string, params?: Record<string, string | number>) => {
const translations: Record<string, string> = {
@ -142,6 +154,8 @@ describe('TabBar', () => {
startDraggingMock.mockClear()
getCurrentWindowMock.mockClear()
scrollIntoViewMock.mockClear()
deleteSessionMock.mockReset()
deleteSessionMock.mockResolvedValue(undefined)
openProjectMenuMock.paths = []
windowControlsMock.show = true
vi.resetModules()

View File

@ -981,6 +981,11 @@ export const en = {
'settings.general.outputStyleSourceLocal': 'Local project style',
'settings.general.outputStyleSourcePolicy': 'Managed style',
'settings.general.outputStyleSourcePlugin': 'Plugin style',
'settings.general.defaultPermissionTitle': 'Default Session Permissions',
'settings.general.defaultPermissionDescription': 'Choose the permission mode applied when a new desktop chat session starts.',
'settings.general.defaultPermissionLabel': 'New session permission mode',
'settings.general.defaultPermissionHint': 'Individual sessions can still be changed from the chat permission selector.',
'settings.general.defaultPermissionScope': 'Each new session workspace',
'settings.general.effortTitle': 'Effort Level',
'settings.general.effortDescription': 'Controls how much computation the model uses.',
'settings.general.effort.low': 'Low',

View File

@ -983,6 +983,11 @@ export const jp: Record<TranslationKey, string> = {
'settings.general.outputStyleSourceLocal': 'ローカルプロジェクトスタイル',
'settings.general.outputStyleSourcePolicy': '管理スタイル',
'settings.general.outputStyleSourcePlugin': 'プラグインスタイル',
'settings.general.defaultPermissionTitle': 'デフォルトのセッション権限',
'settings.general.defaultPermissionDescription': 'デスクトップで新しいチャットセッションを開始するときに適用する権限モードを選択します。',
'settings.general.defaultPermissionLabel': '新しいセッションの権限モード',
'settings.general.defaultPermissionHint': '各セッション内では、チャットの権限セレクターから個別に変更できます。',
'settings.general.defaultPermissionScope': '各新規セッションのワークスペース',
'settings.general.effortTitle': '労力レベル',
'settings.general.effortDescription': 'モデルが使用する計算量を制御します。',
'settings.general.effort.low': '低',

View File

@ -983,6 +983,11 @@ export const kr: Record<TranslationKey, string> = {
'settings.general.outputStyleSourceLocal': '로컬 프로젝트 스타일',
'settings.general.outputStyleSourcePolicy': '관리형 스타일',
'settings.general.outputStyleSourcePlugin': '플러그인 스타일',
'settings.general.defaultPermissionTitle': '기본 세션 권한',
'settings.general.defaultPermissionDescription': '새 데스크톱 채팅 세션을 시작할 때 적용할 권한 모드를 선택합니다.',
'settings.general.defaultPermissionLabel': '새 세션 권한 모드',
'settings.general.defaultPermissionHint': '각 세션 안에서는 채팅 권한 선택기에서 개별적으로 변경할 수 있습니다.',
'settings.general.defaultPermissionScope': '각 새 세션의 작업 공간',
'settings.general.effortTitle': '노력 수준',
'settings.general.effortDescription': '모델이 사용하는 계산량을 제어합니다.',
'settings.general.effort.low': '낮음',

View File

@ -983,6 +983,11 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.outputStyleSourceLocal': '專案本地風格',
'settings.general.outputStyleSourcePolicy': '受管風格',
'settings.general.outputStyleSourcePlugin': '外掛風格',
'settings.general.defaultPermissionTitle': '預設會話權限',
'settings.general.defaultPermissionDescription': '選擇桌面端新建聊天會話時預設使用的權限模式。',
'settings.general.defaultPermissionLabel': '新會話權限模式',
'settings.general.defaultPermissionHint': '每個會話內部仍然可以透過聊天區的權限選擇器單獨修改。',
'settings.general.defaultPermissionScope': '每個新會話的工作區',
'settings.general.effortTitle': '推理強度',
'settings.general.effortDescription': '控制模型使用的計算量。',
'settings.general.effort.low': '低',

View File

@ -983,6 +983,11 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.outputStyleSourceLocal': '项目本地风格',
'settings.general.outputStyleSourcePolicy': '受管风格',
'settings.general.outputStyleSourcePlugin': '插件风格',
'settings.general.defaultPermissionTitle': '默认会话权限',
'settings.general.defaultPermissionDescription': '选择桌面端新建聊天会话时默认使用的权限模式。',
'settings.general.defaultPermissionLabel': '新会话权限模式',
'settings.general.defaultPermissionHint': '每个会话内部仍然可以通过聊天区的权限选择器单独修改。',
'settings.general.defaultPermissionScope': '每个新会话的工作区',
'settings.general.effortTitle': '推理强度',
'settings.general.effortDescription': '控制模型使用的计算量。',
'settings.general.effort.low': '低',

View File

@ -26,6 +26,7 @@ import { ConfirmDialog } from '../components/shared/ConfirmDialog'
import { Input } from '../components/shared/Input'
import { Button } from '../components/shared/Button'
import { Dropdown } from '../components/shared/Dropdown'
import { PermissionModeSelector } from '../components/controls/PermissionModeSelector'
import type { ThemeMode, UpdateProxyMode, NetworkProxyMode, WebSearchMode, AppMode, ChatSendBehavior, OutputStyleSource } from '../types/settings'
import type { Locale } from '../i18n'
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, Model1mSupport, ApiFormat, ProviderAuthStrategy } from '../types/provider'
@ -1933,6 +1934,8 @@ export function GeneralSettings() {
const {
thinkingEnabled,
setThinkingEnabled,
permissionMode,
setPermissionMode,
autoDreamEnabled,
setAutoDreamEnabled,
locale,
@ -2585,6 +2588,29 @@ export function GeneralSettings() {
)}
</div>
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.defaultPermissionTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.defaultPermissionDescription')}</p>
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.general.defaultPermissionLabel')}
</div>
<div className="mt-1 text-xs leading-5 text-[var(--color-text-tertiary)]">
{t('settings.general.defaultPermissionHint')}
</div>
</div>
<PermissionModeSelector
value={permissionMode}
onChange={(mode) => void setPermissionMode(mode)}
workDir={t('settings.general.defaultPermissionScope')}
menuPlacement="bottom"
/>
</div>
</div>
</div>
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.thinkingTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.thinkingDescription')}</p>

View File

@ -17,6 +17,7 @@ vi.mock('../api/sessions', () => ({
}))
import { useSessionStore } from './sessionStore'
import { useSettingsStore } from './settingsStore'
import { useTabStore } from './tabStore'
const initialState = useSessionStore.getState()
@ -59,11 +60,13 @@ describe('sessionStore', () => {
isLoading: false,
error: null,
})
useSettingsStore.setState({ permissionMode: 'default' })
useTabStore.setState({ tabs: [], activeTabId: null })
})
afterEach(() => {
useSessionStore.setState(initialState)
useSettingsStore.setState({ permissionMode: 'default' })
useTabStore.setState({ tabs: [], activeTabId: null })
})
@ -262,6 +265,36 @@ describe('sessionStore', () => {
.toBe('/workspace/repo/.claude/worktrees/desktop-feature-rail-12345678')
})
it('uses the global default permission mode for new sessions when no session override is provided', async () => {
useSettingsStore.setState({ permissionMode: 'bypassPermissions' })
createMock.mockResolvedValue({ sessionId: 'session-default-permission', workDir: '/workspace/repo' })
listMock.mockImplementation(() => new Promise(() => {}))
await useSessionStore.getState().createSession('/workspace/repo')
expect(createMock).toHaveBeenCalledWith({
workDir: '/workspace/repo',
permissionMode: 'bypassPermissions',
})
expect(useSessionStore.getState().sessions[0]?.permissionMode).toBe('bypassPermissions')
})
it('keeps an explicit session permission override ahead of the global default', async () => {
useSettingsStore.setState({ permissionMode: 'bypassPermissions' })
createMock.mockResolvedValue({ sessionId: 'session-explicit-permission', workDir: '/workspace/repo' })
listMock.mockImplementation(() => new Promise(() => {}))
await useSessionStore.getState().createSession('/workspace/repo', {
permissionMode: 'acceptEdits',
})
expect(createMock).toHaveBeenCalledWith({
workDir: '/workspace/repo',
permissionMode: 'acceptEdits',
})
expect(useSessionStore.getState().sessions[0]?.permissionMode).toBe('acceptEdits')
})
it('returns the branched session before the background refresh completes', async () => {
branchMock.mockResolvedValue({
sessionId: 'session-branch-1',

View File

@ -6,6 +6,7 @@ import {
type CreateSessionRepositoryOptions,
} from '../api/sessions'
import { useSessionRuntimeStore } from './sessionRuntimeStore'
import { useSettingsStore } from './settingsStore'
import { useTabStore } from './tabStore'
import type { SessionListItem } from '../types/session'
import type { PermissionMode } from '../types/settings'
@ -79,10 +80,11 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
},
createSession: async (workDir?: string, options?: CreateSessionOptions) => {
const requestedPermissionMode = options?.permissionMode ?? getDefaultSessionPermissionMode()
const { sessionId: id, workDir: resolvedWorkDir } = await sessionsApi.create({
...(workDir ? { workDir } : {}),
...(options?.repository ? { repository: options.repository } : {}),
...(options?.permissionMode ? { permissionMode: options.permissionMode } : {}),
...(requestedPermissionMode ? { permissionMode: requestedPermissionMode } : {}),
})
const now = new Date().toISOString()
const optimisticSession: SessionListItem = {
@ -95,7 +97,7 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
workDir: resolvedWorkDir ?? workDir ?? null,
projectRoot: resolvedWorkDir ?? workDir ?? null,
workDirExists: true,
permissionMode: options?.permissionMode,
permissionMode: requestedPermissionMode,
}
set((state) => ({
@ -234,6 +236,11 @@ function buildSessionListParams(project: string | undefined) {
: { limit: SESSION_LIST_LIMIT }
}
function getDefaultSessionPermissionMode(): PermissionMode | undefined {
const mode = useSettingsStore.getState().permissionMode
return mode === 'default' ? undefined : mode
}
function mergeSessionList(
incoming: SessionListItem[],
currentForTitle: SessionListItem[],