From 6d4af6733ad9b05e6d2606ca218e080ec9075fa0 Mon Sep 17 00:00:00 2001 From: kirozeng Date: Tue, 8 Sep 2026 18:16:06 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(design-system):=20DS-6=20=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E8=A1=A8=E5=8D=95=E4=B8=8E=E7=A1=AE=E8=AE=A4=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E5=A4=8D=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: kirozeng --- .../settings/CustomProviderDialog.tsx | 824 +++++++++++------- .../components/settings/McpServerDialog.tsx | 526 +++++------ .../components/settings/McpServersSection.tsx | 1 + .../components/settings/ProvidersSection.tsx | 1 + ...CustomProviderDialogAccessibility.test.tsx | 53 ++ .../__tests__/McpServerDialog.test.tsx | 141 +++ .../ui/__tests__/formControls.test.tsx | 91 ++ .../ui/__tests__/formField.test.tsx | 53 ++ .../components/ui/__tests__/input.test.tsx | 4 +- .../src/renderer/components/ui/button.tsx | 39 +- .../components/ui/confirm-dialog-provider.tsx | 2 + .../renderer/components/ui/confirm-dialog.tsx | 192 ++-- .../src/renderer/components/ui/form-field.tsx | 86 ++ .../src/renderer/components/ui/input.tsx | 61 +- docs/design-evidence/2026-09-08/ds6-forms.md | 173 ++++ docs/design-rules/DESIGN.md | 18 +- docs/design-rules/cindy-design-system.md | 3 + docs/design-rules/design-decision-log.md | 2 + docs/design-rules/design-governance.md | 9 +- docs/design-rules/design-inventory.md | 6 +- 20 files changed, 1620 insertions(+), 665 deletions(-) create mode 100644 apps/desktop/src/renderer/components/settings/__tests__/McpServerDialog.test.tsx create mode 100644 apps/desktop/src/renderer/components/ui/__tests__/formControls.test.tsx create mode 100644 apps/desktop/src/renderer/components/ui/__tests__/formField.test.tsx create mode 100644 apps/desktop/src/renderer/components/ui/form-field.tsx create mode 100644 docs/design-evidence/2026-09-08/ds6-forms.md diff --git a/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx b/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx index 3de9e3c6ba8..1418a9ae005 100644 --- a/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx +++ b/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx @@ -15,7 +15,15 @@ */ import * as Dialog from '@radix-ui/react-dialog'; -import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react'; +import { + useCallback, + useEffect, + useLayoutEffect, + useId, + useRef, + useState, + type RefObject, +} from 'react'; import { useTranslation } from 'react-i18next'; import { Check, @@ -32,6 +40,9 @@ import { import { cn } from '@/lib/utils'; import { toast } from '@/lib/toast'; import { Spinner } from '@/components/ui/spinner'; +import { Button } from '@/components/ui/button'; +import { FormField } from '@/components/ui/form-field'; +import { Tip } from '@/components/ui/tooltip'; import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { DropdownMenu, @@ -309,7 +320,10 @@ function isCommittableWindowText(text: string): boolean { } /** Compact context-window label shown inside the existing token-count input. */ -function compactContextWindowLabel(draft: string | undefined, contextWindow?: number): string | null { +function compactContextWindowLabel( + draft: string | undefined, + contextWindow?: number, +): string | null { if (draft !== undefined) { if (!isCommittableWindowText(draft) || !draft.trim()) return null; const value = Number(BigInt(draft.trim().replace(/[,_ ]/g, ''))); @@ -547,6 +561,34 @@ export function CustomProviderDialog({ ); })(); + const formId = useId(); + const fieldId = (key: string) => `${formId}-${key}`; + const [fieldError, setFieldError] = useState<{ id: string; message: string } | null>(null); + const errorFor = (key: string) => + fieldError?.id === fieldId(key) ? fieldError.message : undefined; + // UI identity is held outside business drafts and never enters saved configuration. + const rowIds = useRef(new WeakMap()); + const nextRowId = useRef(0); + const rowId = useCallback((row: object) => { + let key = rowIds.current.get(row); + if (key === undefined) { + key = nextRowId.current++; + rowIds.current.set(row, key); + } + return key; + }, []); + const reportFieldError = useCallback( + (key: string, message: string) => { + setFieldError({ id: `${formId}-${key}`, message }); + }, + [formId], + ); + useLayoutEffect(() => { + if (!fieldError) return; + const input = document.getElementById(fieldError.id); + input?.focus(); + input?.scrollIntoView?.({ block: 'nearest' }); + }, [fieldError]); const [name, setName] = useState(initial?.name ?? ''); const [rt, setRt] = useState>(() => initRuntimes(initial)); const [activeTab, setActiveTab] = useState( @@ -850,11 +892,21 @@ export function CustomProviderDialog({ // A temporary route/model change can be reverted before Save; marker // ownership is decided once below from the persisted baseline and the // final serialized values. + for (const agent of AGENTS) { + for (const kind of ['models', 'headers'] as const) { + const before = prev[agent][kind]; + const after = normalized[agent][kind]; + if (before.length !== after.length) continue; + after.forEach((row, index) => { + if (!rowIds.current.has(row)) rowIds.current.set(row, rowId(before[index])); + }); + } + } rtRef.current = normalized; return normalized; }); }, - [], + [rowId], ); // 编辑态回填的已存明文 key(按 agent);测试连接据此判定凭证材料是否被改动。 @@ -1670,10 +1722,11 @@ export function CustomProviderDialog({ }, [picker, patch]); const handleSave = useCallback(async () => { - // 校验失败统一走 toast(规则 7:不在弹窗里塞会撑高/缩回的内联错误条,避免布局抖动闪烁)。 + if (savingRef.current) return; + setFieldError(null); const trimmedName = name.trim(); if (!trimmedName) { - toast.error(t('settings.providers.custom.errors.nameRequired')); + reportFieldError('name', t('settings.providers.custom.errors.nameRequired')); return; } if (editing && authMode === 'apiKey' && !keyHydrationReady) { @@ -1716,7 +1769,10 @@ export function CustomProviderDialog({ // OAuth 鉴权模式下模型列表(含窗口输入)折在「高级」里;不展开的话用户看不到 // 需要修的这个输入框,报错后无从下手,只能瞎猜着点开(review P1)。 if (authMode === 'oauth' && !showAdvanced) setShowAdvanced(true); - toast.error(t('settings.providers.custom.errors.contextWindowInvalid')); + reportFieldError( + `${draftAgent}:model:${rowId(row)}:context`, + t('settings.providers.custom.errors.contextWindowInvalid'), + ); return; } const runtimes: CustomProviderConfig['runtimes'] = {}; @@ -1728,17 +1784,17 @@ export function CustomProviderDialog({ const u = new URL(rf.baseUrl.trim()); if (u.protocol !== 'http:' && u.protocol !== 'https:') { setActiveTab(a); - toast.error(t('settings.providers.custom.errors.baseUrlInvalid')); + reportFieldError(`${a}:baseUrl`, t('settings.providers.custom.errors.baseUrlInvalid')); return; } } catch { setActiveTab(a); - toast.error(t('settings.providers.custom.errors.baseUrlInvalid')); + reportFieldError(`${a}:baseUrl`, t('settings.providers.custom.errors.baseUrlInvalid')); return; } if (!areProviderRequestUrlsAllowed(authMode, rf.baseUrl, rf.modelsUrl)) { setActiveTab(a); - toast.error(t('settings.providers.custom.errors.baseUrlInvalid')); + reportFieldError(`${a}:baseUrl`, t('settings.providers.custom.errors.baseUrlInvalid')); return; } const models = rf.models @@ -1764,13 +1820,21 @@ export function CustomProviderDialog({ const requestPath = a === 'pi' ? '' : rf.requestPath.trim(); if (requestPath && !isProviderRequestPath(requestPath)) { setActiveTab(a); - toast.error(t('settings.providers.custom.errors.requestPathInvalid')); + reportFieldError( + `${a}:requestPath`, + t('settings.providers.custom.errors.requestPathInvalid'), + ); return; } // OAuth 形态模型可留空——授权成功后自动发现并持久化(与内置订阅统一)。 if (models.length === 0 && authMode !== 'oauth') { setActiveTab(a); - toast.error(t('settings.providers.custom.errors.modelRequired')); + reportFieldError( + rf.models.length + ? `${a}:model:${rowId(rf.models[0])}:${rf.models[0].id.trim() ? 'name' : 'id'}` + : `${a}:add-model`, + t('settings.providers.custom.errors.modelRequired'), + ); return; } const headers: Record = {}; @@ -1821,7 +1885,10 @@ export function CustomProviderDialog({ } } if (Object.keys(runtimes).length === 0) { - toast.error(t('settings.providers.custom.errors.runtimeRequired')); + reportFieldError( + `${activeTab}:baseUrl`, + t('settings.providers.custom.errors.runtimeRequired'), + ); return; } // OAuth 形态:四个必填字段 + 端点必须 https(与 main 侧校验同规则,先在表单挡住)。 @@ -1850,7 +1917,17 @@ export function CustomProviderDialog({ !httpsOk(flowUrl) || !httpsOk(tokenUrl) ) { - toast.error(t('settings.providers.custom.errors.oauthInvalid')); + const invalid = + !flowUrl || !httpsOk(flowUrl) + ? oauthFlow === 'device-code' + ? 'deviceAuthorizationUrl' + : 'authorizeUrl' + : !tokenUrl || !httpsOk(tokenUrl) + ? 'tokenUrl' + : !clientId + ? 'clientId' + : 'scopes'; + reportFieldError(`oauth:${invalid}`, t('settings.providers.custom.errors.oauthInvalid')); return; } auth = { @@ -1904,12 +1981,14 @@ export function CustomProviderDialog({ ...(auth ? { auth } : {}), runtimes, }; + savingRef.current = true; setSaving(true); try { if (editing) { const result = await updateCustomProvider(config, keys, { source: 'manual-settings' }); if (result?.ok === false) { setImageGenerationReloadConfirmation({ config, keys, busyCount: result.busyCount }); + savingRef.current = false; setSaving(false); return; } @@ -1918,6 +1997,7 @@ export function CustomProviderDialog({ const result = await createCustomProvider(config, keys, { source: 'manual-settings' }); if (result?.ok === false) { setImageGenerationReloadConfirmation({ config, keys, busyCount: result.busyCount }); + savingRef.current = false; setSaving(false); return; } @@ -1929,10 +2009,15 @@ export function CustomProviderDialog({ } catch (e) { const ipc = extractIpcError(e); toast.error(ipc?.message ?? t('settings.providers.custom.toast.saveFailed')); + savingRef.current = false; setSaving(false); // 仅失败时复位:弹窗仍在,允许改后重试 } }, [ name, + activeTab, + rowId, + reportFieldError, + keyHydrationReady, rt, authMode, oauthFlow, @@ -1953,6 +2038,7 @@ export function CustomProviderDialog({ async (policy: CodexImageGenerationRestartPolicy) => { const pending = imageGenerationReloadConfirmationRef.current; if (!pending || savingRef.current) return; + savingRef.current = true; setSaving(true); try { const options = { @@ -1966,6 +2052,7 @@ export function CustomProviderDialog({ const next = { ...pending, busyCount: result.busyCount }; imageGenerationReloadConfirmationRef.current = next; setImageGenerationReloadConfirmation(next); + savingRef.current = false; setSaving(false); return; } @@ -1980,6 +2067,7 @@ export function CustomProviderDialog({ } catch (error) { const ipc = extractIpcError(error); toast.error(ipc?.message ?? t('settings.providers.custom.toast.saveFailed')); + savingRef.current = false; setSaving(false); } }, @@ -2096,8 +2184,9 @@ export function CustomProviderDialog({ aria-modal="true" aria-labelledby="custom-provider-dialog-title" tabIndex={-1} + onChangeCapture={() => setFieldError(null)} className={cn( - 'flex max-h-[88vh] w-[600px] flex-col rounded-[16px] outline-none', + 'flex max-h-[88vh] w-[min(600px,calc(100vw-32px))] flex-col rounded-xl outline-none', 'border border-[var(--border-default)] bg-[var(--surface-elevated)]', 'shadow-[var(--shadow-menu)]', '[&_button:focus-visible]:outline-none [&_button:focus-visible]:ring-2 [&_button:focus-visible]:ring-[var(--focus-ring)]', @@ -2119,7 +2208,7 @@ export function CustomProviderDialog({ {/* Body (scrollable) */} -
+

{t('settings.providers.custom.dialog.desc')}

