From 82be8f45dd550cd241e6314e606352ac5e058dce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 2 Jul 2026 20:11:37 +0800 Subject: [PATCH] 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 --- .../src/__tests__/generalSettings.test.tsx | 25 +++++++++++++- .../src/components/chat/MessageList.test.tsx | 16 +++++---- .../controls/PermissionModeSelector.tsx | 8 +++-- desktop/src/components/layout/TabBar.test.tsx | 14 ++++++++ desktop/src/i18n/locales/en.ts | 5 +++ desktop/src/i18n/locales/jp.ts | 5 +++ desktop/src/i18n/locales/kr.ts | 5 +++ desktop/src/i18n/locales/zh-TW.ts | 5 +++ desktop/src/i18n/locales/zh.ts | 5 +++ desktop/src/pages/Settings.tsx | 26 +++++++++++++++ desktop/src/stores/sessionStore.test.ts | 33 +++++++++++++++++++ desktop/src/stores/sessionStore.ts | 11 +++++-- 12 files changed, 147 insertions(+), 11 deletions(-) diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 40367de5..91b52ce0 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -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() + + 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() diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 008db94b..cb580da7 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -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', () => { diff --git a/desktop/src/components/controls/PermissionModeSelector.tsx b/desktop/src/components/controls/PermissionModeSelector.tsx index 7b8c4370..f7d094ac 100644 --- a/desktop/src/components/controls/PermissionModeSelector.tsx +++ b/desktop/src/components/controls/PermissionModeSelector.tsx @@ -22,13 +22,14 @@ const MODE_ICONS: Record = { 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} ) : ( - +
+

{t('settings.general.defaultPermissionTitle')}

+

{t('settings.general.defaultPermissionDescription')}

+
+
+
+
+ {t('settings.general.defaultPermissionLabel')} +
+
+ {t('settings.general.defaultPermissionHint')} +
+
+ void setPermissionMode(mode)} + workDir={t('settings.general.defaultPermissionScope')} + menuPlacement="bottom" + /> +
+
+
+

{t('settings.general.thinkingTitle')}

{t('settings.general.thinkingDescription')}

diff --git a/desktop/src/stores/sessionStore.test.ts b/desktop/src/stores/sessionStore.test.ts index 51e7d340..699e37ac 100644 --- a/desktop/src/stores/sessionStore.test.ts +++ b/desktop/src/stores/sessionStore.test.ts @@ -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', diff --git a/desktop/src/stores/sessionStore.ts b/desktop/src/stores/sessionStore.ts index ceeaaf82..2728c57a 100644 --- a/desktop/src/stores/sessionStore.ts +++ b/desktop/src/stores/sessionStore.ts @@ -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((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((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[],