Skip to content

Commit 8809aaa

Browse files
committed
feat(web): 权限模式下拉全面优化
- 下拉项双行布局:名称(tone 色) + 描述,optionRender 渲染 - 每模式配 icon(auto Bulb / RequestApproval QuestionCircle / acceptEdits Edit / plan Compass / dontAsk Stop / yolo Rocket),下拉项、选中态 prefix、StatusBar 三处统一展示 - 配色随主题自适应:light 深/dark 亮双档,按 colorBgBase 亮度切档 - 修复 antd Select 虚拟滚动致 auto/dontAsk 选不中(virtual=false) - renderPermissionModeOption 兼容 FlattenOptionData.data 取 value(修复下拉项全回落 default) - 新建会话默认权限模式改为 auto - 描述文案对齐 Claude 官方文档重写(中英) - 隐藏 dontAsk(仅 UI 过滤,SDK/协议/已存在会话功能保留) - popupClassName → classNames.popup.root(消除 antd v6 deprecated warning)
1 parent 4b7f8c1 commit 8809aaa

10 files changed

Lines changed: 281 additions & 60 deletions

File tree

packages/web/src/components/composer/ChatComposer.tsx

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import { useState, useCallback, useMemo, useRef, useEffect } from 'react'
1818
import { Button, Tooltip, Select, theme, Typography, Popover, message } from 'antd'
19-
import { PlusOutlined, SwapOutlined, SafetyOutlined, RightOutlined, InboxOutlined } from '@ant-design/icons'
19+
import { PlusOutlined, SwapOutlined, RightOutlined, InboxOutlined } from '@ant-design/icons'
2020
import { Sender } from '@ant-design/x'
2121
import { useTranslation } from 'react-i18next'
2222
import styled from '@emotion/styled'
@@ -42,6 +42,8 @@ import { CommandHintBar } from './CommandHintBar'
4242
import { ResponsiveActionBar, type ActionItem } from './ResponsiveActionBar'
4343
import { ActivateCover } from '@/components/ui/ActivateCover'
4444
import { getPermissionModeColor } from './permissionModeColors'
45+
import { buildPermissionModeSelectOptions, renderPermissionModeOption, usePermissionModeDropdownStyle, PERMISSION_MODE_DROPDOWN_CLASS } from './permissionModeOption'
46+
import { getPermissionModeIcon } from './permissionModeIcons'
4547
import { useHasFinePointer } from '@/core/data/hooks/useMediaQuery'
4648

4749

@@ -354,19 +356,10 @@ export function ChatComposer(props: ChatComposerProps) {
354356
if (v !== model) onModelChange?.(v)
355357
}, [model, onModelChange])
356358

359+
usePermissionModeDropdownStyle()
357360
const permissionSelectOptions = useMemo(
358-
() => permissionModeOptions.map(opt => {
359-
const color = opt.tone !== 'neutral'
360-
? getPermissionModeColor(token, opt.tone)
361-
: undefined
362-
return {
363-
value: opt.mode,
364-
label: color
365-
? <span style={{ color }}>{t(`composer.permissionModes.${opt.mode}`)}</span>
366-
: t(`composer.permissionModes.${opt.mode}`),
367-
}
368-
}),
369-
[permissionModeOptions, t, token]
361+
() => buildPermissionModeSelectOptions(t),
362+
[t]
370363
)
371364

