From 401926d7ebd24eddc8096236cd1c7bee3ac21bcd Mon Sep 17 00:00:00 2001 From: "xiaohan.yuan" Date: Sat, 17 Oct 2026 08:18:54 +0800 Subject: [PATCH] feat(settings): drop the assistant/device/data/app entries (TODO 16, 17) --- docs/复刻待办清单-2026-10-17.md | 30 ++- scripts/verify-settings-nav.cjs | 185 ++++++++++++++++++ .../src/components/SettingsDialog.tsx | 25 ++- 3 files changed, 228 insertions(+), 12 deletions(-) create mode 100644 scripts/verify-settings-nav.cjs diff --git a/docs/复刻待办清单-2026-10-17.md b/docs/复刻待办清单-2026-10-17.md index 115f700..43a9c3a 100644 --- a/docs/复刻待办清单-2026-10-17.md +++ b/docs/复刻待办清单-2026-10-17.md @@ -350,19 +350,31 @@ ### 16. 移除助理与设备连接 -- [ ] 移除设置中的“助理”“设备连接”导航和页面入口。 -- [ ] 清理默认选中、内部导航、深链接/旧配置回退中的无效目标。 -- [ ] 验收:设置中不再出现这两项,旧导航目标不会导致空白页。 +- [x] 移除设置中的“助理”“设备连接”导航和页面入口。 +- [x] 清理默认选中、内部导航、深链接/旧配置回退中的无效目标。 +- [x] 验收:设置中不再出现这两项,旧导航目标不会导致空白页。 -状态:待实施;范围仅为用户指定的设置入口,不据此删除其他模块功能。 +状态:已实现并通过真实窗口回归。 + +原版证据:WorkBuddy 5.8.3,REA Evidence `ev_2309de12a003fca2c863a223cbda5bccad36b3af73e29bc309fdaed94745cf9e`(归档 `.tmp/rea-assessment-settings-window/layout.json`,视口 900×680):设置导航 16 项 / 4 组、条目 `190×32`、x=12、y 从 111 起;`.settings-modal__nav` 200 宽而条目 190 宽,10px 差值为内容溢出时 thin 滚动条占用的槽位。观察范围:设置窗口导航栏;未覆盖:被移除项的原版内部页面(本轮不迁移其内容)。 + +实现:`src/renderer/src/components/SettingsDialog.tsx` 新增 `REMOVED_SETTINGS_TABS = ['claw','deviceConnections','dataManagement','buddyApps']`、导出 `VISIBLE_SETTINGS_TABS = SOURCE_WINDOWS_SETTINGS_TABS.filter(...)`(12 项)、`normalizeSection()`;`?section=` 启动、已打开窗口的 `onSettingsNavigate` 深链接统一走 normalize(`models`→`model`,已移除项→`model` 回退),`SourceSettingsNavigation` 新增 `visibleTabs` 属性按可见列表渲染。未删除任何用户数据、已安装内容或配置。 + +验证:`scripts/verify-settings-nav.cjs` 在真实隔离 Electron(真实 preload/IPC/renderer + 原生设置窗口)中 19 项断言全部通过:12 项顺序与移除项不在 DOM/标签/搜索元数据、条目几何 x=12 / 32 高 / 宽度等于导航内容宽、3 个组标题 + 1 条 about 分隔线、未接入项 `aria-disabled` + 「暂未接入」提示且点击提示「该设置尚未接入」、`appearance`/`models` 仍可打开、`openSettings('claw')`(旧窗口导航)与 `openSettings('dataManagement')`(新窗口 `?section=` 启动)都回退到模型面板、无渲染器报错;报告 `.tmp/settings-nav-5YlLX6/report.json`。回归:`npm run typecheck`、`npm run build`、`npm run test:chat`、`npm run test:conversation`、`npm run test:conversation-ipc`、`node scripts/verify-composer-context-info.cjs` 全部通过。 + +遗留:移除 4 项后本窗口导航不再溢出,条目宽 200(原版 16 项溢出时为 190);未补 `scrollbar-gutter`,因为 REA 回收的原版 CSS 没有该规则,保持源码级一致,条目增多再次溢出时会自动回到 190。 ### 17. 移除数据管理与应用管理 -- [ ] 移除设置中的“数据管理”“应用管理”导航和页面入口。 -- [ ] 清理相关无效导航与页面占位。 -- [ ] 验收:入口完全移除且其余设置导航正常;不删除已有用户数据或已安装内容。 +- [x] 移除设置中的“数据管理”“应用管理”导航和页面入口。 +- [x] 清理相关无效导航与页面占位。 +- [x] 验收:入口完全移除且其余设置导航正常;不删除已有用户数据或已安装内容。 -状态:待实施。 +状态:已实现并通过真实窗口回归(与 16 同一次改动,代码见上)。 + +原版证据、实现、验证:同 16。`dataManagement`、`buddyApps` 与 `claw`、`deviceConnections` 一并进入 `REMOVED_SETTINGS_TABS`,走同一份 normalize 回退与 `visibleTabs` 渲染;验收脚本同时断言这两项(以及 `claw`、`deviceConnections`)不在 DOM、标签和搜索元数据中,且 `openSettings('dataManagement')` 深链接落到模型面板而不是空白页。未触碰本地会话数据、安装中心与任何已有用户内容。 + +遗留:无新增;数据管理/应用管理的原版页面内容不再复刻(用户指定仅移除入口)。 ### 18. 安装中心 @@ -443,7 +455,7 @@ ## 推进顺序 1. 对话与侧栏:01 → 02 → 03 → 04 → 05 → 06 → 07 → 08 → 09 → 21(均已实现并留下验收记录,未完成验收点在各条目内标注)。 -2. 设置:先落实 16、17 的入口范围,再依次推进 10 → 11 → 12 → 13 → 15 → 19。 +2. 设置:16、17 的入口范围已落实并验证;接着依次推进 10 → 11 → 12 → 13 → 15 → 19。 3. 14、18、20 保留为待拆解,后续单独展开。 ## 每项交付记录模板 diff --git a/scripts/verify-settings-nav.cjs b/scripts/verify-settings-nav.cjs new file mode 100644 index 0000000..2c2cc3e --- /dev/null +++ b/scripts/verify-settings-nav.cjs @@ -0,0 +1,185 @@ +// TODO 16/17 验收:移除设置里的「助理 / 设备连接 / 数据管理 / 应用管理」入口,并保证旧导航目标不落空白页。 +// 真实隔离 YCode(真实 preload/IPC/渲染进程)+ 原生设置窗口。 +// +// 原版依据:REA `ev_2309de12a003fca2c863a223cbda5bccad36b3af73e29bc309fdaed94745cf9e` +// (WorkBuddy 5.8.3 设置窗口,viewport 记录于 .tmp/rea-assessment-settings-window/layout.json): +// 设置导航共 16 项、分 4 组(general 5 / feature 6 / dataSecurity 3 / about 2), +// 条目几何 190×32、x=12、y=111 起、组间距 76。 +// 用户要求(TODO 16/17)在其中移除:助理(claw)、设备连接(deviceConnections)、数据管理(dataManagement)、 +// 应用管理(buddyApps);其余条目保持原样(未接入的仍为禁用 + 「暂未接入」)。 +const fs = require('node:fs/promises') +const path = require('node:path') +const assert = require('node:assert/strict') + +const EXPECTED_TABS = ['settings', 'account', 'subscriptionUsage', 'appearance', 'keyboardShortcuts', 'personalization', + 'memory', 'extensions', 'models', 'securityCenter', 'helpFeedback', 'getHelp'] +const REMOVED_TABS = ['claw', 'deviceConnections', 'dataManagement', 'buddyApps'] + +if (!process.versions.electron) { + ;(async () => { + const directory = await fs.mkdtemp(path.resolve('.tmp/settings-nav-')) + const data = path.join(directory, 'data') + const home = path.join(directory, 'home') + await fs.mkdir(data, { recursive: true }) + await fs.mkdir(home, { recursive: true }) + const now = Date.now() + await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions: [{ id: 'settings-nav-session', title: 'Settings Nav', + cwd: home, createdAt: now, updatedAt: now + 1, messages: [{ id: 'settings-nav-user', role: 'user', content: '设置导航验收', createdAt: now, status: 'done' }] }] })) + const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home, + YCODE_DISABLE_GPU: '1' } + delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL + const child = require('node:child_process').spawn(require('electron'), + ['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) + let output = '' + child.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('SETTINGS_NAV_')) process.stdout.write(bytes) }) + child.stderr.on('data', bytes => { output += bytes }) + const timer = setTimeout(() => child.kill(), 240000) + child.once('exit', async code => { + clearTimeout(timer) + await fs.writeFile(path.join(directory, 'process.log'), output) + console.log(JSON.stringify({ directory, exitCode: code })) + if (code) console.error(output.slice(-4000)) + process.exitCode = code ? 1 : 0 + }) + })().catch(error => { console.error(error); process.exitCode = 1 }) +} else { + const { app, BrowserWindow } = require('electron') + const { pathToFileURL } = require('node:url') + const directory = process.env.YCODE_QA_DIRECTORY + app.setPath('home', path.join(directory, 'home')) + const checks = [], snapshots = {}, rendererErrors = [] + let main, settings, phase = 'boot' + const pause = ms => new Promise(resolve => setTimeout(resolve, ms)) + async function read(source, win = main) { + let timer + try { + return await Promise.race([win.webContents.executeJavaScript(source), new Promise((_, reject) => { + timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 120))), 15000) + })]) + } finally { clearTimeout(timer) } + } + async function wait(source, label, win = main) { + for (let index = 0; index < 300; index++) { const value = await read(source, win); if (value) return value; await pause(40) } + throw Error('Timeout: ' + label) + } + async function step(label) { phase = label; await fs.writeFile(path.join(directory, 'phase.txt'), label) } + function check(label, actual, expected) { + const pass = typeof expected === 'function' ? expected(actual) : JSON.stringify(actual) === JSON.stringify(expected) + checks.push({ label, pass, actual, expected: typeof expected === 'function' ? expected.toString() : expected }) + } + async function openSettings(section) { + await read(`window.ycode.app.openSettings(${JSON.stringify(section)})`) + for (let index = 0; index < 200; index++) { + settings = BrowserWindow.getAllWindows().find(win => win !== main) + if (settings && !settings.webContents.isLoading()) break + await pause(25) + } + assert.ok(settings, 'settings window') + await wait('!!document.querySelector(".settings-window-root")', 'settings root', settings) + await pause(300) + return settings + } + const navSource = `(()=>{const items=[...document.querySelectorAll('.settings-navigation__item')]; + const nav=document.querySelector('.settings-modal__nav'); + return {tabs:items.map(node=>node.dataset.settingsTab),labels:items.map(node=>node.textContent.trim()), + disabled:items.map(node=>node.getAttribute('aria-disabled')),titles:items.map(node=>node.getAttribute('title')), + groups:[...document.querySelectorAll('.settings-navigation__group-title')].map(node=>node.textContent.trim()), + dividers:document.querySelectorAll('.settings-navigation__group-divider').length, + nav:[nav?Math.round(nav.getBoundingClientRect().width):0,nav?nav.clientWidth:0,nav?nav.scrollHeight-nav.clientHeight:0], + geometry:items.map(node=>{const r=node.getBoundingClientRect();return [Math.round(r.x),Math.round(r.width),Math.round(r.height)]})}})()` + + ;(async () => { + // 导入前不做任何 await,避免 Electron 先进入 ready(主进程 bundle 顶层会调用 disableHardwareAcceleration)。 + const sync = require('node:fs') + sync.mkdirSync(process.env.YCODE_USER_DATA_DIR, { recursive: true }) + app.setPath('userData', process.env.YCODE_USER_DATA_DIR) + app.setPath('sessionData', process.env.YCODE_USER_DATA_DIR) + await import(pathToFileURL(path.resolve('out/main/index.mjs')).href) + await app.whenReady() + for (let index = 0; index < 200; index++) { + main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode') + if (main && !main.webContents.isLoading()) break + await pause(40) + } + assert.ok(main, 'main window') + main.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push(message) }) + await wait('!!window.ycode', 'main renderer') + await pause(1200) + + await step('settings-nav-entries') + await openSettings('model') + settings.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push(message) }) + const nav = await read(navSource, settings) + snapshots.nav = nav + check('settings nav keeps the 12 remaining entries in order', nav.tabs, EXPECTED_TABS) + check('removed entries are gone from the DOM', REMOVED_TABS.map(tab => nav.tabs.includes(tab)), [false, false, false, false]) + check('removed entries are gone from labels and search metadata', [nav.labels.join('|').includes('助理'), nav.labels.join('|').includes('设备连接'), + nav.labels.join('|').includes('数据管理'), nav.labels.join('|').includes('应用管理')], [false, false, false, false]) + // 原版 16 项在 680 高窗口溢出,thin 滚动条占 10px,故条目实测 190 = 200 - 10; + // 移除 4 项后本窗口不再溢出,条目宽度应等于导航栏内容宽(原版 CSS 无 scrollbar-gutter)。 + const navContentWidth = nav.nav[1] + check('nav keeps the recovered 32px entry geometry at x=12 and fills the nav content width', + [nav.geometry.every(([x, width, height]) => x === 12 && height === 32), nav.geometry.every(([, width]) => width === navContentWidth), nav.nav[2] === 0], [true, true, true]) + check('nav width reflects the recovered 200px column with a thin scrollbar only when overflowing', + [nav.nav[0] === 200 || nav.nav[0] === 190, nav.nav[2] === 0 ? navContentWidth === 200 : navContentWidth === 190], [true, true]) + // 原版 4 组;about 组按组件约定渲染为分隔线而不是标题。 + check('nav keeps the four recovered groups', [nav.groups.length, nav.dividers], [3, 1]) + const expectedDisabled = EXPECTED_TABS.filter(tab => tab !== 'models' && tab !== 'appearance') + check('unwired entries stay disabled', nav.tabs.filter((tab, index) => nav.disabled[index] === 'true'), expectedDisabled) + check('unwired entries keep the 暂未接入 hint', nav.titles.filter(title => (title ?? '').includes('暂未接入')).length, expectedDisabled.length) + check('models and appearance stay enabled', nav.tabs.filter((tab, index) => !nav.disabled[index]), ['appearance', 'models']) + + await step('settings-search-excludes-removed') + await read(`(()=>{const input=document.querySelector('.settings-navigation input[type="search"]'); + if(!input)throw new Error('missing search');Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(input,'助理'); + input.dispatchEvent(new Event('input',{bubbles:true}));return true})()`, settings) + await pause(240) + snapshots.search = await read(`(()=>{const results=[...document.querySelectorAll('.settings-navigation__item')]; + return {tabs:results.map(node=>node.dataset.settingsTab),empty:!!document.querySelector('.source-settings-search-empty')}})()`, settings) + check('searching the removed 助理 entry finds nothing', [snapshots.search.tabs, snapshots.search.empty], [[], true]) + await read(`(()=>{const input=document.querySelector('.settings-navigation input[type="search"]'); + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(input,'');input.dispatchEvent(new Event('input',{bubbles:true}));return true})()`, settings) + await pause(200) + + await step('settings-navigation-still-works') + await read(`(()=>{const node=document.querySelector('[data-settings-tab="appearance"]');node.click();return true})()`, settings) + await wait(`!!document.querySelector('.segmented')`, 'appearance panel', settings) + check('appearance entry still opens its panel', await read(`!!document.querySelector('.segmented')&&!document.querySelector('.models-settings-panel')`, settings), true) + await read(`(()=>{const node=document.querySelector('[data-settings-tab="models"]');node.click();return true})()`, settings) + await wait(`!!document.querySelector('.models-settings-panel')`, 'models panel', settings) + check('models entry still opens its panel', await read(`!!document.querySelector('.models-settings-panel')`, settings), true) + + await step('stale-navigation-falls-back') + await read(`(()=>{const node=document.querySelector('[data-settings-tab="securityCenter"]');node.click();return true})()`, settings) + await pause(200) + snapshots.unwiredClick = await read(`({alert:(document.querySelector('.settings-modal__panel [role="alert"]')||{}).textContent||'',models:!!document.querySelector('.models-settings-panel')})`, settings) + check('unwired entry reports 该设置尚未接入 instead of a blank page', snapshots.unwiredClick.alert.includes('该设置尚未接入'), true) + // 旧深链接指向已移除入口:应回退到模型设置。 + await read(`window.ycode.app.openSettings('claw')`) + await pause(400) + check('stale navigation to 助理 falls back to the models panel', await read(`!!document.querySelector('.models-settings-panel')`, settings), true) + const previous = settings.id + await read(`document.querySelector('.settings-window-close').click()`, settings) + for (let index = 0; index < 200 && !settings.isDestroyed(); index++) await pause(20) + check('settings window closed before the deep-link relaunch', settings.isDestroyed(), true) + // 新窗口直接以已移除的 section 启动:同样不能是空白页。 + await openSettings('dataManagement') + check('fresh window with a removed section lands on the models panel', await read(`!!document.querySelector('.models-settings-panel')`, settings), true) + check('fresh window reuses a new settings window', settings.id !== previous, true) + const finalNav = await read(navSource, settings) + snapshots.finalNav = finalNav + check('nav still excludes removed entries after relaunch', finalNav.tabs, EXPECTED_TABS) + check('no renderer error logs', rendererErrors, []) + + const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL', + scope: 'Real isolated YCode App/preload/IPC/renderer + native settings window; navigation entries, geometry, search, disabled hints and stale-target fallback measured live.', + sourceEvidence: ['ev_2309de12a003fca2c863a223cbda5bccad36b3af73e29bc309fdaed94745cf9e'], snapshots, checks, rendererErrors } + await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2)) + console.log('SETTINGS_NAV_' + report.result + ' ' + directory) + for (const failure of checks.filter(item => !item.pass)) console.log('SETTINGS_NAV_FAILURE ' + JSON.stringify(failure)) + app.exit(report.result === 'PASS' ? 0 : 1) + })().catch(async error => { + await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ phase, error: String(error), stack: error.stack, checks, snapshots, rendererErrors }, null, 2)) + console.error(error); app.exit(1) + }) +} diff --git a/src/renderer/src/components/SettingsDialog.tsx b/src/renderer/src/components/SettingsDialog.tsx index a4f2467..823f428 100644 --- a/src/renderer/src/components/SettingsDialog.tsx +++ b/src/renderer/src/components/SettingsDialog.tsx @@ -3,6 +3,7 @@ import { useSettingsStore } from '../stores/settings' import ModelSettingsPanel from './ModelSettingsPanel' import SourceSettingsNavigation from './SourceSettingsNavigation' import { useSharedTheme } from '../useSharedTheme' +import { SOURCE_WINDOWS_SETTINGS_TABS } from './SourceSettingsNavigation' import './settings-shell-source.css' interface Props { @@ -12,15 +13,33 @@ interface Props { nativeWindow?: boolean } +/** + * 用户指定的移除项(TODO 16/17):助理、设备连接、数据管理、应用管理。 + * 原版 5.8.3 设置导航是 16 项(REA `ev_2309de12a003fca2c863a223cbda5bccad36b3af73e29bc309fdaed94745cf9e`, + * 8-9-13:111/145/179/213/247 · 323/357/391/425/459/493 · 569/603/637 · 694/728), + * 这里按用户要求只移除上述四项,其余保留(未接入的仍按原样显示为禁用+「暂未接入」)。 + */ +const REMOVED_SETTINGS_TABS: readonly string[] = ['claw', 'deviceConnections', 'dataManagement', 'buddyApps'] + +/** 设置页可见导航(12 项)。 */ +export const VISIBLE_SETTINGS_TABS = SOURCE_WINDOWS_SETTINGS_TABS.filter((tab) => !REMOVED_SETTINGS_TABS.includes(tab)) + +/** 旧导航/深链接可能指向已移除入口:回退到模型设置,避免落到空白页。 */ +function normalizeSection(value: string | null | undefined): string { + if (!value) return 'model' + const normalized = value === 'models' ? 'model' : value + return REMOVED_SETTINGS_TABS.includes(normalized) ? 'model' : normalized +} + export default function SettingsDialog({ theme, onThemeChange, onClose, nativeWindow = false }: Props): React.JSX.Element { const settings = useSettingsStore() - const [section, setSection] = useState(new URLSearchParams(location.search).get('section') ?? 'model') + const [section, setSection] = useState(() => normalizeSection(new URLSearchParams(location.search).get('section'))) const [createRequest, setCreateRequest] = useState(() => new URLSearchParams(location.search).get('action') === 'new' ? 1 : 0) const [error, setError] = useState('') useEffect(() => { if (!nativeWindow) return return window.ycode.app.onSettingsNavigate?.((next, action) => { - setError(''); setSection(next === 'models' ? 'model' : next) + setError(''); setSection(normalizeSection(next)) if (action === 'new') setCreateRequest(value => value + 1) }) }, [nativeWindow]) @@ -32,7 +51,7 @@ export default function SettingsDialog({ theme, onThemeChange, onClose, nativeWi return () => window.removeEventListener('keydown', escape) }, [onClose]) const modal =
-
{ setError(''); setSection(id === 'models' ? 'model' : id) }} onUnavailable={() => setError('该设置尚未接入')} />
{error &&

{error}

}