Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';

import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';
import { toast } from '@/lib/toast';
import { DefaultOverrideControls } from './DefaultOverrideControls';
import { SettingsTextInput } from './SettingsTextInput';

type Wire = AgentResourceSettingsWire;
type SettingKey = 'maxConcurrentCommands' | 'processPriority' | 'capToolchainThreads';
Expand All @@ -41,7 +41,7 @@ const ROW_HINT_CLASS =
/** 行间分割线:左右缩进与行内边距对齐。 */
const DIVIDER_CLASS = 'mx-4 h-px bg-[var(--settings-theme-card-border)]';

/** 数字输入走标准 Input md 档(32/36/40 中的 36px),原生步进器不自绘。 */
/** 数字输入复用标准 Input md 档,设置封装保留旧局部主题覆盖,原生步进器不自绘。 */

/**
* 均衡档并发值:本机核数的一半,至少 2(与 main 侧 toolchain-thread-cap 的口径一致),
Expand Down Expand Up @@ -298,7 +298,7 @@ export function AgentResourceSection() {
{t('settings.agentResource.maxConcurrentHint')}
</span>
</span>
<Input
<SettingsTextInput
type="number"
min={0}
max={MAX_CONCURRENT_CAP}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import { toast } from '@/lib/toast';
import { DefaultOverrideControls } from './DefaultOverrideControls';
import { SettingsTextInput } from './SettingsTextInput';

/** 带分割线的多行卡片:卡片自身不留内边距,由每行 `px-4 py-4` 承担(房规见 SubagentModelSection 卡 2)。 */
const CARD_CLASS = cn(
Expand All @@ -23,7 +23,7 @@ const ROW_HINT_CLASS =
/** 行间分割线:左右缩进与行内边距对齐。 */
const DIVIDER_CLASS = 'mx-4 h-px bg-[var(--settings-theme-card-border)]';

/** 数字输入走标准 Input md 档(32/36/40 中的 36px),原生步进器不自绘。 */
/** 数字输入复用标准 Input md 档,设置封装保留旧局部主题覆盖,原生步进器不自绘。 */

interface CollaborationSettings {
workerSoftLimit: number;
Expand Down Expand Up @@ -104,7 +104,7 @@ export function CollaborationSection() {
{t('settings.collaboration.workerSoftLimitHint')}
</span>
</span>
<Input
<SettingsTextInput
type="number"
min={1}
max={settings.workerHardLimit}
Expand All @@ -130,7 +130,7 @@ export function CollaborationSection() {
{t('settings.collaboration.workerHardLimitHint')}
</span>
</span>
<Input
<SettingsTextInput
type="number"
min={settings.workerSoftLimit}
max={20}
Expand Down Expand Up @@ -159,7 +159,7 @@ export function CollaborationSection() {
{t('settings.collaboration.idleReleaseHint')}
</span>
</span>
<Input
<SettingsTextInput
type="number"
min={0}
max={120}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
/**
* SettingsTextInput —— `components/ui/input` 的设置页薄封装。
*
* DS-4 把实现升格进 `components/ui/input.tsx`。本文件保留原导出名与类型别名,
* 让既有设置页调用点零行为改动(除 ivory 登记债与 placeholder 已收口到
* `--text-placeholder`)。新代码请直接 import `{ Input }` from `@/components/ui/input`。
* 复用标准 Input 的尺寸、状态和行为,同时保留旧 settings-input-* 的局部主题合同。
* alias 无覆盖时继续跟随 Tier-1;显式覆盖只影响既有设置输入,不提升成全局配色。
* placeholder 沿用既有加载期归一化:缺新 slot 的旧主题先归一化,再解析 alias。
* 新建通用界面直接使用 ui/input;迁移已有 settings 域输入时保留此封装。
*/
export { Input as SettingsTextInput } from '@/components/ui/input';
import { Input, type InputProps } from '@/components/ui/input';
import { cn } from '@/lib/utils';

const LEGACY_INPUT_CHROME =
'text-[var(--settings-input-text)] placeholder:text-[var(--settings-input-placeholder)] border-[var(--settings-input-border)] focus:border-[var(--settings-input-border-focus)]';

export function SettingsTextInput({ inputClassName, ...props }: InputProps) {
return <Input {...props} inputClassName={cn(LEGACY_INPUT_CHROME, inputClassName)} />;
}

export type {
InputSize as SettingsTextInputSize,
InputSurface as SettingsTextInputSurface,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ describe('AgentResourceSection', () => {
expect(visiblePresetHint()).toBe('settings.agentResource.presetHints.full');
const numberInput = screen.getByRole('spinbutton') as HTMLInputElement;
expect(numberInput.value).toBe('0');
// DS-4 前数字框消费过这些局部主题键;迁标准件不能丢掉用户覆盖。
expect(numberInput.className).toContain('text-[var(--settings-input-text)]');
expect(numberInput.className).toContain('border-[var(--settings-input-border)]');
expect(numberInput.className).toContain('focus:border-[var(--settings-input-border-focus)]');
expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('false');
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ describe('CollaborationSection', () => {
expect(screen.getByText('settings.collaboration.title')).toBeTruthy();
});
expect(limitInputs().map((i) => i.value)).toEqual(['5', '8', '0']);
for (const input of limitInputs()) {
expect(input.className).toContain('text-[var(--settings-input-text)]');
expect(input.className).toContain('border-[var(--settings-input-border)]');
expect(input.className).toContain('focus:border-[var(--settings-input-border-focus)]');
}
});

it('writes the soft limit on change without waiting for blur', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ async function renderImageGenerationHelp() {
const user = userEvent.setup();
render(<CustomProviderDialog initial={initial} onSaved={vi.fn()} onClose={vi.fn()} />);
await waitFor(() => expect(customProviderMocks.readCustomProviderKey).toHaveBeenCalled());
// Let the dialog's initial focus settle before testing the help popover's focus behavior.
await waitForInitialDialogFocus();
const advanced = screen.getByRole('button', {
name: 'settings.providers.custom.fields.runtimeAdvanced',
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// @vitest-environment jsdom

import { cleanup, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

const sandbox = vi.hoisted(() => ({ home: '' }));
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>();
return { ...actual, default: { ...actual, homedir: () => sandbox.home }, homedir: () => sandbox.home };
});
vi.mock('../../../../main/logger.js', () => ({
createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn() }),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));

import { loadLocalThemesSync, resetLocalThemesMigrationForTest } from '../../../../main/local-themes/loader';
import '../../../themes/colors';
import { bootstrapLocalThemesSync, getLocalThemes } from '../../../themes/local-themes';
import { exportThemeColors, resolveThemeValue } from '../../../themes/theme-service';
import { builtinThemes } from '../../../themes/registry';
import type { Theme, ThemeType } from '../../../themes/types';
import { SettingsTextInput } from '../../settings/SettingsTextInput';
import { Input } from '../input';

const BINDINGS = [
['text-', 'text-primary', 'settings-input-text'],
['border-', 'border-default', 'settings-input-border'],
['focus:border-', 'text-tertiary-stone', 'settings-input-border-focus'],
['placeholder:text-', 'text-placeholder', 'settings-input-placeholder'],
] as const;

// 复用既有内置色板作为彼此不同的全局/局部覆盖,不另维护测试色值表。
const SEMANTIC = Object.fromEntries(BINDINGS.map(([, semantic]) => [
semantic, resolved(builtinThemes['default-light'], semantic),
]));
const LEGACY = Object.fromEntries(BINDINGS.map(([, semantic, alias]) => [
alias, resolved(builtinThemes['default-dark'], semantic),
]));

// jsdom 不负责 Tailwind/CSS 变量计算。此处从真实 React 控件读取消费表达式,
// 通过生产 registry 解析到值,补齐“文件还在但控件不再消费”的回归边界。
// 浏览器最终 computed style 另由真实 Desktop 验证,不能把此测试冒充实机证据。
function resolved(theme: Theme, id: string, depth = 0): string {
if (depth > 12) throw new Error(`Unexpected alias cycle: ${id}`);
const value = resolveThemeValue(theme, id);
if (value === null) throw new Error(`Missing color: ${id}`);
const alias = /^var\(--([\w-]+)\)$/.exec(value);
return alias ? resolved(theme, alias[1], depth + 1) : value;
}

function consumed(field: HTMLInputElement, prefix: string, theme: Theme): string {
const token = [...field.classList].find((item) => item.startsWith(`${prefix}[var(--`));
const id = token?.match(/var\(--([\w-]+)\)/)?.[1];
if (!id) throw new Error(`Missing ${prefix} color binding: ${field.className}`);
return resolved(theme, id);
}

function loadTheme(type: ThemeType, colors: Record<string, string>): Theme {
const dir = path.join(sandbox.home, '.cindy', 'themes');
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, 'input-compat.json');
const bytes = `${JSON.stringify({ id: 'input-compat', name: 'Input compatibility fixture', type, colors }, null, 2)}\n`;
fs.writeFileSync(file, bytes);
const payload = loadLocalThemesSync();
expect(payload.success).toBe(true);
vi.stubGlobal('electronAPI', { localThemes: { listSync: () => payload } });
bootstrapLocalThemesSync();
const first = getLocalThemes().find((theme) => theme.id === 'input-compat-local');
expect(first).toBeDefined();
bootstrapLocalThemesSync();
expect(getLocalThemes().find((theme) => theme.id === first!.id)).toEqual(first);
expect(fs.readFileSync(file, 'utf8')).toBe(bytes);
return first!;
}

beforeEach(() => {
sandbox.home = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ds4-input-compat-')));
resetLocalThemesMigrationForTest();
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
fs.rmSync(sandbox.home, { recursive: true, force: true });
});

describe.each(['light', 'dark'] as const)('旧设置输入主题消费:%s', (type) => {
it.each([
['仅全局覆盖', SEMANTIC],
['旧局部覆盖与全局覆盖并存', { ...SEMANTIC, ...LEGACY }],
['旧文件缺新 placeholder slot', LEGACY],
['完整本地副本含局部覆盖', { ...exportThemeColors(builtinThemes[`default-${type}`]), ...SEMANTIC, ...LEGACY }],
] as const)('%s:旧设置输入保留局部作用域,通用 Input 保持语义默认', (_name, colors) => {
const original = JSON.stringify(colors);
const theme = loadTheme(type, colors);
const { container } = render(<>
<SettingsTextInput value="legacy" onChange={() => {}} />
<Input value="general" onChange={() => {}} />
</>);
const [legacy, general] = container.querySelectorAll('input');
for (const [prefix, semantic, alias] of BINDINGS) {
expect(consumed(legacy, prefix, theme), `${prefix} legacy input`).toBe(resolved(theme, alias));
expect(consumed(general, prefix, theme), `${prefix} general input`).toBe(resolved(theme, semantic));
}
expect(JSON.stringify(colors)).toBe(original);
});
});

it('所有内置主题未做局部覆盖时,旧设置封装与标准 Input 逐值相同', () => {
const { container } = render(<>
<SettingsTextInput value="legacy" onChange={() => {}} />
<Input value="general" onChange={() => {}} />
</>);
const [legacy, general] = container.querySelectorAll('input');
for (const theme of Object.values(builtinThemes)) {
for (const [prefix] of BINDINGS) {
expect(consumed(legacy, prefix, theme), `${theme.id} ${prefix}`).toBe(consumed(general, prefix, theme));
}
}
});

it('局部主题兼容不能盖掉标准错误态', () => {
const theme = loadTheme('light', { ...SEMANTIC, ...LEGACY });
const { container } = render(<SettingsTextInput value="invalid" onChange={() => {}} error />);
const field = container.querySelector('input')!;
expect(consumed(field, 'border-', theme)).toBe(resolved(theme, 'error-border'));
expect(consumed(field, 'focus:border-', theme)).toBe(resolved(theme, 'error-fg'));
expect(consumed(field, 'text-', theme)).toBe(LEGACY['settings-input-text']);
});
10 changes: 7 additions & 3 deletions apps/desktop/src/renderer/components/ui/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,12 @@ const EYE_STYLES: Record<InputSize, { iconSize: number; offset: string }> = {
};

/**
* 边框 / 文字 / placeholder 一律绑 Tier-1 slot,不继承 `--settings-input-*` 域 alias
* 边框 / 文字 / placeholder 默认绑 Tier-1 slot,不继承 `--settings-input-*` 域 alias
* (与 G5 对 button/secondary 的同一条判据:primitive 不继承域 alias,防设置页私有
* 决定泄漏成全局默认)。这几个 alias 的默认值本就 forward-resolve 到同一批 slot,
* 11 个内置主题与外部主题导入 allowlist 均未 override 它们,故默认外观逐值不变;
* 变的是 override 面 —— 手写过 `settings-input-*` 的用户本地主题不再作用于本组件。
* `--settings-input-border-focus` 亦同(其默认值 = `--text-tertiary-stone`)
* 既有设置输入的局部主题合同由 SettingsTextInput 经 inputClassName 保留,
* 不把 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)]';
Expand Down Expand Up @@ -107,6 +107,8 @@ export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>,
error?: boolean;
/** 附加到**外层容器**(内层 input 恒为 w-full),供 flex 行传 `flex-1 min-w-0`。 */
className?: string;
/** 内层控件的样式扩展;供既有域封装保留局部主题合同,错误态仍优先。 */
inputClassName?: string;
}

export function Input({
Expand All @@ -122,6 +124,7 @@ export function Input({
trailing,
error = false,
className,
inputClassName,
disabled,
autoComplete,
spellCheck,
Expand Down Expand Up @@ -178,6 +181,7 @@ export function Input({
mono && 'font-mono',
FIELD_CHROME,
SURFACE_STYLES[surface],
inputClassName,
error && ERROR_CHROME,
disabled && 'cursor-not-allowed opacity-60',
)}
Expand Down
47 changes: 47 additions & 0 deletions docs/design-evidence/2026-09-06/input-theme-compat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# DS-4 旧设置输入主题兼容验证

日期:2026-09-06。平台:Desktop / macOS arm64。基点:`5fc5881101753aba47922d089f3b3246e9b9ffce`,加本批次未提交修复。未使用真实用户主题、凭证或数据库。

本批恢复此前消费 settings 域 alias 的输入框,不改变通用 Input 的 Tier-1 默认。原 SettingsTextInput 消费者与 AgentResource / Collaboration 的四个数字框共用同一标准组件;旧局部覆盖仅在设置封装内生效。

**自动验证**

- `inputThemeCompatibility.test.tsx`:真实临时主题文件 → main loader → renderer bootstrap / normalize → 实际 React 控件的 Token 消费表达式 → 生产 registry 解析。覆盖 Light/Dark、局部/全局覆盖、旧 placeholder 归一化、完整主题副本、重复加载和磁盘字节不变。
- 所有内置主题没有局部覆盖时,标准 Input 与 SettingsTextInput 四项颜色逐值相同。
- 错误态边框与 focus 环优先于局部兼容样式。四个数字框的现有渲染测试保留交互验证并增加局部 alias 消费断言。
- jsdom 测试不声称验证 Tailwind 最终 CSS;浏览器测量见下。

**真实 Desktop 构建内的组件对照**

使用独立 dev 沙箱,包装启动与 `desktop-whoami` 均返回 ready / MATCH。通过 CDP 在该构建内挂载实际 Input / SettingsTextInput,以生产 Tailwind 和 ThemeService 渲染,读取 `getComputedStyle`(含 placeholder 伪元素)并采集 Light/Dark 截图。

对照左侧是仍保持 Tier-1 默认的通用 Input(也即修复前直接 re-export 的效果),右侧是本批局部兼容封装。同屏使用故意不同的全局和局部覆盖,以证明作用域。这些数值是验证输入,不是新设计裁决。

| 模式 | 项目 | 通用 Input | 兼容设置输入 |
| --- | --- | --- | --- |
| Light | 文字 RGB | 35,69,103 | 101,67,33 |
| Light | 边框 RGB | 86,120,154 | 135,101,67 |
| Light | focus 边框 RGB | 52,86,120 | 169,135,101 |
| Light | placeholder RGB | 120,154,188 | 170,136,102 |
| Dark | 文字 RGB | 170,204,238 | 238,203,170 |
| Dark | 边框 RGB | 136,170,204 | 187,153,119 |
| Dark | focus 边框 RGB | 187,221,255 | 255,221,187 |
| Dark | placeholder RGB | 120,154,188 | 170,136,102 |

两模式错误边框保持生产 error token;禁用 opacity 均为 0.6;focus 环仍为原来的 65,124,221(RGB)、2px。两张截图均已目检。

**证据边界与待交接**

- 这是实际 Desktop 构建中的受控组件样例,非 SSR、非手绘;不是登录后的完整设置页端到端验证。新沙箱没有同意登录协议,本次未代用户同意。完整设置页以及全部内置主题的实机检查未执行。
- 没有宣称全局/局部同时覆盖时零视觉变化:有意恢复设置域旧覆盖;无局部覆盖的默认值由自动测试逐值核对。
- 截图与 CDP 复现/测量脚本随桌面执行记录本地交付,栅格文件不入仓。尚未创建 PR 或上传附件;提交 PR 时需补真实可访问的附件链接,此项目前不记为完成。
- 09-04 日志提出的跨 surface alias 全族收敛没有在本批执行。本批只处理已经迁移的既有输入框;通用标准件保持 Tier-1,旧局部 override 不提升成全局 semantic。

采集时代码 SHA-256(便于核对未提交修改,不把基点当成采集版本):

| 文件 | SHA-256 |
| --- | --- |
| `components/ui/input.tsx` | `88527dd4fb758d9ff7c1fe425f43d67bcc4770abb79f329ad913f95bfd53dba8` |
| `components/settings/SettingsTextInput.tsx` | `fc25cad5aec84abd783904138d88448ec350795c15dd6e2b233c77832c088a57` |
| `components/settings/AgentResourceSection.tsx` | `6f1a9700998f06e69aabbc33f8f78a3b574ad0d138acff9a584d8664f931e909` |
| `components/settings/CollaborationSection.tsx` | `49ce0ba642e6d079b8d609843908279797f95d08ae16a63b6c261983c783d208` |
Loading