@@ -2151,13 +2240,23 @@ export function CustomProviderDialog({ {/* 显示名称(共享) */}
- {t('settings.providers.custom.fields.name')} - + + {(control) => ( + + )} +
{/* 鉴权形态:API 密钥 / OAuth / 无鉴权。 */} @@ -2167,6 +2266,7 @@ export function CustomProviderDialog({ {(['apiKey', 'oauth', 'none'] as const).map((m) => ( -
- patch(activeTab, (x) => ({ ...x, baseUrl: v }))} - placeholder={t('settings.providers.custom.fields.baseUrlPlaceholder')} - /> + + {t('settings.providers.custom.runtimeFill.action')} + + } + reserveFeedback + > + {(control) => ( + patch(activeTab, (x) => ({ ...x, baseUrl: v }))} + placeholder={t('settings.providers.custom.fields.baseUrlPlaceholder')} + /> + )} +
{/* 精确推理路径:给非标准兼容端点使用;留空仍按所选协议推导。 */} {activeTab !== 'pi' && (
- {t('settings.providers.custom.fields.requestPath')} - patch(activeTab, (x) => ({ ...x, requestPath: v }))} - placeholder={ - activeTab === 'claude-code' || f.wireProtocol === 'anthropic-messages' - ? '/v1/messages' - : customProviderCodexWireProtocolOption(f.wireProtocol).defaultRequestPath - } - /> - - {t('settings.providers.custom.fields.requestPathHelp')} - + + {(control) => ( + patch(activeTab, (x) => ({ ...x, requestPath: v }))} + placeholder={ + activeTab === 'claude-code' || f.wireProtocol === 'anthropic-messages' + ? '/v1/messages' + : customProviderCodexWireProtocolOption(f.wireProtocol).defaultRequestPath + } + /> + )} +
)} {/* API 密钥(OAuth 形态隐藏——鉴权走 Runner 的 Bearer,不收集 key) */} {authMode === 'apiKey' && (
-
- {t('settings.providers.custom.fields.apiKey')} - {/* 已存密钥时给明确徽标 —— 编辑态字段是遮罩空白(留空=不改),无徽标会让人误以为没存上。 */} - {activeKeyCanRemainSaved && f.apiKey.trim() && ( - + + {t('settings.providers.custom.fields.apiKeySaved')} + + ) + } + > + {(control) => ( + { + keyEditRevisionRef.current[activeTab] += 1; + patch(activeTab, (x) => ({ ...x, apiKey: v })); }} - > - - {t('settings.providers.custom.fields.apiKeySaved')} - + placeholder={keyPlaceholder} + mono + secret + /> )} -
- { - keyEditRevisionRef.current[activeTab] += 1; - patch(activeTab, (x) => ({ ...x, apiKey: v })); - }} - placeholder={keyPlaceholder} - mono - secret - /> - - {t('settings.providers.custom.fields.apiKeyHelp')} - +
)} @@ -2467,44 +2600,61 @@ export function CustomProviderDialog({
{t('settings.providers.custom.fields.models')} {f.models.map((m, i) => ( -
-
- - patch(activeTab, (x) => ({ - ...x, - models: x.models.map((y, j) => - j === i ? replaceCustomProviderModelId(y, v) : y, - ), - })) - } - placeholder={t('settings.providers.custom.fields.modelIdPlaceholder')} - /> +
+
+ + {(control) => ( + + patch(activeTab, (x) => ({ + ...x, + models: x.models.map((y, j) => + j === i ? replaceCustomProviderModelId(y, v) : y, + ), + })) + } + placeholder={t('settings.providers.custom.fields.modelIdPlaceholder')} + /> + )} +
-
- - patch(activeTab, (x) => ({ - ...x, - models: x.models.map((y, j) => (j === i ? { ...y, name: v } : y)), - })) - } - placeholder={t('settings.providers.custom.fields.modelNamePlaceholder')} - /> +
+ + {(control) => ( + + patch(activeTab, (x) => ({ + ...x, + models: x.models.map((y, j) => (j === i ? { ...y, name: v } : y)), + })) + } + placeholder={t( + 'settings.providers.custom.fields.modelNamePlaceholder', + )} + /> + )} +
+ + {(control) => ( + + setWindowDrafts((drafts) => { + const draftText = drafts[`${activeTab}:${i}`]; + // 只清可提交草稿(显示回落到已提交规范值);不可提交 + // 草稿必须保留——输入框失焦先于保存按钮 click,清掉 + // 会让保存守卫看不到非法文本、静默存旧值(review P1)。 + if ( + draftText === undefined || + !isCommittableWindowText(draftText) + ) { + return drafts; + } + const rest = { ...drafts }; + delete rest[`${activeTab}:${i}`]; + return rest; + }) + } + onChange={(v) => { + setWindowDrafts((drafts) => ({ + ...drafts, + [`${activeTab}:${i}`]: v, + })); + patch(activeTab, (x) => ({ + ...x, + models: x.models.map((y, j) => { + if (j !== i) return y; + const trimmed = v.trim(); + if (trimmed === '') { + const next = { ...y }; + delete next.contextWindow; + return next; + } + // 整体校验(分隔符只允许单个、夹在数字组之间;BigInt 精确 + // 校验上界防 parseInt 先舍入):不合法的中间态/非法值只 + // 留在草稿,不提交、不剥字符拼数字(review P1 ×2)。 + if (!isCommittableWindowText(trimmed)) return y; + return { + ...y, + contextWindow: Number(BigInt(trimmed.replace(/[,_ ]/g, ''))), + }; + }), + })); + }} + placeholder={t( + 'settings.providers.custom.fields.modelContextWindowPlaceholder', + )} + trailing={(() => { + const label = compactContextWindowLabel( + windowDrafts[`${activeTab}:${i}`], + m.contextWindow, + ); + return label ? ( + + ) : null; + })()} + /> + )} + +
+ +
- + className="w-9 px-0" + aria-label={t('settings.providers.custom.fields.removeRow')} + > + + + {activeTab === 'pi' && (
@@ -2747,19 +2930,32 @@ export function CustomProviderDialog({
))} +

+ {errorFor(`${activeTab}:add-model`)} +

{/* 请求头(可选) */} @@ -2781,56 +2977,96 @@ export function CustomProviderDialog({ )}
{f.headers.map((h, i) => ( -
-
- - patch(activeTab, (x) => ({ - ...x, - headers: x.headers.map((y, j) => (j === i ? { ...y, name: v } : y)), - })) - } - placeholder={t('settings.providers.custom.fields.headerNamePlaceholder')} - /> +
+
+ + {(control) => ( + + patch(activeTab, (x) => ({ + ...x, + headers: x.headers.map((y, j) => + j === i ? { ...y, name: v } : y, + ), + })) + } + placeholder={t( + 'settings.providers.custom.fields.headerNamePlaceholder', + )} + /> + )} +
-
- +
+ + {(control) => ( + + patch(activeTab, (x) => ({ + ...x, + headers: x.headers.map((y, j) => + j === i ? { ...y, value: v } : y, + ), + })) + } + placeholder={t( + 'settings.providers.custom.fields.headerValuePlaceholder', + )} + /> + )} + +
+ +
- + headers: x.headers.filter((_, j) => j !== i), + })); + requestAnimationFrame(() => document.getElementById(nextId)?.focus()); + }} + className="w-9 px-0" + aria-label={t('settings.providers.custom.fields.removeRow')} + > + + +
))}
- {/* Footer */} -
- - +
diff --git a/apps/desktop/src/renderer/components/settings/McpServerDialog.tsx b/apps/desktop/src/renderer/components/settings/McpServerDialog.tsx index 19ef0e91bd3..3f75b8cf95f 100644 --- a/apps/desktop/src/renderer/components/settings/McpServerDialog.tsx +++ b/apps/desktop/src/renderer/components/settings/McpServerDialog.tsx @@ -6,15 +6,20 @@ * * 「MCP id」内部句柄由显示名 slug 派生 + 去重,对用户隐藏(= agent 侧 mcpServers[name], * 不能含 . 或 /)。配置经 maker IPC 入 localDb;token 经 safeStorage 存(见 lib/customMcpServers)。 - * 编辑态回填已存 token、留空 = 不改;id 不可改。颜色全走主题 token。 + * 编辑态回填后清空 token 会清除,回填前空值保留;id 不可改。颜色全走主题 token。 * * 说明:transport 仅远程 http/sse。token 在 Claude 端合成 Authorization: Bearer; * Codex 端只支持 Bearer 型鉴权,用户自定义的非 Bearer header 仅 Claude 生效。 */ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useId, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Check, Eye, EyeOff, Loader2, Plus, Sparkles, Trash2, X } from 'lucide-react'; +import { Check, Plus, Sparkles, Trash2 } from 'lucide-react'; +import * as Dialog from '@radix-ui/react-dialog'; +import { Button } from '@/components/ui/button'; +import { FormField } from '@/components/ui/form-field'; +import { Tip } from '@/components/ui/tooltip'; +import { SettingsTextInput } from './SettingsTextInput'; import { cn } from '@/lib/utils'; import { toast } from '@/lib/toast'; @@ -66,45 +71,6 @@ function uniqueId(name: string, existing: ReadonlySet): string { return `${base}-${i}`; } -function FieldLabel({ children }: { children: React.ReactNode }) { - return ( - {children} - ); -} - -function TextInput({ - value, - onChange, - placeholder, - type = 'text', - trailing, -}: { - value: string; - onChange: (v: string) => void; - placeholder?: string; - type?: string; - trailing?: React.ReactNode; -}) { - return ( -
- onChange(e.target.value)} - placeholder={placeholder} - className={cn( - 'h-[40px] w-full rounded-[10px] pl-[12px] text-14 outline-none transition-colors', - trailing ? 'pr-9' : 'pr-[12px]', - 'text-[var(--settings-input-text)] placeholder:text-[var(--settings-input-placeholder)]', - 'border border-[var(--settings-input-border)] bg-[var(--settings-input-bg)] focus:border-[var(--settings-input-border-focus)]', - )} - style={{ userSelect: 'text', WebkitUserSelect: 'text' }} - /> - {trailing} -
- ); -} - export function McpServerDialog({ initial, existingIds, onSaved, onClose }: McpServerDialogProps) { const { t } = useTranslation(); const editing = !!initial; @@ -113,7 +79,6 @@ export function McpServerDialog({ initial, existingIds, onSaved, onClose }: McpS const [transport, setTransport] = useState(initial?.transport ?? 'http'); const [url, setUrl] = useState(initial?.url ?? ''); const [token, setToken] = useState(''); - const [showToken, setShowToken] = useState(false); const [hasToken, setHasToken] = useState(false); const headerKeyRef = useRef(0); const [headers, setHeaders] = useState(() => { @@ -124,6 +89,17 @@ export function McpServerDialog({ initial, existingIds, onSaved, onClose }: McpS return initRows.map((r) => ({ ...r, _key: headerKeyRef.current++ })); }); const [saving, setSaving] = useState(false); + const savingRef = useRef(false); + const id = useId(); + const nameRef = useRef(null); + const addHeaderRef = useRef(null); + const returnFocusRef = useRef( + document.activeElement instanceof HTMLElement ? document.activeElement : null, + ); + const [errors, setErrors] = useState<{ name?: string; url?: string }>({}); + const close = () => { + if (!savingRef.current) onClose(); + }; // 编辑态:回填已存 token(让 token 框「能看」/可核对,据此点亮「已保存」徽标)。 useEffect(() => { @@ -143,20 +119,23 @@ export function McpServerDialog({ initial, existingIds, onSaved, onClose }: McpS }, [editing, initial]); const handleSave = useCallback(async () => { + if (savingRef.current) return; const trimmedName = name.trim(); - if (!trimmedName) { - toast.error(t('settings.mcp.errors.nameRequired')); - return; - } const trimmedUrl = url.trim(); + const nextErrors: { name?: string; url?: string } = {}; + if (!trimmedName) nextErrors.name = t('settings.mcp.errors.nameRequired'); try { const u = new URL(trimmedUrl); - if (u.protocol !== 'http:' && u.protocol !== 'https:') { - toast.error(t('settings.mcp.errors.urlInvalid')); - return; - } + if (u.protocol !== 'http:' && u.protocol !== 'https:') + nextErrors.url = t('settings.mcp.errors.urlInvalid'); } catch { - toast.error(t('settings.mcp.errors.urlInvalid')); + nextErrors.url = t('settings.mcp.errors.urlInvalid'); + } + setErrors(nextErrors); + if (nextErrors.name || nextErrors.url) { + const invalid = document.getElementById(`${id}-${nextErrors.name ? 'name' : 'url'}`); + invalid?.focus(); + invalid?.scrollIntoView?.({ block: 'nearest' }); return; } const headerMap: Record = {}; @@ -164,14 +143,16 @@ export function McpServerDialog({ initial, existingIds, onSaved, onClose }: McpS const n = h.name.trim(); if (n) headerMap[n] = h.value.trim(); } - const id = editing && initial ? initial.id : uniqueId(trimmedName, new Set(existingIds ?? [])); + const configId = + editing && initial ? initial.id : uniqueId(trimmedName, new Set(existingIds ?? [])); const config: CustomMcpConfig = { - id, + id: configId, name: trimmedName, transport, url: trimmedUrl, headers: headerMap, }; + savingRef.current = true; setSaving(true); try { if (editing) { @@ -188,226 +169,263 @@ export function McpServerDialog({ initial, existingIds, onSaved, onClose }: McpS } catch (e) { const ipc = extractIpcError(e); toast.error(ipc?.message ?? t('settings.mcp.toast.saveFailed')); + savingRef.current = false; setSaving(false); } - }, [name, url, transport, token, hasToken, headers, editing, initial, existingIds, onSaved, t]); + }, [ + id, + name, + url, + transport, + token, + hasToken, + headers, + editing, + initial, + existingIds, + onSaved, + t, + ]); const tokenPlaceholder = hasToken ? t('settings.mcp.fields.tokenEditPlaceholder') : t('settings.mcp.fields.tokenPlaceholder'); return ( -
-
- {/* Header bar */} -
-
- -

+ { + if (!open) close(); + }} + > + + + { + event.preventDefault(); + nameRef.current?.focus(); + }} + onCloseAutoFocus={(event) => { + event.preventDefault(); + if (returnFocusRef.current?.isConnected) returnFocusRef.current.focus(); + }} + onEscapeKeyDown={(event) => { + if (savingRef.current || event.isComposing || event.keyCode === 229) + event.preventDefault(); + }} + onPointerDownOutside={(event) => { + if (savingRef.current) event.preventDefault(); + }} + > +
+ + {editing ? t('settings.mcp.dialog.editTitle') : t('settings.mcp.dialog.createTitle')} -

-
- -
- - {/* Body */} -
-

- {t('settings.mcp.dialog.desc')} -

- - {/* 显示名称 */} -
- {t('settings.mcp.fields.name')} - +
- - {/* transport 分段 */} -
- {t('settings.mcp.fields.transport')} -
+ + {t('settings.mcp.dialog.desc')} + + - {MCP_TRANSPORTS.map((tp) => { - const active = transport === tp; - return ( - - ); - })} -
-
- -
- {/* 端点 URL */} -
- {t('settings.mcp.fields.url')} - -
- - {/* bearer token(可选) */} -
-
- {t('settings.mcp.fields.token')} - {hasToken && ( - + ))} +
+ +
+ + {(control) => ( + { + setUrl(value); + setErrors((prev) => ({ ...prev, url: undefined })); }} - > - - {t('settings.mcp.fields.tokenSaved')} - + placeholder={t('settings.mcp.fields.urlPlaceholder')} + /> )} -
- setShowToken((v) => !v)} - className="absolute right-[12px] top-1/2 -translate-y-1/2 text-[var(--settings-eye-icon)] transition-colors hover:text-[var(--settings-eye-icon-hover)]" - aria-label={showToken ? t('settings.apiKey.hideKey') : t('settings.apiKey.showKey')} - > - {showToken ? : } - + + + + {t('settings.mcp.fields.tokenSaved')} + + ) } - /> - - {t('settings.mcp.fields.tokenHelp')} - -
- - {/* 请求头(可选) */} -
- {t('settings.mcp.fields.headers')} - {headers.map((h, i) => ( -
-
- - setHeaders((prev) => prev.map((y, j) => (j === i ? { ...y, name: v } : y))) - } - placeholder={t('settings.mcp.fields.headerNamePlaceholder')} - /> -
-
- - setHeaders((prev) => prev.map((y, j) => (j === i ? { ...y, value: v } : y))) - } - placeholder={t('settings.mcp.fields.headerValuePlaceholder')} - /> -
- -
- ))} - + {(control) => ( + + )} + +
+ + {t('settings.mcp.fields.headers')} + + {headers.map((h, i) => ( +
+ + {(control) => ( + + setHeaders((prev) => + prev.map((row) => + row._key === h._key ? { ...row, name: value } : row, + ), + ) + } + placeholder={t('settings.mcp.fields.headerNamePlaceholder')} + /> + )} + + + {(control) => ( + + setHeaders((prev) => + prev.map((row) => (row._key === h._key ? { ...row, value } : row)), + ) + } + placeholder={t('settings.mcp.fields.headerValuePlaceholder')} + /> + )} + + + + +
+ ))} + +
-
- - {/* Footer */} -
- - -
-
-
+
+ + +
+ + + ); } diff --git a/apps/desktop/src/renderer/components/settings/McpServersSection.tsx b/apps/desktop/src/renderer/components/settings/McpServersSection.tsx index 3e94ae47851..471785d94f3 100644 --- a/apps/desktop/src/renderer/components/settings/McpServersSection.tsx +++ b/apps/desktop/src/renderer/components/settings/McpServersSection.tsx @@ -155,6 +155,7 @@ export function McpServersSection() { const handleDelete = useCallback( async (config: CustomMcpConfig) => { const ok = await confirm({ + presentation: 'standard', title: t('settings.mcp.deleteConfirm.title', { name: config.name }), description: t('settings.mcp.deleteConfirm.message'), confirmText: t('settings.mcp.deleteConfirm.confirm'), diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx index 1aeb2301d5d..b471bb701cd 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx @@ -2446,6 +2446,7 @@ export function ProvidersSection() { const handleDelete = useCallback( async (p: ProviderView) => { const ok = await confirm({ + presentation: 'standard', title: t('settings.providers.custom.deleteConfirm.title'), description: t('settings.providers.custom.deleteConfirm.description', { name: p.name }), confirmText: t('settings.providers.custom.deleteConfirm.confirm'), diff --git a/apps/desktop/src/renderer/components/settings/__tests__/CustomProviderDialogAccessibility.test.tsx b/apps/desktop/src/renderer/components/settings/__tests__/CustomProviderDialogAccessibility.test.tsx index ebb79cb9b8b..0a64758c3c3 100644 --- a/apps/desktop/src/renderer/components/settings/__tests__/CustomProviderDialogAccessibility.test.tsx +++ b/apps/desktop/src/renderer/components/settings/__tests__/CustomProviderDialogAccessibility.test.tsx @@ -252,6 +252,9 @@ describe('CustomProviderDialog accessibility', () => { name: 'settings.providers.custom.fields.modelContextWindowTitle', }); await waitFor(() => expect(document.activeElement).toBe(contextWindow)); + expect(contextWindow).toBe( + screen.getByPlaceholderText('settings.providers.custom.fields.modelContextWindowPlaceholder'), + ); }); it('cancels a pending manual create without discarding the Provider draft', async () => { @@ -1377,3 +1380,53 @@ describe('CustomProviderDialog accessibility', () => { expect(capability.checked).toBe(true); }); }); + +describe('DS-6 field errors and save ownership', () => { + it('reports name validation on the field and focuses it', async () => { + render(); + await waitForInitialDialogFocus(); + fireEvent.click(screen.getByRole('button', { name: 'settings.providers.custom.save' })); + const name = screen.getByLabelText('settings.providers.custom.fields.name'); + expect(document.activeElement).toBe(name); + expect(name.getAttribute('aria-invalid')).toBe('true'); + expect(document.getElementById(name.getAttribute('aria-describedby')!)?.textContent).toBe( + 'settings.providers.custom.errors.nameRequired', + ); + expect(customProviderMocks.createCustomProvider).not.toHaveBeenCalled(); + }); + + it('locks only an actual save request and restores Cancel after failure', async () => { + let reject!: (error: Error) => void; + customProviderMocks.readCustomProviderKey.mockResolvedValue(null); + customProviderMocks.updateCustomProvider.mockReturnValueOnce( + new Promise((_, fail) => { + reject = fail; + }), + ); + const onClose = vi.fn(), + onSaved = vi.fn(); + render( + , + ); + await waitForInitialDialogFocus(); + await act(async () => {}); + const save = screen.getByRole('button', { name: 'settings.providers.custom.save' }); + const cancel = screen.getByRole('button', { name: 'settings.providers.custom.cancel' }); + fireEvent.click(save); + fireEvent.click(save); + fireEvent.click(cancel); + fireEvent.keyDown(window, { key: 'Escape' }); + expect(customProviderMocks.updateCustomProvider).toHaveBeenCalledOnce(); + expect(onClose).not.toHaveBeenCalled(); + expect(save.getAttribute('aria-busy')).toBe('true'); + await act(async () => reject(new Error('Try again'))); + expect((cancel as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(save); + await waitFor(() => expect(onSaved).toHaveBeenCalledOnce()); + expect(customProviderMocks.updateCustomProvider).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/desktop/src/renderer/components/settings/__tests__/McpServerDialog.test.tsx b/apps/desktop/src/renderer/components/settings/__tests__/McpServerDialog.test.tsx new file mode 100644 index 00000000000..b67be08a91e --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/__tests__/McpServerDialog.test.tsx @@ -0,0 +1,141 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { McpServerDialog } from '../McpServerDialog'; + +const api = vi.hoisted(() => ({ create: vi.fn(), update: vi.fn(), read: vi.fn(), error: vi.fn() })); +vi.mock('@/lib/customMcpServers', () => ({ + createCustomMcpServer: api.create, + updateCustomMcpServer: api.update, + readCustomMcpToken: api.read, +})); +vi.mock('@/lib/toast', () => ({ toast: { success: vi.fn(), error: api.error } })); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +const initial = { + id: 'custom_original', + name: 'Example', + transport: 'sse' as const, + url: 'https://example.test/mcp', + headers: { 'X-Test': 'value' }, +}; +const field = (name: string) => + screen.getByLabelText(`settings.mcp.fields.${name}`) as HTMLInputElement; +const save = () => screen.getByRole('button', { name: 'settings.mcp.save' }); +const cancel = () => screen.getByRole('button', { name: 'settings.mcp.cancel' }); +const change = (name: string, value: string) => + fireEvent.change(field(name), { target: { value } }); +beforeEach(() => { + vi.clearAllMocks(); + api.create.mockResolvedValue(undefined); + api.update.mockResolvedValue(undefined); + api.read.mockResolvedValue(null); +}); +afterEach(() => { + cleanup(); + document.querySelector('[data-ds6-trigger]')?.remove(); +}); + +it('associates field errors and focuses the first invalid field without sending a request', async () => { + render(); + fireEvent.click(save()); + expect(document.activeElement).toBe(field('name')); + for (const name of ['name', 'url']) { + expect(field(name).getAttribute('aria-invalid')).toBe('true'); + expect( + document.getElementById(field(name).getAttribute('aria-describedby')!)?.textContent, + ).toContain('settings.mcp.errors.'); + } + expect(api.create).not.toHaveBeenCalled(); + change('name', 'Example'); + change('url', 'file:///tmp/example'); + fireEvent.click(save()); + expect(document.activeElement).toBe(field('url')); + expect(api.error).not.toHaveBeenCalled(); +}); + +it('preserves create ID, transport, trimmed headers and raw token arguments', async () => { + const onSaved = vi.fn(); + render(); + change('name', ' Example '); + change('url', ' https://example.test/mcp '); + change('token', ' token '); + fireEvent.click(screen.getByRole('button', { name: 'sse' })); + fireEvent.change(screen.getByPlaceholderText('settings.mcp.fields.headerNamePlaceholder'), { + target: { value: ' X-Test ' }, + }); + fireEvent.change(screen.getByPlaceholderText('settings.mcp.fields.headerValuePlaceholder'), { + target: { value: ' value ' }, + }); + fireEvent.click(save()); + await waitFor(() => expect(onSaved).toHaveBeenCalledOnce()); + expect(api.create).toHaveBeenCalledWith({ ...initial, id: 'custom_example-2' }, ' token '); +}); + +it('blocks duplicate submit, Cancel, Escape and outside only while saving, then recovers after failure', async () => { + let fail!: (error: Error) => void; + api.update.mockReturnValueOnce( + new Promise((_, reject) => { + fail = reject; + }), + ); + const onClose = vi.fn(), + onSaved = vi.fn(); + render(); + fireEvent.click(save()); + fireEvent.click(save()); + fireEvent.click(cancel()); + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); + fireEvent.pointerDown(screen.getByRole('dialog').previousElementSibling!); + expect(api.update).toHaveBeenCalledOnce(); + expect(onClose).not.toHaveBeenCalled(); + expect(save().getAttribute('aria-busy')).toBe('true'); + expect((cancel() as HTMLButtonElement).disabled).toBe(true); + await act(async () => fail(new Error('Unavailable'))); + expect(api.error).toHaveBeenCalled(); + expect(field('name').value).toBe('Example'); + expect((cancel() as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(save()); + await waitFor(() => expect(onSaved).toHaveBeenCalledOnce()); + expect(api.update).toHaveBeenCalledTimes(2); +}); + +it('retains existing token when saved before hydration and clears only after a loaded token is emptied', async () => { + let hydrate!: (value: string) => void; + api.read.mockReturnValue( + new Promise((resolve) => { + hydrate = resolve; + }), + ); + api.update.mockRejectedValueOnce(new Error('Retry')); + render(); + fireEvent.click(save()); + await waitFor(() => expect(api.error).toHaveBeenCalled()); + expect(api.update).toHaveBeenLastCalledWith(initial, '', false); + await act(async () => hydrate('example-secret')); + expect(field('token').type).toBe('password'); + fireEvent.click(screen.getByRole('button', { name: 'settings.apiKey.showKey' })); + expect(field('token').type).toBe('text'); + change('token', ''); + fireEvent.click(save()); + await waitFor(() => expect(api.update).toHaveBeenLastCalledWith(initial, '', true)); +}); + +it('keeps header IDs through row deletion and restores the opening control focus on close', async () => { + const user = userEvent.setup(); + const trigger = document.createElement('button'); + trigger.dataset.ds6Trigger = 'true'; + document.body.append(trigger); + trigger.focus(); + const { unmount } = render(); + await waitFor(() => expect(document.activeElement).toBe(field('name'))); + fireEvent.click(screen.getByRole('button', { name: 'settings.mcp.fields.addHeader' })); + const survivor = screen.getAllByPlaceholderText('settings.mcp.fields.headerNamePlaceholder')[1]; + const id = survivor.id; + await user.click(screen.getByRole('button', { name: 'settings.mcp.fields.removeRow 1' })); + expect(screen.getByPlaceholderText('settings.mcp.fields.headerNamePlaceholder').id).toBe(id); + await waitFor(() => expect(document.activeElement).toBe(survivor)); + unmount(); + await waitFor(() => expect(document.activeElement).toBe(trigger)); + trigger.remove(); +}); diff --git a/apps/desktop/src/renderer/components/ui/__tests__/formControls.test.tsx b/apps/desktop/src/renderer/components/ui/__tests__/formControls.test.tsx new file mode 100644 index 00000000000..6763f494faf --- /dev/null +++ b/apps/desktop/src/renderer/components/ui/__tests__/formControls.test.tsx @@ -0,0 +1,91 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, expect, it, vi } from 'vitest'; +import { Button } from '../button'; +import { Input } from '../input'; +import { ConfirmDialog } from '../confirm-dialog'; + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +vi.mock('@/lib/scrollbarAutoHide', () => ({ flashScrollbar: vi.fn() })); +afterEach(cleanup); + +it('retains the busy button name, blocks activation and restores it on failure', () => { + const click = vi.fn(); + const { rerender } = render( + , + ); + const button = screen.getByRole('button', { name: 'Save changes' }); + fireEvent.click(button); + expect(click).not.toHaveBeenCalled(); + expect(button.getAttribute('aria-busy')).toBe('true'); + expect(button.querySelector('.motion-reduce\\:animate-none')).not.toBeNull(); + rerender(); + fireEvent.click(button); + expect(click).toHaveBeenCalledOnce(); +}); + +it('keeps disabled secret values masked and the reveal control unavailable', () => { + render( {}} />); + const input = screen.getByLabelText('Token') as HTMLInputElement; + const reveal = screen.getByRole('button') as HTMLButtonElement; + expect(reveal.disabled).toBe(true); + fireEvent.click(reveal); + expect(input.type).toBe('password'); + expect(reveal.getAttribute('aria-pressed')).toBe('false'); +}); + +it.each([undefined, 'standard'] as const)( + 'preserves ordinary confirm ordering and Cancel focus (%s)', + async (presentation) => { + const onTertiary = vi.fn(); + render( + , + ); + expect(screen.getAllByRole('button').map((button) => button.textContent)).toEqual([ + 'Save', + 'Discard', + 'Cancel', + ]); + await waitFor(() => + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Cancel' })), + ); + fireEvent.click(screen.getByRole('button', { name: 'Discard' })); + expect(onTertiary).toHaveBeenCalledOnce(); + }, +); + +it('prioritizes typed confirmation over explicit main focus and blocks busy closure', async () => { + const close = vi.fn(); + const props = { + presentation: 'standard' as const, + open: true, + title: 'Delete?', + confirmText: 'Delete', + autoFocusConfirm: true, + requireTypedConfirmation: { expected: 'DELETE', label: 'Type DELETE' }, + onOpenChange: close, + }; + const { rerender } = render(); + const input = screen.getByRole('textbox'); + await waitFor(() => expect(document.activeElement).toBe(input)); + expect((screen.getByRole('button', { name: 'Delete' }) as HTMLButtonElement).disabled).toBe(true); + fireEvent.change(input, { target: { value: 'DELETE' } }); + expect((screen.getByRole('button', { name: 'Delete' }) as HTMLButtonElement).disabled).toBe( + false, + ); + rerender(); + fireEvent.keyDown(screen.getByRole('alertdialog'), { key: 'Escape' }); + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + expect(close).not.toHaveBeenCalled(); +}); diff --git a/apps/desktop/src/renderer/components/ui/__tests__/formField.test.tsx b/apps/desktop/src/renderer/components/ui/__tests__/formField.test.tsx new file mode 100644 index 00000000000..977651af64d --- /dev/null +++ b/apps/desktop/src/renderer/components/ui/__tests__/formField.test.tsx @@ -0,0 +1,53 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, expect, it, vi } from 'vitest'; +import { FormField } from '../form-field'; +import { Input } from '../input'; + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +afterEach(cleanup); + +it('associates labels, hints, errors and caller descriptions without native required validation', async () => { + const { rerender } = render( + + {(control) => {}} />} + , + ); + const input = screen.getByLabelText('Endpoint'); + const id = input.id; + await userEvent.click(screen.getByText('Endpoint')); + expect(document.activeElement).toBe(input); + expect(input.getAttribute('aria-required')).toBe('true'); + expect(input.hasAttribute('required')).toBe(false); + rerender( + + {(control) => {}} />} + , + ); + expect(input.id).toBe(id); + expect(input.getAttribute('aria-describedby')).toBe(`external ${id}-hint ${id}-error`); + expect(input.getAttribute('aria-invalid')).toBe('true'); + expect(document.getElementById(`${id}-error`)?.textContent).toBe('Invalid URL'); +}); + +it('keeps surviving dynamic field identities when an earlier row is removed', () => { + const fields = (keys: number[]) => + keys.map((key) => ( + + {(control) => {}} />} + + )); + const { rerender } = render(<>{fields([1, 2])}); + const survivingId = screen.getByLabelText('Header 2').id; + expect(screen.getByLabelText('Header 1').id).not.toBe(survivingId); + rerender(<>{fields([2])}); + expect(screen.getByLabelText('Header 2').id).toBe(survivingId); +}); diff --git a/apps/desktop/src/renderer/components/ui/__tests__/input.test.tsx b/apps/desktop/src/renderer/components/ui/__tests__/input.test.tsx index 917d79dec66..21e72a715e1 100644 --- a/apps/desktop/src/renderer/components/ui/__tests__/input.test.tsx +++ b/apps/desktop/src/renderer/components/ui/__tests__/input.test.tsx @@ -25,7 +25,7 @@ describe('Input', () => { expect(cls).toContain('text-[var(--text-primary)]'); expect(cls).toContain('placeholder:text-[var(--text-placeholder)]'); expect(cls).toContain('border-[var(--border-default)]'); - expect(cls).toContain('focus:ring-[var(--focus-ring)]'); + expect(cls).toContain('focus:ring-[var(--focus-ring-soft)]'); expect(cls).not.toContain('settings-input-placeholder'); }); @@ -38,7 +38,7 @@ describe('Input', () => { expect(inputClass()).toContain('h-[40px]'); }); - it('keeps ivory as an explicit registered-debt variant', () => { + it('keeps ivory as an explicit white-panel variant', () => { render( {}} surface="ivory" />); expect(inputClass()).toContain('bg-[var(--settings-input-bg)]'); expect(inputClass()).not.toContain('bg-[var(--surface-elevated)]'); diff --git a/apps/desktop/src/renderer/components/ui/button.tsx b/apps/desktop/src/renderer/components/ui/button.tsx index edb69206e20..e0782c083f3 100644 --- a/apps/desktop/src/renderer/components/ui/button.tsx +++ b/apps/desktop/src/renderer/components/ui/button.tsx @@ -11,12 +11,13 @@ * 禁用态指针遵循既有「禁用统一普通指针」裁决(#3246):class 仍写 * disabled:cursor-not-allowed,globals.css 把它收成普通箭头。 * - * loading 首批调用点未用到,本张不做。 + * loading 仅表达调用方持有的进行中状态;不执行请求或改变提交语义。 */ import * as React from 'react'; import { cn } from '@/lib/utils'; +import { Spinner } from './spinner'; export type ButtonVariant = 'primary' | 'secondary' | 'cta'; export type ButtonSize = 'md' | 'lg'; @@ -24,6 +25,8 @@ export type ButtonSize = 'md' | 'lg'; export interface ButtonProps extends React.ButtonHTMLAttributes { variant?: ButtonVariant; size?: ButtonSize; + /** Busy actions retain their label and width, and cannot be activated again. */ + loading?: boolean; } const SIZE_STYLES: Record = { @@ -55,13 +58,26 @@ const VARIANT_STYLES: Record = { }; const Button = React.forwardRef( - ({ className, variant = 'primary', size = 'md', type = 'button', disabled, ...props }, ref) => ( + ( + { + className, + variant = 'primary', + size = 'md', + type = 'button', + disabled, + loading = false, + children, + ...props + }, + ref, + ) => ( ), ); Button.displayName = 'Button'; diff --git a/apps/desktop/src/renderer/components/ui/confirm-dialog-provider.tsx b/apps/desktop/src/renderer/components/ui/confirm-dialog-provider.tsx index ac468edf2b5..e8de7486895 100644 --- a/apps/desktop/src/renderer/components/ui/confirm-dialog-provider.tsx +++ b/apps/desktop/src/renderer/components/ui/confirm-dialog-provider.tsx @@ -3,6 +3,7 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState, ty import { ConfirmDialog } from '@/components/ui/confirm-dialog'; export interface ConfirmOptions { + presentation?: 'standard'; title: string; description?: string; /** 可选的标题与正文样式;仅调用方显式传入时生效。 */ @@ -273,6 +274,7 @@ export function ConfirmDialogProvider({ children }: { children: ReactNode }) { {children} {currentItem && ( void; title: string; @@ -89,6 +92,7 @@ export interface ConfirmDialogProps { export function ConfirmDialog({ open, + presentation, onOpenChange, title, description, @@ -306,71 +310,104 @@ export function ConfirmDialog({ />
)} -
- - - - {tertiaryText && ( - // tertiary 走 secondary 同款轮廓样式 —— 视觉上 "中性可选"; - // 不用 AlertDialog.Action / Cancel,自己 onClick 触发,Radix 不会 - // 自动关 dialog,因此外层得在 onTertiary 里手动 onOpenChange(false)。 - - )} - {showCancel && ( - + {presentation === 'standard' ? ( +
+ + + + {tertiaryText && ( + + )} + {showCancel && ( + + + + )} +
+ ) : ( +
+ + + {tertiaryText && ( + // tertiary 走 secondary 同款轮廓样式 —— 视觉上 "中性可选"; + // 不用 AlertDialog.Action / Cancel,自己 onClick 触发,Radix 不会 + // 自动关 dialog,因此外层得在 onTertiary 里手动 onOpenChange(false)。 + - - )} -
+ )} + {showCancel && ( + + + + )} +
+ )} diff --git a/apps/desktop/src/renderer/components/ui/form-field.tsx b/apps/desktop/src/renderer/components/ui/form-field.tsx new file mode 100644 index 00000000000..ae8ec590b23 --- /dev/null +++ b/apps/desktop/src/renderer/components/ui/form-field.tsx @@ -0,0 +1,86 @@ +import { useId, type ReactNode } from 'react'; + +import { cn } from '@/lib/utils'; + +export interface FormFieldControlProps { + id: string; + 'aria-describedby'?: string; + 'aria-invalid'?: true; + 'aria-required'?: true; + error: boolean; +} + +export interface FormFieldProps { + id?: string; + label: ReactNode; + hint?: ReactNode; + error?: ReactNode; + required?: boolean; + /** Existing descriptions outside this field remain associated. */ + describedBy?: string; + labelAction?: ReactNode; + className?: string; + /** Dense dynamic rows can retain a label for assistive technology. */ + hideLabel?: boolean; + /** Reserve feedback space only for fields that can fail validation. */ + reserveFeedback?: boolean; + children: (control: FormFieldControlProps) => ReactNode; +} + +/** Label/description/error relationships only; values and validation belong to the form. */ +export function FormField({ + id, + label, + hint, + error, + required, + describedBy, + labelAction, + className, + hideLabel, + reserveFeedback, + children, +}: FormFieldProps) { + const generatedId = useId(); + const controlId = id ?? generatedId; + const hintId = `${controlId}-hint`; + const errorId = `${controlId}-error`; + const description = [describedBy, hint && hintId, error && errorId].filter(Boolean).join(' '); + return ( +
+
+ + {labelAction} +
+ {children({ + id: controlId, + 'aria-describedby': description || undefined, + 'aria-invalid': error ? true : undefined, + 'aria-required': required ? true : undefined, + error: Boolean(error), + })} + {(hint || error || reserveFeedback) && ( +
p:last-child]:min-h-[2.5em]', + )} + > + {hint && ( +

+ {hint} +

+ )} +

+ {error} +

+
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/ui/input.tsx b/apps/desktop/src/renderer/components/ui/input.tsx index 31c501ac39c..d2571dcb882 100644 --- a/apps/desktop/src/renderer/components/ui/input.tsx +++ b/apps/desktop/src/renderer/components/ui/input.tsx @@ -5,11 +5,10 @@ * 圆角:单行一律胶囊(§5);textarea 变体一律 8px。 * placeholder 一律 `--text-placeholder`(§4 G3)。 * - * `surface="ivory"` 是登记债(DS-4 G6,拍板人 = 用户/设计师,2026-09-03): - * colors.ts 的无文档漂移,仅供白弹窗面板场合。独立议题收口,本张不翻案。 + * `surface="ivory"` 已在 DS-6 D2 确认用于白面板,默认仍为 elevated。 * API 原样搬:secret 眼睛显形、mono、trailing。 * - * FormField(label + 说明 + 错误行)首批调用点未用到,本张不建。 + * 字段的 label / 说明 / 错误关系由 FormField 组合,业务校验由消费者持有。 */ import { @@ -23,6 +22,7 @@ import { Eye, EyeOff } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; +import { Tip } from './tooltip'; export type InputSize = 'sm' | 'md' | 'lg'; export type InputSurface = 'elevated' | 'ivory'; @@ -31,10 +31,9 @@ export type InputSurface = 'elevated' | 'ivory'; * 底色:默认 `elevated` = DESIGN.md §4 `input/text` 规定的 fill(`--surface-elevated`)。 * * `ivory` 是给「输入压在白色弹窗面板上」的调用点保留的既有底色(`--settings-input-bg`, - * 解析到 `--surface-card-ivory`)。这份 ivory 在设计文档里没有任何背书,属 colors.ts 的 - * 无文档漂移;它在白面板上能给出 fill 抬升,所以不在本 PR 里翻成白色,但也不能当默认—— + * 解析到 `--surface-card-ivory`)。DS-6 D2 保留其明确用途和用户局部覆盖; * settings 卡片本身就是 ivory(`--settings-theme-card-bg`),ivory 输入压在 ivory 卡上会和 - * 背景同色,填充对比度归零。settings 域这处 ivory / elevated 的收口是独立议题。 + * 背景同色,因此不要把 ivory 当成通用默认值。 */ const SURFACE_STYLES: Record = { elevated: 'bg-[var(--surface-elevated)]', @@ -82,12 +81,15 @@ const EYE_STYLES: Record = { * 不把 settings 域 override 提升为所有 Input 或全局 semantic 的默认值。 */ const FIELD_CHROME = - 'text-[var(--text-primary)] placeholder:text-[var(--text-placeholder)] border border-[var(--border-default)] focus:border-[var(--text-tertiary-stone)] focus:ring-2 focus:ring-[var(--focus-ring)]'; + 'text-[var(--text-primary)] placeholder:text-[var(--text-placeholder)] border border-[var(--border-default)] focus:border-[var(--text-tertiary-stone)] focus:ring-2 focus:ring-[var(--focus-ring-soft)]'; const ERROR_CHROME = 'border-[var(--error-border)] focus:border-[var(--error-fg)] focus:ring-[var(--error-fg)]'; -export interface InputProps extends Omit, 'size' | 'onChange'> { +export interface InputProps extends Omit< + InputHTMLAttributes, + 'size' | 'onChange' +> { value: string; onChange: (v: string) => void; /** 绛兼潵鍏ョ粍浠剁殑 DOM input锛屼緥濡傝缃紑绐楃殑深链 focus。 */ @@ -139,21 +141,25 @@ export function Input({ const eyeStyle = EYE_STYLES[size]; const eyeButton = secret ? ( - + + + ) : null; const trailingNode = eyeButton ?? trailing; @@ -162,7 +168,8 @@ export function Input({ onChange(e.target.value)} @@ -194,8 +201,10 @@ export function Input({ ); } -export interface TextareaProps - extends Omit, 'onChange'> { +export interface TextareaProps extends Omit< + TextareaHTMLAttributes, + 'onChange' +> { value: string; onChange: (v: string) => void; surface?: InputSurface; diff --git a/docs/design-evidence/2026-09-08/ds6-forms.md b/docs/design-evidence/2026-09-08/ds6-forms.md new file mode 100644 index 00000000000..41e195f8f47 --- /dev/null +++ b/docs/design-evidence/2026-09-08/ds6-forms.md @@ -0,0 +1,173 @@ +# DS-6 真实设置表单与普通确认验证 + +状态:工程候选可审阅;目标尚未全部验收,未提交、未公开上传。日期:2026-09-08。 + +## 采集边界 + +- 开工与任务 HEAD:`a00ae8d464406f3092855f6fe9a5929553ceed37`;分支 `ds/6-forms-and-confirm`。候选是其上的未提交补丁,后续补文件摘要。 +- macOS arm64;Node 22.22.3、pnpm 10.33.2。frozen-lockfile 安装通过,安全启动器确认 Node/Electron native cache 匹配。未修改依赖、Token JSON、CI 或用户配置语义。 +- Desktop 独立 `ds6` 沙箱,`--region=global --passive`;重启后安全包装明确 ready,数据库文件句柄核实均在 `CindyGlobal-dev2-ds6`。完整 Settings 使用真实路由/组件,服务边界在 renderer 内返回虚构 provider/MCP 与假密钥,不做真实配置/凭证写入、不绕鉴权。 +- 自动化使用假数据/服务替身,不读取真实凭证。完整设置页、真实组件受控样例、静态效果稿、computed 和人工目检分别记录。 + +## 改前事实 + +| 消费者 | 字段与原校验 | 保存/关闭 | 主题消费 | +| --- | --- | --- | --- | +| CustomProviderDialog | 名称必填;Claude Code/Codex/Pi 各维护 URL、模型、key、请求头;至少一个 runtime;非 OAuth 至少一有效模型;请求路径/URL/窗口草稿保留原校验;OAuth 两种 flow 的 URL/clientId/scopes | 成功父级关闭;失败 Toast 保留草稿;Esc/遮罩已有层级管理和 saving 防护,Cancel 仍可关;测试连接/模型获取独立 | SettingsTextInput ivory;settings-input-* 局部 alias;按钮 confirm-btn-*;错误目前 Toast | +| McpServerDialog | 名称必填;URL 必须 http(s);http/sse;token 可空;请求头 trim、忽略空名、同名后者覆盖 | 成功父级关闭;失败保留;原无 dialog 焦点/Esc/遮罩合同;× 与 Cancel 并存 | 私有 40px/10px TextInput,settings-input-*;按钮 confirm-btn-* | +| MCP token | 回填后 hasToken=true 且输入清空:clearToken=true;回填尚未完成且空:false | create(config, token) / update(config, token, clearToken) 不变 | 与供应商 key 空值含义不同,不统一 | +| ConfirmDialog | default/destructive;主→第三→Cancel;默认 Cancel 焦点,autoFocusConfirm 与 typed 分支保留 | AlertDialog 遮罩不关;Provider 取得选择即结算,不等待业务;队列/abort/checkbox/skip 不变 | confirm-bg/shadow/title/desc、confirm-btn-primary/secondary-*;未迁调用继续原分支 | +| 相邻模型入口 | ModelAdvancedDrawer 保留 Codex 上限编辑、未改不写入、恢复默认;ModelHarnessPicker/兼容/停用选择按 main | 回归对象,非第三消费者 | 不迁私有数字框 | + +## 共享确认调用盘点 + +仅两处显式加入本批呈现:`ProvidersSection.handleDelete` 自定义供应商删除、`McpServersSection.handleDelete` 自定义 MCP 删除;均保留原 default,不替换删除回调。 + +以下是 2026-09-08 在任务基线搜索 `useConfirmDialog| 本文件是 `docs/design-rules/` 全部设计文档的索引与版本台账(2026-07-24 起启用,此前为跳转 stub)。 > 设计类 `.md` 一律放本目录,并在下表登记;规范正文不要写进本文件。 + +DS-6 表单贡献入口:先读 [DESIGN §4](./DESIGN.md#inputs--forms),在真实字段中复用 [FormField](../../apps/desktop/src/renderer/components/ui/form-field.tsx) 与 [SettingsTextInput](../../apps/desktop/src/renderer/components/settings/SettingsTextInput.tsx)(普通域用 Input);保存反馈用 [Button loading](../../apps/desktop/src/renderer/components/ui/button.tsx)。业务校验、请求与焦点由表单持有;首消费者为 [CustomProviderDialog](../../apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx),第二消费者为 [McpServerDialog](../../apps/desktop/src/renderer/components/settings/McpServerDialog.tsx)。[证据与未验收项](../design-evidence/2026-09-08/ds6-forms.md) 区分组件/整页/人工/G2,settings 仍是 pilot。 + ## 文档索引 | 文档 | 内容 | 角色 | diff --git a/docs/design-rules/design-decision-log.md b/docs/design-rules/design-decision-log.md index 1565329f8ae..4026dc1d25d 100644 --- a/docs/design-rules/design-decision-log.md +++ b/docs/design-rules/design-decision-log.md @@ -12,6 +12,8 @@ ## 2026-09 +- **09-08 DS-6 表单五项裁决(用户已采纳)**:输入焦点环按 soft/50% 实施,实际亮暗/错误/旧主题可辨识度仍须验收;保留 elevated 默认及有明确白面板用途的 ivory。普通确认复用标准按钮但保留中性反相/轮廓主次、排列、默认取消焦点和显式/typed 分支,不整体改为 CTA、不迁授权。必填/格式错误移到字段并定位首错,服务失败仍 Toast;真正保存时防重复并禁止关闭,成功关闭、失败恢复,独立连接测试/模型获取不被锁住。取代治理 §10 对这三组视觉项的 pending,以及 §4 对 ivory 暂留/焦点偏差和普通确认焦点的旧描述。业务规则、payload、凭证和配置语义不变。落点:`DESIGN.md §4`、治理 §10、真实 provider/MCP 表单;工程/证据/G2 状态分别见 [证据索引](../design-evidence/2026-09-08/ds6-forms.md),批准不等于验证通过。 + - **09-06** **键盘快捷键外框统一使用 4px 圆角(用户裁决,PR #4001)**:所有可见快捷键外框,无论使用 `` 还是承载快捷键的交互按钮,统一采用 `rounded-[4px]`;边框、填充、内边距和文字颜色按所在表面保留上下文差异。该规则是圆角体系的注册例外,覆盖 08-29「4px 不入档」对键盘键帽的适用范围;`DESIGN.md §5` 与 `design-governance.md §10` 已同步回写。 - **09-06** **首页撤掉订阅与赠送余额告知大卡(拍板人 = 用户)**——用户重申: diff --git a/docs/design-rules/design-governance.md b/docs/design-rules/design-governance.md index 123aa38a425..4b2e52bee59 100644 --- a/docs/design-rules/design-governance.md +++ b/docs/design-rules/design-governance.md @@ -237,16 +237,15 @@ ls apps/desktop/src/renderer/themes/builtin/*.ts | wc -l | --- | --- | --- | | Permission 按钮 / 非按钮圆角 | 2026-08-29 用户裁决,#3619 回写:按钮胶囊,textarea 与盒内非按钮 8px;见 `DESIGN.md §5` 与 [decision-log](./design-decision-log.md) 对应日期;不决定外层卡片几何 | **已批准、尚未全部实现**:[PermissionPrompt](../../apps/desktop/src/renderer/components/new-chat/PermissionPrompt.tsx):209/241/267 按钮仍 8px,DS-11 随迁移落实;三项待决未关闭前不借此提前改文件。Mobile Permission 按钮已 pill;外层卡片差异见下表 | | 快捷键键帽外框 | 2026-09-06 用户裁决 [#4001](https://github.com/makecindy/cindy/pull/4001):所有可见快捷键外框(含承载快捷键的交互按钮)4px;边框/填充/内边距/颜色按所在表面 | 已回写 `DESIGN.md §5` / decision-log;Desktop Permission 本样本键帽已 4px,不重开该决定,不把键帽按普通按钮胶囊改掉 | -| DS-4 基础控件 | `DESIGN.md §4` 与 decision-log 2026-09-04:按钮/输入尺寸、按钮字号字重、hover/pressed、通用 secondary Tier-1、ivory 暂留 | DS-4 已落地;DS-4b 设置封装恢复局部覆盖。暂留 ivory 不等于批准永久统一,完整表单/证据与第二消费者在 DS-6 | +| DS-4 基础控件 | `DESIGN.md §4` 与 decision-log 2026-09-04:按钮/输入尺寸、按钮字号字重、hover/pressed、通用 secondary Tier-1、ivory 暂留 | DS-4 已落地;DS-4b 设置封装恢复局部覆盖。ivory 的长期用途已由下列 DS-6 裁决明确;完整表单/证据与第二消费者在 DS-6 | + +**DS-6 已批准、验收另记(2026-09-08)**:D1 soft/50% 输入焦点环,亮暗/错误/旧主题可辨识是验收前提;不通过须提供实际对照再讨论该项。D2 elevated 默认+用途明确的 ivory,保留局部覆盖。D3 普通确认保留反相中性主按钮、轮廓次/第三按钮、主→第三→取消排列、默认 Cancel / 显式主按钮 / typed 输入优先分支及 default/destructive;仅指定两处删除入口 opt-in,授权不迁。D4 原必填/格式规则转字段错误并定位首错,服务失败保留 Toast。D5 仅实际保存禁止重复提交及 Cancel/Esc/遮罩关闭,成功关闭/失败恢复,不锁独立测试连接/获取模型,不改业务语义。实现与实际证据见 [DS-6 索引](../design-evidence/2026-09-08/ds6-forms.md),未验证不记通过。 ### 待决:只阻塞对应范围 | 问题 / 当前实际行为与依据 | 可选择的可见结果 / 推荐理由(未批准) | 影响、未决定时的保持方式 / 最晚阻塞 | | --- | --- | --- | -| **Input 焦点环**:`DESIGN.md §4` 规定 soft/50%,[ui/input.tsx](../../apps/desktop/src/renderer/components/ui/input.tsx):85 实际 opaque `--focus-ring`。设置壳另保留 `settings-input-border-focus`;聊天输入的描边是另一用途 | A:焦点环变柔和,按现规范实现;B:保留当前鲜明环并正式修订规范。建议先用真实设置表单比较聚焦与错误态可辨识度,再选 A/B;源码不能替视觉判断 | 所有标准 Input 消费者及旧设置主题受影响。pending 时保持 opaque 和规范中的偏差标注;DS-6 如获决定同批实施,否则保留现状并登记,DS-8 可等值接管但不能写“已统一”;最终 G4 前需结论 | -| **ivory / elevated 长期关系**:[ui/input.tsx](../../apps/desktop/src/renderer/components/ui/input.tsx):39—41 默认 elevated,显式 ivory 走 `settings-input-bg` → `surface-card-ivory`;DS-4 已批准暂留 | A:长期保留可说明用途的 ivory 变体;B:默认统一 elevated,仍尊重用户显式局部覆盖。建议先在白色面板与嵌套卡片比较边界,避免统一后层次消失;不能把暂留当永久决定 | 设置表单、输入卡片与显式主题覆盖。pending 保持已有两变体,DS-6 按实际消费者准备;DS-8 保留等值路径,最终 G1/G4 前明确长期用途 | | **用户 `colors.radius` 效果**:[theme-service.ts](../../apps/desktop/src/renderer/themes/theme-service.ts):11 优先主题显式值;[Tailwind](../../apps/desktop/tailwind.config.ts):85—87 用 `--radius` 派生 rounded-lg/md/sm;实际 computed 可偏离默认档 | A:保留用户覆盖,并区分默认基线与合法自定义;B:以后让标准控件固定几何,仅在明确兼容方案与用户裁决下讨论。**建议 A**,保留现有用户能力。B 不能通过删字段/白名单绕过旧主题红线,当前未授权 | 自定义圆角主题。pending 保留字段与实际效果;DS-7 棘轮不能把合法覆盖报违规,可按类名与默认主题建基线;DS-8 必须等值保留,不能保留就先关闭该部分决定,不得先切换 | -| **普通确认主次与局部色**:[confirm-dialog.tsx](../../apps/desktop/src/renderer/components/ui/confirm-dialog.tsx):323/360/379 使用独立 `confirm-btn-*`;普通主按钮反相中性,取消/第三按钮轮廓;危险动作已有 destructive 分支。主按钮 DOM 在取消前,`:215` 仅显式 `autoFocusConfirm` 改为主按钮焦点,不能当规范示例已实现 | A:复用标准组件但保留现有确认层级、排列与焦点;B:调整可见主次/排列/默认焦点。建议 A 先建立复用与局部覆盖合同,B 需针对真实危险/普通样本单独明确;不能盲套 CTA | 所有普通确认消费者及 `confirm-bg` / `confirm-shadow` / `confirm-title` / `confirm-desc` 容器与文案,以及 `confirm-btn-*` 按钮的主题覆盖。pending 保持现状;DS-6 迁移前确认所选结果,未明确的可见改动不实施,旧 alias 必须留原作用域 | | **跨 surface 旧 alias**:DS-4b 仅设置输入;`msg-user-text/msg-assistant-text` 默认同指 `text-primary` 仍允许独立用户覆盖,设置同理。来源:`colors.ts`、theme-service 与 Token README 真实消费者 | A:以语义源供默认值,保留旧局部 ID 及覆盖优先级;B:强制局部跟随全局会改旧用户主题效果,不能在现兼容合同下执行。**建议 A**,按族核对,不能靠默认同值猜意图 | 设置、消息、确认/授权及其它主题用户。pending 原 ID、作用域、加载幂等和磁盘不变;DS-6/8/9/11 分别在相关消费者切换前核对,历史“49 文件”不当实时清单 | | **Permission 允许/拒绝主次**:[PermissionPrompt](../../apps/desktop/src/renderer/components/new-chat/PermissionPrompt.tsx):209/241 为拒绝/整任务允许轮廓,:267 允许一次实底,CINDY 内置覆盖为反相中性;Mobile [InteractionPanel](../../apps/mobile/src/session/InteractionPanel.tsx):635—672 拒绝/始终允许 secondary、允许一次 primary(cta) | A:保留允许一次为视觉主动作、其它次级;B:降低允许强调或突出拒绝以增强审慎感。建议先比较普通/高风险真实样本;不以 Desktop 默认白底推断所有主题。选项仅指视觉,不改含义/顺序/默认/审批生命周期 | 所有授权用户,两端与主题。pending 原样保留;**DS-11 前必须关闭**,责任为用户/设计师决定,DS-11 执行者准备/落实 | | **Permission 危险样式**:Desktop PermissionPrompt 没有危险视觉 variant;Mobile [interactionModel](../../apps/mobile/src/session/interactionModel.ts):64—82 判高风险,[InteractionPanel](../../apps/mobile/src/session/InteractionPanel.tsx):611—620 高风险允许要二次点击且不提供始终允许;风险提示为中性色,无 destructive 红 | A:保留中性风险信息与已有确认行为;B:危险授权加清晰的危险色/层级,普通授权保持中性。建议比较风险提示的辨识度再决定 B 的范围,不能因普通 ConfirmDialog 已有 destructive 就认为授权已裁决 | 高风险授权及信息色;pending 保留现有行为与配色,尤其不移除 Mobile 二次点击、不恢复高风险始终允许。**DS-11 前必须关闭**;若要求权限业务变化则退出设计迁移范围另议 | @@ -298,7 +297,7 @@ DS-4/4b 尚有公开附件交接与完整设置页/部分状态证据缺口,DS | DS-4 | Button 与 Input 标准组件(既有 `components/ui/`) | 有意可见 | [#3920](https://github.com/makecindy/cindy/pull/3920),2026-09-04 合入;完整表单 / 公开附件缺口由 DS-6 补齐 | | DS-4b | 设置输入旧主题局部覆盖兼容收口 | 零视觉兼容修复 | [#4010](https://github.com/makecindy/cindy/pull/4010),2026-09-06 合入;只覆盖设置封装,未完成全仓 alias 收口 | | DS-5 | 对齐执行路线、数值权威、双端语义与待决合同;仅文档及必要台账静态说明 | 零视觉 | 本批,PR 待创建 | -| DS-6 | 完整设置表单、第二消费者与普通确认复用;按真实需求补 FormField / loading,附使用说明、真实状态证据与独立贡献者首轮试用 | 有意可见 | 待 DS-5;FormField / loading 当前不存在;G2 未到场可待验收,不能冒称通过 | +| DS-6 | 完整设置表单、第二消费者与普通确认复用;按真实需求补 FormField / loading,附使用说明、真实状态证据与独立贡献者首轮试用 | 有意可见 | DS-5/#4022 已合入;DS-6 工作区实现 FormField / loading 与两个消费者,尚未提交,验证与 G2 见证据索引;不能冒称验收通过 | | DS-7 | 复用守卫,成熟写法先报告/反例/历史回放后阻断;增量发现 Mobile 入口;未成熟范围继续报告 | CI 门禁 | 待 DS-6;§8 审核,用户 radius 合法覆盖不误报 | | DS-8 | Desktop 颜色与排版、间距、圆角/尺寸、动效的 DTCG → 生成 → 生产消费链;旧主题与动态/保护边界逐族验证,结束影子阶段 | 零视觉接管 | 待 DS-7;有新观感须独立归类,不能混入等值接管 | | DS-9 | 工具、推理、消息、代码、附件的完整聊天呈现;按台账核验 Orca、定时任务、文件、Bots、主布局、登录、浮层、宿主插件 UI、辅助/原生入口的继承与残余去向 | 有意可见 | 待 DS-8;不依赖 Mobile,Permission 仍隔离;延期须有理由、负责人和复查日期 | diff --git a/docs/design-rules/design-inventory.md b/docs/design-rules/design-inventory.md index 107ed1ff586..1e175154d89 100644 --- a/docs/design-rules/design-inventory.md +++ b/docs/design-rules/design-inventory.md @@ -9,7 +9,7 @@ 本区块由 `scripts/design-inventory.mjs` 生成,请勿手改。 重新生成:`pnpm design:inventory`;校验:`pnpm check:design-inventory`。 -计数快照日期:2026-09-07。生成命令:`pnpm design:inventory`。裸颜色匹配与 `scripts/hardcoded-color-audit.mjs` 共用 `scripts/shared/hardcoded-color-match.mjs`(HEX / rgb() / rgba() / hsl() / hsla()),台账统计层额外剔除 `var()` 包装与注释(TS/TSX 剥块注释与整行注释)——语义 token 消费与注释引用不是迁移债务;裸圆角为粗粒度(`rounded*` class、`border-radius:` 与 React style 对象的 `borderRadius:`)。Token 计数为样式源里 `var(--token)` / `hsl(var(--token)` 的去重 ID 数。 +计数快照日期:2026-09-08。生成命令:`pnpm design:inventory`。裸颜色匹配与 `scripts/hardcoded-color-audit.mjs` 共用 `scripts/shared/hardcoded-color-match.mjs`(HEX / rgb() / rgba() / hsl() / hsla()),台账统计层额外剔除 `var()` 包装与注释(TS/TSX 剥块注释与整行注释)——语义 token 消费与注释引用不是迁移债务;裸圆角为粗粒度(`rounded*` class、`border-radius:` 与 React style 对象的 `borderRadius:`)。Token 计数为样式源里 `var(--token)` / `hsl(var(--token)` 的去重 ID 数。 登记 surface 数:35。平台本轮仅 Desktop。 @@ -38,7 +38,7 @@ | `desktop.overlay.toast` | desktop | Toast | App 常驻 ToastContainer(用户可见出口,不展开业务逻辑) | Toast, ToastContainer | apps/desktop/src/renderer/components/ui/toast/Toast.tsx, apps/desktop/src/renderer/components/ui/toast/ToastContainer.tsx, apps/desktop/src/renderer/components/ui/toast/index.ts | 7 | 4 | 3 | | `desktop.plugins.app-main` | desktop | 插件主视图 | hash `/apps/:ghostId`(GhostMainViewFeatureLayout) | GhostMainViewFeatureLayout, GhostMainViewHost | apps/desktop/src/renderer/features/plugin/GhostMainViewFeatureLayout.tsx, apps/desktop/src/renderer/features/plugin/GhostMainViewHost.tsx | 0 | 0 | 0 | | `desktop.plugins.installed` | desktop | 已装插件 | hash `/plugins`(GhostPluginPage) | AddMarketplaceDialog, GhostPluginDetailView, GhostPluginPage, MarketPluginDetailView, MyPublishesSection, PluginManagementLayout, PluginScopePicker, UpdateAllDialog | apps/desktop/src/renderer/features/plugin/AddMarketplaceDialog.tsx, apps/desktop/src/renderer/features/plugin/GhostPluginDetailView.tsx, apps/desktop/src/renderer/features/plugin/GhostPluginPage.tsx, apps/desktop/src/renderer/features/plugin/MarketPluginDetailView.tsx, apps/desktop/src/renderer/features/plugin/MyPublishesSection.tsx, apps/desktop/src/renderer/features/plugin/PluginManagementLayout.tsx, apps/desktop/src/renderer/features/plugin/PluginScopePicker.tsx, apps/desktop/src/renderer/features/plugin/UpdateAllDialog.tsx, apps/desktop/src/renderer/features/plugin/plugin-motion.css | 49 | 0 | 82 | -| `desktop.settings` | desktop | 设置 | hash `/settings`(SettingsView;tab 含 general / personalization / providers / billing / usage / voice-input / im-bot / shortcuts / agent-island / import / remote-control / ghosts / builtin-tools / computer-use / help / about) | SettingsView | apps/desktop/src/renderer/components/settings/AboutSection.tsx, apps/desktop/src/renderer/components/settings/AccountDeletionSection.tsx, apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx, apps/desktop/src/renderer/components/settings/AgentIslandSection.tsx, apps/desktop/src/renderer/components/settings/AgentResourceSection.tsx, apps/desktop/src/renderer/components/settings/AppearanceSection.tsx, apps/desktop/src/renderer/components/settings/AuxiliaryModelSection.tsx, apps/desktop/src/renderer/components/settings/BetaChannelCell.tsx, apps/desktop/src/renderer/components/settings/BrowserBackendSubsection.tsx, apps/desktop/src/renderer/components/settings/BrowserRealProfileSubsection.tsx, apps/desktop/src/renderer/components/settings/BuiltinToolsSection.tsx, apps/desktop/src/renderer/components/settings/ChatEmbeddingCell.tsx, apps/desktop/src/renderer/components/settings/ChipMetricsSection.tsx, apps/desktop/src/renderer/components/settings/CollaborationSection.tsx, apps/desktop/src/renderer/components/settings/CompactionSection.tsx, apps/desktop/src/renderer/components/settings/ComposerSendShortcutSection.tsx, apps/desktop/src/renderer/components/settings/ComputerPermissionGuideWindow.tsx, apps/desktop/src/renderer/components/settings/ComputerUseSection.tsx, apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx, apps/desktop/src/renderer/components/settings/CustomProviderRuntimeFillOverlay.tsx, apps/desktop/src/renderer/components/settings/DefaultOverrideControls.tsx, apps/desktop/src/renderer/components/settings/DingTalkBotSection.tsx, apps/desktop/src/renderer/components/settings/DiscordBotSection.tsx, apps/desktop/src/renderer/components/settings/DownloadMeter.tsx, apps/desktop/src/renderer/components/settings/ExperimentalSection.tsx, apps/desktop/src/renderer/components/settings/FeishuBotNotificationSection.tsx, apps/desktop/src/renderer/components/settings/FeishuBotSection.tsx, apps/desktop/src/renderer/components/settings/FontFamilyPicker.tsx, apps/desktop/src/renderer/components/settings/GenericGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/GitSafetySection.tsx, apps/desktop/src/renderer/components/settings/HelpAssistantPanel.tsx, apps/desktop/src/renderer/components/settings/HelpSection.tsx, apps/desktop/src/renderer/components/settings/HelpThreadView.tsx, apps/desktop/src/renderer/components/settings/HookConnectionsSection.tsx, apps/desktop/src/renderer/components/settings/HookWorkspacePrefsEditor.tsx, apps/desktop/src/renderer/components/settings/ImBotSection.tsx, apps/desktop/src/renderer/components/settings/ImChannelSettingsCard.tsx, apps/desktop/src/renderer/components/settings/ImDefaultSettingsSection.tsx, apps/desktop/src/renderer/components/settings/ImLifecycleAnnouncementSection.tsx, apps/desktop/src/renderer/components/settings/InputDeviceConnectionStatus.tsx, apps/desktop/src/renderer/components/settings/JoyConGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/KeyboardShortcutsSection.tsx, apps/desktop/src/renderer/components/settings/LanguageSection.tsx, apps/desktop/src/renderer/components/settings/LayoutResetControl.tsx, apps/desktop/src/renderer/components/settings/LinkOpenSection.tsx, apps/desktop/src/renderer/components/settings/LocalOllamaInstall.tsx, apps/desktop/src/renderer/components/settings/LocalPackagingTag.tsx, apps/desktop/src/renderer/components/settings/LogoutSection.tsx, apps/desktop/src/renderer/components/settings/LspBetaCell.tsx, apps/desktop/src/renderer/components/settings/McpServerDialog.tsx, apps/desktop/src/renderer/components/settings/McpServersSection.tsx, apps/desktop/src/renderer/components/settings/MemorySection.tsx, apps/desktop/src/renderer/components/settings/MessageNavRailCell.tsx, apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx, apps/desktop/src/renderer/components/settings/ModelPriceOverrideDialog.tsx, apps/desktop/src/renderer/components/settings/MyDevicesPanel.tsx, apps/desktop/src/renderer/components/settings/NotificationSection.tsx, apps/desktop/src/renderer/components/settings/OAuthDeviceCodeCard.tsx, apps/desktop/src/renderer/components/settings/OllamaProviderDetail.tsx, apps/desktop/src/renderer/components/settings/PiPackagesSection.tsx, apps/desktop/src/renderer/components/settings/PlayStationGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/ProfileEditDialog.tsx, apps/desktop/src/renderer/components/settings/ProvidersSection.tsx, apps/desktop/src/renderer/components/settings/RemoteControlSection.tsx, apps/desktop/src/renderer/components/settings/RemoteHostDetail.tsx, apps/desktop/src/renderer/components/settings/RemoteSection.tsx, apps/desktop/src/renderer/components/settings/SessionImportSection.tsx, apps/desktop/src/renderer/components/settings/SessionRuntimeFallbackCell.tsx, apps/desktop/src/renderer/components/settings/SessionShareImportWizard.tsx, apps/desktop/src/renderer/components/settings/SettingsCatalogPanel.tsx, apps/desktop/src/renderer/components/settings/SettingsSidebarNav.tsx, apps/desktop/src/renderer/components/settings/SettingsTextInput.tsx, apps/desktop/src/renderer/components/settings/SettingsView.tsx, apps/desktop/src/renderer/components/settings/SilentEncryptedRetryCell.tsx, apps/desktop/src/renderer/components/settings/SshKeySetupDialog.tsx, apps/desktop/src/renderer/components/settings/StorageManagementCard.tsx, apps/desktop/src/renderer/components/settings/StreamFadeSection.tsx, apps/desktop/src/renderer/components/settings/SubagentModelSection.tsx, apps/desktop/src/renderer/components/settings/SwitchProGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/TelegramBehaviorSettings.tsx, apps/desktop/src/renderer/components/settings/TelegramBotSection.tsx, apps/desktop/src/renderer/components/settings/TelegramRemoteDevices.tsx, apps/desktop/src/renderer/components/settings/TerminalShellSection.tsx, apps/desktop/src/renderer/components/settings/TipsSection.tsx, apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx, apps/desktop/src/renderer/components/settings/UserProfileCard.tsx, apps/desktop/src/renderer/components/settings/UserPromptSection.tsx, apps/desktop/src/renderer/components/settings/VisionBridgeSection.tsx, apps/desktop/src/renderer/components/settings/VoiceInputSection.tsx, apps/desktop/src/renderer/components/settings/WechatBotSection.tsx, apps/desktop/src/renderer/components/settings/WecomBotSection.tsx, apps/desktop/src/renderer/components/settings/WindowBehaviorSection.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexKeyboardLayout.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexKeycapGlyphs.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexSettings.tsx, apps/desktop/src/renderer/components/settings/XUsageGuide.tsx, apps/desktop/src/renderer/components/settings/XboxGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/XboxGamepadSettings.tsx, apps/desktop/src/renderer/components/settings/androidStatusPresentation.ts, apps/desktop/src/renderer/components/settings/billingVisibility.ts, apps/desktop/src/renderer/components/settings/browserOpenForLoginError.ts, apps/desktop/src/renderer/components/settings/computerPermissionFlow.ts, apps/desktop/src/renderer/components/settings/contacts/ContactDetailPane.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsImportDialog.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsListPane.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsManagerDialog.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsSection.tsx, apps/desktop/src/renderer/components/settings/contacts/startContactsAiSession.ts, apps/desktop/src/renderer/components/settings/dualSenseSilhouette.ts, apps/desktop/src/renderer/components/settings/feishuBotPresentation.ts, apps/desktop/src/renderer/components/settings/fontFamilyValue.ts, apps/desktop/src/renderer/components/settings/gamepadLayoutPrimitives.tsx, apps/desktop/src/renderer/components/settings/gamepadSilhouetteGeom.ts, apps/desktop/src/renderer/components/settings/hookWorkspacePrefsLogic.ts, apps/desktop/src/renderer/components/settings/imBotVisibility.ts, apps/desktop/src/renderer/components/settings/imDefaultSettingsLogic.ts, apps/desktop/src/renderer/components/settings/joyConSilhouette.ts, apps/desktop/src/renderer/components/settings/modelManagementPresentation.ts, apps/desktop/src/renderer/components/settings/modelManagementState.ts, apps/desktop/src/renderer/components/settings/myDevicesModel.ts, apps/desktop/src/renderer/components/settings/providerAssetModule.ts, apps/desktop/src/renderer/components/settings/realProfilePermissionGuide.ts, apps/desktop/src/renderer/components/settings/switchProSilhouette.ts, apps/desktop/src/renderer/components/settings/ultimateC1Silhouette.ts, apps/desktop/src/renderer/components/settings/usage/UsageBreakdownTables.tsx, apps/desktop/src/renderer/components/settings/usage/UsageHistorySection.tsx, apps/desktop/src/renderer/components/settings/usage/UsageStatRow.tsx, apps/desktop/src/renderer/components/settings/usage/UsageTaskTable.tsx, apps/desktop/src/renderer/components/settings/usage/UsageTokenBars.tsx, apps/desktop/src/renderer/components/settings/usage/formatUsagePercent.ts, apps/desktop/src/renderer/components/settings/usage/usageHistoryStats.ts, apps/desktop/src/renderer/components/settings/usageVisibility.ts, apps/desktop/src/renderer/components/settings/wizardRecommend.ts, apps/desktop/src/renderer/components/settings/workLouderCodexCommandCopy.ts, apps/desktop/src/renderer/components/settings/xboxSeriesSilhouette.ts, apps/desktop/src/renderer/styles/sortable.css | 142 | 121 | 744 | +| `desktop.settings` | desktop | 设置 | hash `/settings`(SettingsView;tab 含 general / personalization / providers / billing / usage / voice-input / im-bot / shortcuts / agent-island / import / remote-control / ghosts / builtin-tools / computer-use / help / about) | SettingsView | apps/desktop/src/renderer/components/settings/AboutSection.tsx, apps/desktop/src/renderer/components/settings/AccountDeletionSection.tsx, apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx, apps/desktop/src/renderer/components/settings/AgentIslandSection.tsx, apps/desktop/src/renderer/components/settings/AgentResourceSection.tsx, apps/desktop/src/renderer/components/settings/AppearanceSection.tsx, apps/desktop/src/renderer/components/settings/AuxiliaryModelSection.tsx, apps/desktop/src/renderer/components/settings/BetaChannelCell.tsx, apps/desktop/src/renderer/components/settings/BrowserBackendSubsection.tsx, apps/desktop/src/renderer/components/settings/BrowserRealProfileSubsection.tsx, apps/desktop/src/renderer/components/settings/BuiltinToolsSection.tsx, apps/desktop/src/renderer/components/settings/ChatEmbeddingCell.tsx, apps/desktop/src/renderer/components/settings/ChipMetricsSection.tsx, apps/desktop/src/renderer/components/settings/CollaborationSection.tsx, apps/desktop/src/renderer/components/settings/CompactionSection.tsx, apps/desktop/src/renderer/components/settings/ComposerSendShortcutSection.tsx, apps/desktop/src/renderer/components/settings/ComputerPermissionGuideWindow.tsx, apps/desktop/src/renderer/components/settings/ComputerUseSection.tsx, apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx, apps/desktop/src/renderer/components/settings/CustomProviderRuntimeFillOverlay.tsx, apps/desktop/src/renderer/components/settings/DefaultOverrideControls.tsx, apps/desktop/src/renderer/components/settings/DingTalkBotSection.tsx, apps/desktop/src/renderer/components/settings/DiscordBotSection.tsx, apps/desktop/src/renderer/components/settings/DownloadMeter.tsx, apps/desktop/src/renderer/components/settings/ExperimentalSection.tsx, apps/desktop/src/renderer/components/settings/FeishuBotNotificationSection.tsx, apps/desktop/src/renderer/components/settings/FeishuBotSection.tsx, apps/desktop/src/renderer/components/settings/FontFamilyPicker.tsx, apps/desktop/src/renderer/components/settings/GenericGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/GitSafetySection.tsx, apps/desktop/src/renderer/components/settings/HelpAssistantPanel.tsx, apps/desktop/src/renderer/components/settings/HelpSection.tsx, apps/desktop/src/renderer/components/settings/HelpThreadView.tsx, apps/desktop/src/renderer/components/settings/HookConnectionsSection.tsx, apps/desktop/src/renderer/components/settings/HookWorkspacePrefsEditor.tsx, apps/desktop/src/renderer/components/settings/ImBotSection.tsx, apps/desktop/src/renderer/components/settings/ImChannelSettingsCard.tsx, apps/desktop/src/renderer/components/settings/ImDefaultSettingsSection.tsx, apps/desktop/src/renderer/components/settings/ImLifecycleAnnouncementSection.tsx, apps/desktop/src/renderer/components/settings/InputDeviceConnectionStatus.tsx, apps/desktop/src/renderer/components/settings/JoyConGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/KeyboardShortcutsSection.tsx, apps/desktop/src/renderer/components/settings/LanguageSection.tsx, apps/desktop/src/renderer/components/settings/LayoutResetControl.tsx, apps/desktop/src/renderer/components/settings/LinkOpenSection.tsx, apps/desktop/src/renderer/components/settings/LocalOllamaInstall.tsx, apps/desktop/src/renderer/components/settings/LocalPackagingTag.tsx, apps/desktop/src/renderer/components/settings/LogoutSection.tsx, apps/desktop/src/renderer/components/settings/LspBetaCell.tsx, apps/desktop/src/renderer/components/settings/McpServerDialog.tsx, apps/desktop/src/renderer/components/settings/McpServersSection.tsx, apps/desktop/src/renderer/components/settings/MemorySection.tsx, apps/desktop/src/renderer/components/settings/MessageNavRailCell.tsx, apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx, apps/desktop/src/renderer/components/settings/ModelPriceOverrideDialog.tsx, apps/desktop/src/renderer/components/settings/MyDevicesPanel.tsx, apps/desktop/src/renderer/components/settings/NotificationSection.tsx, apps/desktop/src/renderer/components/settings/OAuthDeviceCodeCard.tsx, apps/desktop/src/renderer/components/settings/OllamaProviderDetail.tsx, apps/desktop/src/renderer/components/settings/PiPackagesSection.tsx, apps/desktop/src/renderer/components/settings/PlayStationGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/ProfileEditDialog.tsx, apps/desktop/src/renderer/components/settings/ProvidersSection.tsx, apps/desktop/src/renderer/components/settings/RemoteControlSection.tsx, apps/desktop/src/renderer/components/settings/RemoteHostDetail.tsx, apps/desktop/src/renderer/components/settings/RemoteSection.tsx, apps/desktop/src/renderer/components/settings/SessionImportSection.tsx, apps/desktop/src/renderer/components/settings/SessionRuntimeFallbackCell.tsx, apps/desktop/src/renderer/components/settings/SessionShareImportWizard.tsx, apps/desktop/src/renderer/components/settings/SettingsCatalogPanel.tsx, apps/desktop/src/renderer/components/settings/SettingsSidebarNav.tsx, apps/desktop/src/renderer/components/settings/SettingsTextInput.tsx, apps/desktop/src/renderer/components/settings/SettingsView.tsx, apps/desktop/src/renderer/components/settings/SilentEncryptedRetryCell.tsx, apps/desktop/src/renderer/components/settings/SshKeySetupDialog.tsx, apps/desktop/src/renderer/components/settings/StorageManagementCard.tsx, apps/desktop/src/renderer/components/settings/StreamFadeSection.tsx, apps/desktop/src/renderer/components/settings/SubagentModelSection.tsx, apps/desktop/src/renderer/components/settings/SwitchProGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/TelegramBehaviorSettings.tsx, apps/desktop/src/renderer/components/settings/TelegramBotSection.tsx, apps/desktop/src/renderer/components/settings/TelegramRemoteDevices.tsx, apps/desktop/src/renderer/components/settings/TerminalShellSection.tsx, apps/desktop/src/renderer/components/settings/TipsSection.tsx, apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx, apps/desktop/src/renderer/components/settings/UserProfileCard.tsx, apps/desktop/src/renderer/components/settings/UserPromptSection.tsx, apps/desktop/src/renderer/components/settings/VisionBridgeSection.tsx, apps/desktop/src/renderer/components/settings/VoiceInputSection.tsx, apps/desktop/src/renderer/components/settings/WechatBotSection.tsx, apps/desktop/src/renderer/components/settings/WecomBotSection.tsx, apps/desktop/src/renderer/components/settings/WindowBehaviorSection.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexKeyboardLayout.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexKeycapGlyphs.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexSettings.tsx, apps/desktop/src/renderer/components/settings/XUsageGuide.tsx, apps/desktop/src/renderer/components/settings/XboxGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/XboxGamepadSettings.tsx, apps/desktop/src/renderer/components/settings/androidStatusPresentation.ts, apps/desktop/src/renderer/components/settings/billingVisibility.ts, apps/desktop/src/renderer/components/settings/browserOpenForLoginError.ts, apps/desktop/src/renderer/components/settings/computerPermissionFlow.ts, apps/desktop/src/renderer/components/settings/contacts/ContactDetailPane.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsImportDialog.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsListPane.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsManagerDialog.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsSection.tsx, apps/desktop/src/renderer/components/settings/contacts/startContactsAiSession.ts, apps/desktop/src/renderer/components/settings/dualSenseSilhouette.ts, apps/desktop/src/renderer/components/settings/feishuBotPresentation.ts, apps/desktop/src/renderer/components/settings/fontFamilyValue.ts, apps/desktop/src/renderer/components/settings/gamepadLayoutPrimitives.tsx, apps/desktop/src/renderer/components/settings/gamepadSilhouetteGeom.ts, apps/desktop/src/renderer/components/settings/hookWorkspacePrefsLogic.ts, apps/desktop/src/renderer/components/settings/imBotVisibility.ts, apps/desktop/src/renderer/components/settings/imDefaultSettingsLogic.ts, apps/desktop/src/renderer/components/settings/joyConSilhouette.ts, apps/desktop/src/renderer/components/settings/modelManagementPresentation.ts, apps/desktop/src/renderer/components/settings/modelManagementState.ts, apps/desktop/src/renderer/components/settings/myDevicesModel.ts, apps/desktop/src/renderer/components/settings/providerAssetModule.ts, apps/desktop/src/renderer/components/settings/realProfilePermissionGuide.ts, apps/desktop/src/renderer/components/settings/switchProSilhouette.ts, apps/desktop/src/renderer/components/settings/ultimateC1Silhouette.ts, apps/desktop/src/renderer/components/settings/usage/UsageBreakdownTables.tsx, apps/desktop/src/renderer/components/settings/usage/UsageHistorySection.tsx, apps/desktop/src/renderer/components/settings/usage/UsageStatRow.tsx, apps/desktop/src/renderer/components/settings/usage/UsageTaskTable.tsx, apps/desktop/src/renderer/components/settings/usage/UsageTokenBars.tsx, apps/desktop/src/renderer/components/settings/usage/formatUsagePercent.ts, apps/desktop/src/renderer/components/settings/usage/usageHistoryStats.ts, apps/desktop/src/renderer/components/settings/usageVisibility.ts, apps/desktop/src/renderer/components/settings/wizardRecommend.ts, apps/desktop/src/renderer/components/settings/workLouderCodexCommandCopy.ts, apps/desktop/src/renderer/components/settings/xboxSeriesSilhouette.ts, apps/desktop/src/renderer/styles/sortable.css | 142 | 121 | 733 | | `desktop.shell.main-layout` | desktop | 主窗口壳(标题栏 / 左右栏 / 内容区) | main BrowserWindow → renderer/index.tsx → main-entry.tsx → App → MainLayout (`#/` 受保护壳) | ChromeActions, FeishuConflictDialogHost, GhostMediaLightboxHost, MainLayout, RightSidebar, RightSidebarShell, SessionShareImportWizard, Sidebar, UpdateNoticeDialog, WindowControls | apps/desktop/src/renderer/cindy-brain/GhostMediaLightboxHost.tsx, apps/desktop/src/renderer/components/UpdateNoticeDialog.tsx, apps/desktop/src/renderer/components/feishuBot/FeishuConflictDialog.tsx, apps/desktop/src/renderer/components/feishuBot/FeishuConflictDialogHost.tsx, apps/desktop/src/renderer/components/layout/BrowserWebviewPool.tsx, apps/desktop/src/renderer/components/layout/ChromeActions.tsx, apps/desktop/src/renderer/components/layout/ContentHeader.tsx, apps/desktop/src/renderer/components/layout/CredentialStoreBanner.tsx, apps/desktop/src/renderer/components/layout/FadeSwitcher.tsx, apps/desktop/src/renderer/components/layout/GhostPanelWindowLayout.tsx, apps/desktop/src/renderer/components/layout/GlobalDropImportListener.tsx, apps/desktop/src/renderer/components/layout/MainLayout.tsx, apps/desktop/src/renderer/components/layout/RightSidebar.tsx, apps/desktop/src/renderer/components/layout/RightSidebarDetach.tsx, apps/desktop/src/renderer/components/layout/RightSidebarMaximize.tsx, apps/desktop/src/renderer/components/layout/RightSidebarToggle.tsx, apps/desktop/src/renderer/components/layout/SidebarWindowLayout.tsx, apps/desktop/src/renderer/components/layout/chromeActionsGeometry.ts, apps/desktop/src/renderer/components/layout/chromeActionsLayout.ts, apps/desktop/src/renderer/components/layout/railChromeActions.ts, apps/desktop/src/renderer/components/layout/windowDrag.tsx, apps/desktop/src/renderer/components/settings/SessionShareImportWizard.tsx, apps/desktop/src/renderer/components/sidebar/AccountSwitcherDialog.tsx, apps/desktop/src/renderer/components/sidebar/AttentionDot.tsx, apps/desktop/src/renderer/components/sidebar/GhostMainViewNavEntries.tsx, apps/desktop/src/renderer/components/sidebar/MobileDownloadDialog.tsx, apps/desktop/src/renderer/components/sidebar/Sidebar.tsx, apps/desktop/src/renderer/components/sidebar/SidebarIconButton.tsx, apps/desktop/src/renderer/components/sidebar/SidebarTopNav.tsx, apps/desktop/src/renderer/components/sidebar/SortableList.tsx, apps/desktop/src/renderer/components/sidebar/UpdateBanner.tsx, apps/desktop/src/renderer/components/sidebar/UserInfoSection.tsx, apps/desktop/src/renderer/components/sidebar/VendorIcon.tsx, apps/desktop/src/renderer/components/sidebar/VendorReadinessBadge.tsx, apps/desktop/src/renderer/components/sidebar/WorktreeBadge.tsx, apps/desktop/src/renderer/components/title-bar/ChromeIconButton.tsx, apps/desktop/src/renderer/components/title-bar/MenuButton.tsx, apps/desktop/src/renderer/components/title-bar/WindowControls.tsx, apps/desktop/src/renderer/features/right-sidebar/AddTabDropdown.tsx, apps/desktop/src/renderer/features/right-sidebar/EmptyState.tsx, apps/desktop/src/renderer/features/right-sidebar/RightSidebarShell.tsx, apps/desktop/src/renderer/features/right-sidebar/TabBar.tsx, apps/desktop/src/renderer/features/right-sidebar/TabBodyErrorBoundary.tsx, apps/desktop/src/renderer/features/right-sidebar/hooks/useBrowserWebview.ts, apps/desktop/src/renderer/features/right-sidebar/hooks/useNativePopupSurface.ts, apps/desktop/src/renderer/features/right-sidebar/iosSimulatorPluginAvailability.ts, apps/desktop/src/renderer/features/right-sidebar/lib/browserPartition.ts, apps/desktop/src/renderer/features/right-sidebar/lib/browserWebviewPool.ts, apps/desktop/src/renderer/features/right-sidebar/lib/detachedSidebarRouting.ts, apps/desktop/src/renderer/features/right-sidebar/lib/executeSidebarCommand.ts, apps/desktop/src/renderer/features/right-sidebar/lib/iosSimulatorFocusBridge.ts, apps/desktop/src/renderer/features/right-sidebar/lib/lastTurnChangedFiles.ts, apps/desktop/src/renderer/features/right-sidebar/lib/nativePopupTabs.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openBackgroundTasksTab.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openInSidebarBrowser.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openInSidebarFileBrowser.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openSubagentsTab.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openTerminalShortcut.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openTurnReview.ts, apps/desktop/src/renderer/features/right-sidebar/lib/popupRouter.ts, apps/desktop/src/renderer/features/right-sidebar/lib/popupTabs.ts, apps/desktop/src/renderer/features/right-sidebar/lib/rsbBrowserBridge.ts, apps/desktop/src/renderer/features/right-sidebar/lib/sidebarCommands.ts, apps/desktop/src/renderer/features/right-sidebar/lib/sidebarHostSession.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/BackgroundTasksBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/WorkflowAgentStrip.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/WorkflowProgressTree.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/chatTaskFocusIntent.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/listSessionTasks.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/workflowProgressModel.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/FileBrowserBody.css, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/FileBrowserBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/dropExternalFile.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/fileTreeImagePreview.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/useSessionScopedTreeWidth.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/index.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/IOSSimulatorInstanceGrid.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/IOSSimulatorTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/iosSimulatorH264Decoder.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/OrcaWorkersAttentionIcon.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/OrcaWorkersTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/actions.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/closeDecision.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/ResourceUsageBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/resource-usage.css, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/subscription.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/ImageDiffPreview.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/MarkdownDiffPreview.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/PlainUnifiedDiff.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/diffRows.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/highlight.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/inlineDiff.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/useDiffHighlights.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/ReviewTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/diffExpansionPreference.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/fileTree.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/gitApplyCommand.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/markdownPreview.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/useLastTurnFilter.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/useReviewGitState.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/useReviewSource.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/ConversationStream.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/DetailView.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/RunList.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/SubagentChrome.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/SubagentToolCard.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/SubagentsBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/subagentChangeFence.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/subagentConversation.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/subagentFormat.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/terminal/TerminalTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/terminal/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/terminal/lib/xtermPool.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/BrowserChrome.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/BrowserCommentPopover.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/BrowserTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/browserCommentEditorDraft.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/lib/parseOmnibox.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/useBrowserComment.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/useLocalHtmlAutoReload.ts, apps/desktop/src/renderer/features/right-sidebar/registry.ts, apps/desktop/src/renderer/features/right-sidebar/store.ts, apps/desktop/src/renderer/features/right-sidebar/types.ts, apps/desktop/src/renderer/layout/LayoutRoot.tsx, apps/desktop/src/renderer/layout/PanelDragController.tsx, apps/desktop/src/renderer/layout/PanelDragPrototype.tsx, apps/desktop/src/renderer/layout/collapsePrefs.ts, apps/desktop/src/renderer/layout/layoutDevTools.ts, apps/desktop/src/renderer/layout/panePlacement.tsx, apps/desktop/src/renderer/layout/paneWidths.tsx, apps/desktop/src/renderer/layout/panelMaximize.tsx, apps/desktop/src/renderer/styles/globals.css | 145 | 72 | 288 | | `desktop.skillhub.local` | desktop | SkillHub 本地技能 | hash `/skillhub/local` 及详情 `/skillhub/local/:kind/global/:name`、`/skillhub/local/:kind/project/:projectHash/:name` | InstallTargetPicker, PluginManagementLayout, SkillhubDetailView, SkillhubFeatureLayout, SkillhubHomeView, SkillhubMarketPreviewPanel | apps/desktop/src/renderer/features/plugin/PluginManagementLayout.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubDetailView.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubFeatureLayout.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubMarketPreviewPanel.tsx, apps/desktop/src/renderer/features/skillhub/components/InstallTargetPicker.tsx | 41 | 0 | 71 | | `desktop.skillhub.market` | desktop | SkillHub 市场 | hash `/skillhub/market`(SkillhubMarketListView) | InstallTargetPicker, MarketCard, MarketInfoEditDialog, SkillhubMarketListView, SkillhubMarketPreviewPanel, VisibilityEditorDialog | apps/desktop/src/renderer/features/skillhub/SkillhubMarketListView.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubMarketPreviewPanel.tsx, apps/desktop/src/renderer/features/skillhub/components/InstallTargetPicker.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketCard.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketInfoEditDialog.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketPreviewTree.tsx, apps/desktop/src/renderer/features/skillhub/components/PlatformTagSelector.tsx, apps/desktop/src/renderer/features/skillhub/components/SkillCategoryFilterBar.tsx, apps/desktop/src/renderer/features/skillhub/components/SkillIcon.tsx, apps/desktop/src/renderer/features/skillhub/components/SkillTagList.tsx, apps/desktop/src/renderer/features/skillhub/components/TeamScopePicker.tsx, apps/desktop/src/renderer/features/skillhub/components/VisibilityEditorDialog.tsx | 46 | 0 | 59 | @@ -138,7 +138,7 @@ Mobile 尚未展开顶层 screen,**待 DS-7 增量发现**;数值接管在 D | `desktop.overlay.toast` | unassigned | legacy | — | DS-8 提供适用数值;DS-9 按入口核对标准组件与呈现继承 | DS-9 核对实际消费者、局部 alias 与跨入口影响;保留现有保护合同,残余项登记理由、实际负责人及复查日期 | | `desktop.plugins.app-main` | unassigned | legacy | — | DS-8 提供适用数值;DS-9 按入口核对标准组件与呈现继承 | DS-9 核对实际消费者、局部 alias 与跨入口影响;保留现有保护合同,残余项登记理由、实际负责人及复查日期 | | `desktop.plugins.installed` | unassigned | legacy | — | DS-8 提供适用数值;DS-9 按入口核对标准组件与呈现继承 | DS-9 核对实际消费者、局部 alias 与跨入口影响;保留现有保护合同,残余项登记理由、实际负责人及复查日期 | -| `desktop.settings` | unassigned | pilot | DESIGN.md §10 语义豁免色族消费者;外部主题导入保护 token(资源用量类别色在独立窗) | DS-4 / DS-4b 的 Button / Input 与 SettingsTextInput 局部兼容壳已落地;DS-6 完整表单和第二消费者,DS-8 数值同源 | DS-6 补全状态、说明与公开附件;focus、ivory/elevated 见治理 §10。旧 alias 与 slot 按作用域生效,禁止仅因同值删除局部配色;DS-6/8/9/11 按实际消费者分批核对跨 surface alias,confirm-dialog 归 DS-6 | +| `desktop.settings` | unassigned | pilot | DESIGN.md §10 语义豁免色族消费者;外部主题导入保护 token(资源用量类别色在独立窗) | DS-4 / DS-4b 的 Button / Input 与 SettingsTextInput 局部兼容壳已落地;DS-6 完整表单和第二消费者,DS-8 数值同源 | DS-6 已接入两个真实表单与指定普通确认,保留 pilot;本批相关测试与双模式代表 Settings/组件验证通过,原生平台待项/G2/公开附件仍待验收,负责人 kirozeng,2026-09-10 或 DS-7 开工前复查;D1—D5 已批准,见治理 §10 和 2026-09-08 证据。旧 alias 与 slot 按作用域生效,禁止仅因同值删除局部配色;DS-6/8/9/11 按实际消费者分批核对跨 surface alias,confirm-dialog 归 DS-6 | | `desktop.shell.main-layout` | unassigned | legacy | DESIGN.md §15 CINDY 皮肤族(侧栏 vibrancy / 选中 pill);外部主题导入保护 token | DS-8 提供适用数值;DS-9 按入口核对标准组件与呈现继承 | DS-9 核对实际消费者、局部 alias 与跨入口影响;保留现有保护合同,残余项登记理由、实际负责人及复查日期 | | `desktop.skillhub.local` | unassigned | legacy | — | DS-8 提供适用数值;DS-9 按入口核对标准组件与呈现继承 | DS-9 核对实际消费者、局部 alias 与跨入口影响;保留现有保护合同,残余项登记理由、实际负责人及复查日期 | | `desktop.skillhub.market` | unassigned | legacy | — | DS-8 提供适用数值;DS-9 按入口核对标准组件与呈现继承 | DS-9 核对实际消费者、局部 alias 与跨入口影响;保留现有保护合同,残余项登记理由、实际负责人及复查日期 | From ecafd62a6216ed512787017528d1a38bd184dcac Mon Sep 17 00:00:00 2001 From: Kirozeng Date: Tue, 8 Sep 2026 23:20:44 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix(desktop):=20=E8=A1=A8=E5=8D=95=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E5=86=85=E7=9A=84=20Tip=20=E6=8F=90=E5=8D=87=E5=88=B0?= =?UTF-8?q?=E6=A8=A1=E6=80=81=E5=B1=82=E4=B9=8B=E4=B8=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review P2:CustomProviderDialog / McpServerDialog 的密码显隐按钮与 行删除按钮的 Tip 经 Portal 渲染,内容层默认 z-[60] 低于手写模态的 z-[10000],悬停/聚焦时提示被宿主弹窗盖住。 - Input 为 secret 眼睛按钮新增 secretTipContentClassName 透传,两张 表单的密钥/Token 输入传 z-[10001] - 行删除 Tip 传 contentClassName="z-[10001]",沿用仓内既有惯例 (SessionShareExportDialog / AccountSwitcherDialog 等) - DESIGN.md Dialog & Modal 段回写规则,DS-6 证据文件补记录 - 回归测试:两张表单悬停触发后断言可见 tooltip 层含 z-[10001] Signed-off-by: Kirozeng --- .../settings/CustomProviderDialog.tsx | 11 ++++-- .../components/settings/McpServerDialog.tsx | 6 +++- ...CustomProviderDialogAccessibility.test.tsx | 35 +++++++++++++++++++ .../__tests__/McpServerDialog.test.tsx | 26 ++++++++++++++ .../src/renderer/components/ui/input.tsx | 12 ++++++- docs/design-evidence/2026-09-08/ds6-forms.md | 6 ++++ docs/design-rules/DESIGN.md | 1 + 7 files changed, 93 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx b/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx index 1418a9ae005..818415cc24a 100644 --- a/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx +++ b/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx @@ -2567,6 +2567,7 @@ export function CustomProviderDialog({ placeholder={keyPlaceholder} mono secret + secretTipContentClassName="z-[10001]" /> )} @@ -2748,7 +2749,10 @@ export function CustomProviderDialog({ )}
- + - ))} + {/* + transport 是紧凑互斥设置,走共享分段控件(DESIGN.md §4 Settings + segmented controls):单一 Tab 停靠点、方向键 / Home / End 与 RTL + 键盘行为由控件自带,不再用独立 Button 自造第二套选中态(review P2)。 + */} + ({ + value: tp, + label: {tp}, + }))} + />
diff --git a/apps/desktop/src/renderer/components/settings/__tests__/McpServerDialog.test.tsx b/apps/desktop/src/renderer/components/settings/__tests__/McpServerDialog.test.tsx index 734fa4791d1..e2b925fe015 100644 --- a/apps/desktop/src/renderer/components/settings/__tests__/McpServerDialog.test.tsx +++ b/apps/desktop/src/renderer/components/settings/__tests__/McpServerDialog.test.tsx @@ -60,7 +60,9 @@ it('preserves create ID, transport, trimmed headers and raw token arguments', as change('name', ' Example '); change('url', ' https://example.test/mcp '); change('token', ' token '); - fireEvent.click(screen.getByRole('button', { name: 'sse' })); + // transport 走共享分段控件(review P2):role=radio 的 radiogroup,不再是独立 button。 + expect(screen.getByRole('radiogroup', { name: 'settings.mcp.fields.transport' })).toBeTruthy(); + fireEvent.click(screen.getByRole('radio', { name: 'sse' })); fireEvent.change(screen.getByPlaceholderText('settings.mcp.fields.headerNamePlaceholder'), { target: { value: ' X-Test ' }, }); diff --git a/docs/design-rules/cindy-design-system.md b/docs/design-rules/cindy-design-system.md index ac8a6204102..79590a5ef9d 100644 --- a/docs/design-rules/cindy-design-system.md +++ b/docs/design-rules/cindy-design-system.md @@ -29,7 +29,7 @@ DS-6 表单贡献入口:先读 [DESIGN §4](./DESIGN.md#inputs--forms),在 1. 先读 [DESIGN.md](./DESIGN.md) 的适用视觉/组件规则,再读 [治理合同](./design-governance.md) §4 兼容、§6 证据、§7/8 风险与门禁;当前顺序及目标验收见 §12。 2. 在 [inventory](./design-inventory.md) 找实际入口、保护合同与人工下一动作;没认领的 owner 仍是 unassigned,按实际工作认领,不能把共享组件已被引用当成整页迁移完成。 -3. 复用现有 [Button](../../apps/desktop/src/renderer/components/ui/button.tsx)、[Input / Textarea](../../apps/desktop/src/renderer/components/ui/input.tsx);设置旧局部覆盖使用 [SettingsTextInput](../../apps/desktop/src/renderer/components/settings/SettingsTextInput.tsx)。FormField 与 Button loading API **尚未提供**,DS-6 随真实表单需求建立并补使用说明。 +3. 复用现有 [Button](../../apps/desktop/src/renderer/components/ui/button.tsx)、[Input / Textarea](../../apps/desktop/src/renderer/components/ui/input.tsx);设置旧局部覆盖使用 [SettingsTextInput](../../apps/desktop/src/renderer/components/settings/SettingsTextInput.tsx)。表单字段的 label / hint / 错误组合用 [FormField](../../apps/desktop/src/renderer/components/ui/form-field.tsx),保存期间的防重复反馈用 Button 的 loading 状态(均已随 DS-6 提供,用法见上方「DS-6 表单贡献入口」)。 4. 需要改设计值时读 [Token README](../../packages/design-tokens/README.md):当前影子层不被产品消费;双端语义样本与未来生成合同都在该处。Desktop / Mobile 分别在 DS-8 / DS-10 接管;新观感先查治理 §10 待决项,不因数值相同而删除局部主题覆盖。 以上仓内入口即可开始贡献;无需访问个人桌面记录。此阅读路径检查不代替 G2 的独立贡献者试用。 From 751a36a647b2b88449b7369394bd49cb3f57964f Mon Sep 17 00:00:00 2001 From: Kirozeng Date: Wed, 9 Sep 2026 12:56:07 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(desktop):=20=E5=AD=97=E6=AE=B5=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E5=8F=AA=E5=9C=A8=E6=8A=A5=E9=94=99=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E8=87=AA=E8=BA=AB=E8=A2=AB=E7=BC=96=E8=BE=91=E6=97=B6=E6=B8=85?= =?UTF-8?q?=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review P2:面板级 onChangeCapture 原先在任意后代输入变化时清空唯一的 fieldError——Base URL 无效时改名称或 API Key 会立即消失 URL 的错误 提示与 aria-invalid,尽管 URL 未修正。现仅在 event.target 即报错字段 本身时清除;其它字段的编辑保留提示直至再次保存重新校验。回归测试 覆盖「改其它字段错误保留 / 改报错字段错误清除」两分支。 Signed-off-by: Kirozeng --- .../settings/CustomProviderDialog.tsx | 10 ++++++- ...CustomProviderDialogAccessibility.test.tsx | 30 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx b/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx index 0fb1002bc93..3bdf5c54bc3 100644 --- a/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx +++ b/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx @@ -2214,7 +2214,15 @@ export function CustomProviderDialog({ aria-modal="true" aria-labelledby="custom-provider-dialog-title" tabIndex={-1} - onChangeCapture={() => setFieldError(null)} + onChangeCapture={(event) => { + // 只在报错字段自身被编辑时清除:改其它字段(名称/密钥/别的 runtime 行) + // 不应清掉当前字段的错误提示与 aria-invalid——URL 未修正时提示必须 + // 保留到再次保存重新校验(review P2)。 + const target = event.target; + if (fieldError && target instanceof HTMLElement && target.id === fieldError.id) { + setFieldError(null); + } + }} className={cn( 'flex max-h-[88vh] w-[min(600px,calc(100vw-32px))] flex-col rounded-xl outline-none', 'border border-[var(--border-default)] bg-[var(--surface-elevated)]', diff --git a/apps/desktop/src/renderer/components/settings/__tests__/CustomProviderDialogAccessibility.test.tsx b/apps/desktop/src/renderer/components/settings/__tests__/CustomProviderDialogAccessibility.test.tsx index 31ab21cc932..a3261d54cac 100644 --- a/apps/desktop/src/renderer/components/settings/__tests__/CustomProviderDialogAccessibility.test.tsx +++ b/apps/desktop/src/renderer/components/settings/__tests__/CustomProviderDialogAccessibility.test.tsx @@ -1464,4 +1464,34 @@ describe('DS-6 field errors and save ownership', () => { expect(removeTip.textContent).toBe('settings.providers.custom.fields.removeRow'); expect(removeTip.parentElement!.className).toContain('z-[10001]'); }); + + it('keeps the field error while other fields change and clears it when the errored field is edited', async () => { + render(); + await waitForInitialDialogFocus(); + const name = screen.getByLabelText('settings.providers.custom.fields.name'); + const baseUrl = screen.getByLabelText('settings.providers.custom.fields.baseUrl'); + fireEvent.change(name, { target: { value: 'X' } }); + fireEvent.change(baseUrl, { target: { value: 'not-a-url' } }); + fireEvent.click(screen.getByRole('button', { name: 'settings.providers.custom.save' })); + + // 非法 URL 报错落在 baseUrl,首错聚焦并带 aria-invalid。 + expect(document.activeElement).toBe(baseUrl); + expect(baseUrl.getAttribute('aria-invalid')).toBe('true'); + expect( + document.getElementById(baseUrl.getAttribute('aria-describedby')!)?.textContent, + ).toBe('settings.providers.custom.errors.baseUrlInvalid'); + + // 编辑其它字段(name)不得清掉 baseUrl 的错误——面板级 onChangeCapture 只在 + // 报错字段自身被编辑时清除(review P2)。 + fireEvent.change(name, { target: { value: 'XY' } }); + expect(baseUrl.getAttribute('aria-invalid')).toBe('true'); + expect( + document.getElementById(baseUrl.getAttribute('aria-describedby')!)?.textContent, + ).toBe('settings.providers.custom.errors.baseUrlInvalid'); + + // 编辑报错字段本身:错误清除,等下次保存重新校验。 + fireEvent.change(baseUrl, { target: { value: 'https://example.test/v1' } }); + expect(baseUrl.getAttribute('aria-invalid')).not.toBe('true'); + expect(customProviderMocks.createCustomProvider).not.toHaveBeenCalled(); + }); }); From 62472f559c8e2d729b93a6546bd500e86d8989a5 Mon Sep 17 00:00:00 2001 From: Kirozeng Date: Wed, 9 Sep 2026 14:25:54 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(desktop):=20=E9=9D=9E=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E4=BF=AE=E6=AD=A3=E5=AD=97=E6=AE=B5=E6=97=B6?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=B8=85=E9=99=A4=E8=BF=87=E6=9C=9F=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review P1:上一轮把错误清除收敛到「报错元素自身的 change」后,两个 非输入控件路径会让错误滞留到再次保存: - 列表级错误(`${agent}:add-model`,模型列表为空,提示挂在「添加模型」 按钮旁):点该按钮新增行并填写内容时,change 目标是新行输入而非按钮, 提示不清。现在该 agent 模型行的输入变化同样视为对列表错误的修正。 - applyPreset 程序化替换名称/鉴权/全部 runtime:不触发任何输入 change, 既有错误指向整体失效。函数内显式清除。 回归测试 ×2:删光模型行保存→重添行填写即清;应用预设即清并核对 名称已被程序化替换。 Signed-off-by: Kirozeng --- .../settings/CustomProviderDialog.tsx | 26 +++++-- ...CustomProviderDialogAccessibility.test.tsx | 69 +++++++++++++++++++ 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx b/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx index 3bdf5c54bc3..b1fa43dd8ba 100644 --- a/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx +++ b/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx @@ -1058,6 +1058,10 @@ export function CustomProviderDialog({ setWindowDrafts({}); const first = configuredPresetAgents(p)[0]; if (first) setActiveTab(first); + // 预设整体替换名称/鉴权/全部 runtime:任何既有字段错误的指向(字段值、 + // 行结构、tab)都已失效。程序化赋值不触发输入的 change,须在此显式清除 + // (review P1)。 + setFieldError(null); }, [i18n.language, setRtSynced], ); @@ -2215,11 +2219,25 @@ export function CustomProviderDialog({ aria-labelledby="custom-provider-dialog-title" tabIndex={-1} onChangeCapture={(event) => { - // 只在报错字段自身被编辑时清除:改其它字段(名称/密钥/别的 runtime 行) - // 不应清掉当前字段的错误提示与 aria-invalid——URL 未修正时提示必须 - // 保留到再次保存重新校验(review P2)。 + // 错误清除粒度(review P2/P1 双向约束): + // - 报错字段自身被编辑时清除——改其它字段(名称/密钥/别的 runtime 行) + // 不得清掉当前字段的错误提示与 aria-invalid,保留到再次保存重新校验; + // - 例外是列表级错误(`${agent}:add-model`,模型列表为空,提示挂在 + // 「添加模型」按钮旁、不依赖任何行存在):用户点该按钮新增行并填写 + // 内容时,change 目标是新行输入而非按钮本身,这条填空路径正是对 + // 列表错误的修正,须同步清除,否则提示要滞留到再次保存。 const target = event.target; - if (fieldError && target instanceof HTMLElement && target.id === fieldError.id) { + if (!(target instanceof HTMLElement) || !fieldError) return; + if (target.id === fieldError.id) { + setFieldError(null); + return; + } + const key = fieldError.id.slice(formId.length + 1); + const agent = key.slice(0, key.indexOf(':')); + if ( + key === `${agent}:add-model` && + target.id.startsWith(`${formId}-${agent}:model:`) + ) { setFieldError(null); } }} diff --git a/apps/desktop/src/renderer/components/settings/__tests__/CustomProviderDialogAccessibility.test.tsx b/apps/desktop/src/renderer/components/settings/__tests__/CustomProviderDialogAccessibility.test.tsx index a3261d54cac..f4bb84c0de0 100644 --- a/apps/desktop/src/renderer/components/settings/__tests__/CustomProviderDialogAccessibility.test.tsx +++ b/apps/desktop/src/renderer/components/settings/__tests__/CustomProviderDialogAccessibility.test.tsx @@ -1494,4 +1494,73 @@ describe('DS-6 field errors and save ownership', () => { expect(baseUrl.getAttribute('aria-invalid')).not.toBe('true'); expect(customProviderMocks.createCustomProvider).not.toHaveBeenCalled(); }); + + it('clears the list-level model error when the new model row is filled after adding it back', async () => { + render(); + await waitForInitialDialogFocus(); + // 名称 / baseUrl 合法,删掉仅有的空模型行 → 保存报列表级错误(渲染在「添加模型」旁, + // 不依赖任何行存在)。 + fireEvent.change(screen.getByLabelText('settings.providers.custom.fields.name'), { + target: { value: 'X' }, + }); + fireEvent.change(screen.getByLabelText('settings.providers.custom.fields.baseUrl'), { + target: { value: 'https://example.test/v1' }, + }); + fireEvent.click( + screen.getAllByRole('button', { + name: 'settings.providers.custom.fields.removeRow', + })[0]!, + ); + fireEvent.click(screen.getByRole('button', { name: 'settings.providers.custom.save' })); + expect(screen.getByText('settings.providers.custom.errors.modelRequired')).toBeTruthy(); + + // 重新添加模型并填写:新行输入的 change 就是对列表级错误的修正,提示须同步清除 + // ——否则会滞留到再次保存(review P1)。 + fireEvent.click( + screen.getByRole('button', { name: 'settings.providers.custom.fields.addModel' }), + ); + fireEvent.change( + screen.getByPlaceholderText('settings.providers.custom.fields.modelIdPlaceholder'), + { target: { value: 'm1' } }, + ); + expect(screen.queryByText('settings.providers.custom.errors.modelRequired')).toBeNull(); + expect(customProviderMocks.createCustomProvider).not.toHaveBeenCalled(); + }); + + it('clears any field error when a preset programmatically replaces the form values', async () => { + window.electronAPI.maker.listProviderPresets = vi.fn(async () => ({ + presets: [ + { + id: 'preset-a', + name: 'Preset A', + runtimes: { + 'claude-code': { + baseUrl: 'https://preset.example.test/v1', + models: [{ id: 'pm-1', name: 'PM 1' }], + }, + }, + }, + ], + })); + render(); + await waitForInitialDialogFocus(); + // 保存空表单 → 名称必填报错。 + fireEvent.click(screen.getByRole('button', { name: 'settings.providers.custom.save' })); + expect(screen.getByText('settings.providers.custom.errors.nameRequired')).toBeTruthy(); + + // 应用预设:程序化替换名称/鉴权/全部 runtime,不触发任何输入的 change—— + // 既有字段错误的指向已整体失效,须同步清除(review P1)。 + fireEvent.click( + screen.getByRole('button', { name: 'settings.providers.custom.presets.label' }), + ); + fireEvent.click(screen.getByRole('option', { name: 'Preset A' })); + await waitFor(() => + expect(screen.queryByText('settings.providers.custom.errors.nameRequired')).toBeNull(), + ); + expect( + (screen.getByLabelText('settings.providers.custom.fields.name') as HTMLInputElement) + .value, + ).toBe('Preset A'); + expect(customProviderMocks.createCustomProvider).not.toHaveBeenCalled(); + }); });