372365
// 点击外部关闭下拉
@@ -552,8 +545,9 @@ export function ChatComposer(props: ChatComposerProps) {
552545
const showLocalModeCover = active && mode === 'local'
553546
const isBashMode = text.startsWith('! ')
554547

555-
const permissionModeTone = permissionMode !== 'default' ? getPermissionModeTone(permissionMode) : null
548+
const permissionModeTone = getPermissionModeTone(permissionMode ?? 'default')
556549
const permissionModeColor = getPermissionModeColor(token, permissionModeTone) ?? undefined
550+
const PermissionModeIcon = getPermissionModeIcon(permissionMode ?? 'default')
557551

558552
// Sender header 区域内容(可组合,多条可共存)
559553
const headerNodes = [
@@ -654,11 +648,15 @@ export function ChatComposer(props: ChatComposerProps) {
654648
render: () => (
655649
<CompactHoverSelect
656650
$token={token}
657-
prefix={<SafetyOutlined style={{ fontSize: 12, opacity: 0.55, color: permissionModeColor }} />}
651+
prefix={<PermissionModeIcon style={{ fontSize: 12, opacity: 0.55, color: permissionModeColor }} />}
658652
value={permissionMode ?? 'default'}
659653
onChange={v => onPermissionModeChange?.(v as PermissionMode)}
660654
disabled={controlsDisabled || showLocalModeCover}
661655
options={permissionSelectOptions}
656+
optionRender={(option) => renderPermissionModeOption(option, t, token)}
657+
virtual={false}
658+
listHeight={320}
659+
classNames={{ popup: { root: PERMISSION_MODE_DROPDOWN_CLASS } }}
662660
style={{ color: permissionModeColor }}
663661
/>
664662
),

packages/web/src/components/composer/StatusBar.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
} from '@mobi/shared'
2525
import { getContextBudgetTokens } from '@/domain/chat'
2626
import { getPermissionModeColor } from './permissionModeColors'
27+
import { getPermissionModeIcon } from './permissionModeIcons'
2728

2829
interface StatusBarProps {
2930
/** 是否正在运行 */
@@ -95,16 +96,16 @@ export function StatusBar(props: StatusBarProps) {
9596
}
9697
}, [contextSize, model, agentFlavor, t, token])
9798

98-
// 显示的权限模式
99+
// 显示的权限模式(default 也展示,让用户始终看到当前审批策略)
99100
const displayPermissionMode = permissionMode
100-
&& permissionMode !== 'default'
101101
&& isPermissionModeAllowedForFlavor(permissionMode, agentFlavor)
102102
? permissionMode
103103
: null
104104

105105
const permissionModeLabel = displayPermissionMode ? t(`composer.permissionModes.${displayPermissionMode}`) : null
106106
const permissionModeTone = displayPermissionMode ? getPermissionModeTone(displayPermissionMode) : null
107107
const permissionModeColor = getPermissionModeColor(token, permissionModeTone) ?? token.colorTextSecondary
108+
const PermissionModeIcon = displayPermissionMode ? getPermissionModeIcon(displayPermissionMode) : null
108109

109110
return (
110111
<div style={{
@@ -148,7 +149,8 @@ export function StatusBar(props: StatusBarProps) {
148149
{t('composer.abort')}
149150
</Button>
150151
) : displayPermissionMode ? (
151-
<span style={{ fontSize: 12, color: permissionModeColor }}>
152+
<span style={{ fontSize: 12, color: permissionModeColor, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
153+
{PermissionModeIcon && <PermissionModeIcon style={{ fontSize: 12 }} />}
152154
{permissionModeLabel}
153155
</span>
154156
) : null}

packages/web/src/components/composer/permissionModeColors.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,37 @@
1616

1717
import type { GlobalToken } from 'antd/es/theme/interface'
1818

19-
// permission mode tone 到 antd token 颜色的映射
20-
const PERMISSION_TONE_COLORS: Record<string, keyof GlobalToken> = {
19+
// neutral 用 antd token(随主题),其余用固定品牌色(对齐 Claude CLI)
20+
const PERMISSION_TONE_TOKEN: Record<string, keyof GlobalToken> = {
2121
neutral: 'colorTextSecondary',
22-
info: 'colorInfo',
23-
warning: 'colorWarning',
24-
danger: 'colorError',
25-
success: 'colorSuccess',
2622
}
2723

28-
/** 根据 tone 获取对应的 antd token 颜色值 */
24+
// light/dark 双档(antd 色板 7/5 号):light 用深档保证对比度,dark 用亮档避免深色沉入背景
25+
const PERMISSION_TONE_FIXED: Record<string, { light: string; dark: string }> = {
26+
gold: { light: '#d48806', dark: '#ffc53d' }, // auto
27+
purple: { light: '#722ed1', dark: '#9254de' }, // acceptEdits
28+
green: { light: '#389e0d', dark: '#52c41a' }, // plan
29+
danger: { light: '#cf1322', dark: '#ff4d4f' }, // dontAsk / yolo
30+
}
31+
32+
/** 根据 colorBgBase 亮度判断当前是否 dark 主题 */
33+
function isDarkTheme(token: GlobalToken): boolean {
34+
const bg = token.colorBgBase
35+
if (typeof bg !== 'string' || !bg.startsWith('#')) return false
36+
const hex = bg.slice(1)
37+
if (hex.length !== 6) return false
38+
const r = parseInt(hex.slice(0, 2), 16) / 255
39+
const g = parseInt(hex.slice(2, 4), 16) / 255
40+
const b = parseInt(hex.slice(4, 6), 16) / 255
41+
return (0.299 * r + 0.587 * g + 0.114 * b) < 0.5
42+
}
43+
44+
/** 根据 tone 获取对应颜色值(neutral 走主题 token,其余按主题选档) */
2945
export function getPermissionModeColor(token: GlobalToken, tone: string | null | undefined): string | undefined {
3046
if (!tone) return undefined
31-
const key = PERMISSION_TONE_COLORS[tone]
47+
const fixed = PERMISSION_TONE_FIXED[tone]
48+
if (fixed) return isDarkTheme(token) ? fixed.dark : fixed.light
49+
const key = PERMISSION_TONE_TOKEN[tone]
3250
if (!key) return undefined
3351
const value = token[key]
3452
return typeof value === 'string' ? value : undefined
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Copyright Maner·Fan
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/**
18+
* 权限模式 → icon 组件映射
19+
*
20+
* 语义清晰、互相区分度高:
21+
* - auto: Bulb(智能判断/分类器)
22+
* - default(Manual): QuestionCircle(每次询问)
23+
* - acceptEdits: Edit(文件编辑)
24+
* - plan: Compass(规划方向,只读探查)
25+
* - dontAsk: Stop(拒绝未批准)
26+
* - bypassPermissions(YOLO): Rocket(狂飙全放行)
27+
*/
28+
29+
import type { ComponentType, CSSProperties } from 'react'
30+
import {
31+
BulbOutlined,
32+
QuestionCircleOutlined,
33+
EditOutlined,
34+
CompassOutlined,
35+
StopOutlined,
36+
RocketOutlined,
37+
} from '@ant-design/icons'
38+
import type { PermissionMode } from '@mobi/shared'
39+
40+
type IconComponent = ComponentType<{ style?: CSSProperties; fontSize?: number | string }>
41+
42+
const PERMISSION_MODE_ICONS: Record<PermissionMode, IconComponent> = {
43+
auto: BulbOutlined,
44+
default: QuestionCircleOutlined,
45+
acceptEdits: EditOutlined,
46+
plan: CompassOutlined,
47+
dontAsk: StopOutlined,
48+
bypassPermissions: RocketOutlined,
49+
}
50+
51+
/** 获取权限模式对应的 icon 组件 */
52+
export function getPermissionModeIcon(mode: PermissionMode): IconComponent {
53+
return PERMISSION_MODE_ICONS[mode]
54+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/*
2+
* Copyright Maner·Fan
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/**
18+
* 权限模式下拉框共享渲染
19+
*
20+
* 三处复用:NewSessionForm(新建表单)、ChatComposer(桌面端运行时切换)、
21+
* NewSessionPage(移动端运行时切换)。
22+
*
23+
* 渲染策略:
24+
* - 选中态(label):仅名称,紧凑显示
25+
* - 下拉项(optionRender):名称(tone 色)+ 描述(secondary 小字),描述自动换行
26+
* 描述直接展示比悬停 tooltip 更直观;popupMatchSelectWidth=false 让宽度自适应
27+
*/
28+
29+
import type { ReactNode } from 'react'
30+
import type { GlobalToken } from 'antd/es/theme/interface'
31+
import type { PermissionMode, PermissionModeTone } from '@mobi/shared'
32+
import { getPermissionModeOptionsForFlavor, getPermissionModeTone } from '@mobi/shared'
33+
import { getPermissionModeColor } from './permissionModeColors'
34+
import { getPermissionModeIcon } from './permissionModeIcons'
35+
36+
export interface PermissionModeSelectOption {
37+
value: string
38+
/** 仅名称,用于选中态紧凑显示 */
39+
label: string
40+
tone: PermissionModeTone
41+
}
42+
43+
/**
44+
* 权限模式下拉 popup 的 className(用于隐藏滚动条等样式作用域)
45+
*/
46+
export const PERMISSION_MODE_DROPDOWN_CLASS = 'permission-mode-dropdown'
47+
48+
let permStyleInjected = false
49+
/**
50+
* 注入下拉样式:隐藏滚动条
51+
*
52+
* 双行 option(名称+描述)在 6 项内可完整展示,无需滚动条。
53+
* 兜底覆盖 rc-virtual-list 与 webkit/firefox 两种滚动条。
54+
*/
55+
export function usePermissionModeDropdownStyle(): void {
56+
if (!permStyleInjected && typeof document !== 'undefined') {
57+
const style = document.createElement('style')
58+
style.textContent = `
59+
.${PERMISSION_MODE_DROPDOWN_CLASS} .rc-virtual-list-scrollbar { display: none !important; }
60+
.${PERMISSION_MODE_DROPDOWN_CLASS} .rc-virtual-list-holder { overflow: hidden !important; scrollbar-width: none !important; }
61+
.${PERMISSION_MODE_DROPDOWN_CLASS} .rc-virtual-list-holder::-webkit-scrollbar { display: none !important; }
62+
`
63+
document.head.appendChild(style)
64+
permStyleInjected = true
65+
}
66+
}
67+
68+
/**
69+
* 构建权限模式下拉选项(label 仅名称,附带 tone 供 optionRender 取用)
70+
*
71+
* 注意:dontAsk 在 UI 隐藏(仅过滤选项展示),功能完全保留——
72+
* PermissionMode 枚举 / SDK 协议 / hub / cli / StatusBar 均不变;
73+
* 已存在的 dontAsk 会话 resume 后仍按 dontAsk 模式正常运行。
74+
*/
75+
export function buildPermissionModeSelectOptions(t: (key: string) => string): PermissionModeSelectOption[] {
76+
return getPermissionModeOptionsForFlavor('claude')
77+
.filter(opt => opt.mode !== 'dontAsk')
78+
.map(opt => ({
79+
value: opt.mode,
80+
label: t(`composer.permissionModes.${opt.mode}`),
81+
tone: opt.tone,
82+
}))
83+
}
84+
85+
/**
86+
* 渲染下拉项:名称(tone 色)+ 描述(secondary 12px)
87+
*
88+
* 用于 antd Select 的 optionRender。入参放宽为 unknown —— antd v6 的
89+
* optionRender 传入的是 FlattenOptionData 包装类型,但运行时即原始 option 对象,
90+
* 内部按需取 value/label/tone。neutral 色调回落到正文色(不染灰,避免视觉弱化)。
91+
*/
92+
export function renderPermissionModeOption(
93+
option: unknown,
94+
t: (key: string) => string,
95+
token: GlobalToken
96+
): ReactNode {
97+
// antd optionRender 的 option 是 FlattenOptionData,原始数据在 option.data;
98+
// 兼容直接 option 与 .data 包装两种结构,确保 value/label 可靠取到
99+
const raw = (option ?? {}) as {
100+
value?: string; label?: string;
101+
data?: { value?: string; label?: string }
102+
}
103+
const source = raw.data ?? raw
104+
const mode = (source.value || raw.value || 'default') as PermissionMode
105+
const tone = getPermissionModeTone(mode)
106+
const label = source.label ?? raw.label ?? t(`composer.permissionModes.${mode}`)
107+
const color = getPermissionModeColor(token, tone)
108+
const Icon = getPermissionModeIcon(mode)
109+
return (
110+
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, padding: '4px 0' }}>
111+
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: color ?? token.colorText, fontWeight: 500 }}>
112+
<Icon style={{ fontSize: 13 }} />
113+
{label}
114+
</span>
115+
<span style={{ fontSize: 12, lineHeight: 1.35, color: token.colorTextSecondary, whiteSpace: 'normal' }}>
116+
{t(`composer.permissionModeDescriptions.${mode}`)}
117+
</span>
118+
</div>
119+
)
120+
}

packages/web/src/components/session/NewSessionForm.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
Select,
2727
Spin,
2828
Tag,
29+
theme,
2930
Tooltip,
3031
Typography,
3132
} from 'antd'
@@ -40,7 +41,10 @@ import { useMachineDirectoryListing, parsePrefixInput } from './useMachineDirect
4041
import { useRecentPaths } from './useRecentPaths'
4142
import type { AgentType, SessionType } from '@/domain/session/types'
4243
import { CLAUDE_MODEL_FALLBACK } from '@/domain/session/types'
43-
import { getEffortOptions, getPermissionModeOptionsForFlavor, type EffortLevel, type PermissionMode } from '@mobi/shared'
44+
import { getEffortOptions, getPermissionModeTone, type EffortLevel, type PermissionMode } from '@mobi/shared'
45+
import { buildPermissionModeSelectOptions, renderPermissionModeOption, usePermissionModeDropdownStyle, PERMISSION_MODE_DROPDOWN_CLASS } from '@/components/composer/permissionModeOption'
46+
import { getPermissionModeColor } from '@/components/composer/permissionModeColors'
47+
import { getPermissionModeIcon } from '@/components/composer/permissionModeIcons'
4448
import {
4549
loadPreferredAgent,
4650
loadPreferredEffort,
@@ -80,6 +84,8 @@ function startEllipsis(path: string, maxLen = 40): string {
8084
*/
8185
export function NewSession(props: NewSessionProps) {
8286
const { t } = useTranslation()
87+
const { token } = theme.useToken()
88+
usePermissionModeDropdownStyle()
8389
useSessions()
8490

8591
const { machines: fetchedMachines, isLoading: machinesLoading } = useMachines()
@@ -97,6 +103,9 @@ export function NewSession(props: NewSessionProps) {
97103
const [agent, setAgent] = useState<AgentType>(loadPreferredAgent)
98104
const [model, setModel] = useState(loadPreferredModel)
99105
const [permissionMode, setPermissionMode] = useState<PermissionMode>(loadPreferredPermissionMode)
106+
const permissionModeTone = getPermissionModeTone(permissionMode)
107+
const permissionModeColor = getPermissionModeColor(token, permissionModeTone)
108+
const PermissionModeIcon = getPermissionModeIcon(permissionMode)
100109
const [effort, setEffort] = useState<EffortLevel>(loadPreferredEffort)
101110
const [sessionType, setSessionType] = useState<SessionType>('simple')
102111
const [worktreeName, setWorktreeName] = useState('')
@@ -394,10 +403,13 @@ export function NewSession(props: NewSessionProps) {
394403
<Select
395404
value={permissionMode}
396405
onChange={(v) => setPermissionMode(v)}
397-
options={getPermissionModeOptionsForFlavor(agent).map(opt => ({
398-
value: opt.mode,
399-
label: t(`composer.permissionModes.${opt.mode}`),
400-
}))}
406+
options={buildPermissionModeSelectOptions(t)}
407+
optionRender={(option) => renderPermissionModeOption(option, t, token)}
408+
prefix={<PermissionModeIcon style={{ color: permissionModeColor }} />}
409+
labelRender={(props) => <span style={{ color: permissionModeColor }}>{props.label}</span>}
410+
virtual={false}
411+
listHeight={320}
412+
classNames={{ popup: { root: PERMISSION_MODE_DROPDOWN_CLASS } }}
401413
style={{ width: '100%' }}
402414
disabled={isFormDisabled}
403415
/>

0 commit comments

Comments
 (0)