mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
96a5842a01
commit
82be8f45dd
@ -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 />)
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '低',
|
||||
|
||||
@ -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': '낮음',
|
||||
|
||||
@ -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': '低',
|
||||
|
||||
@ -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': '低',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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[],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user