From 1ed71b63b5af9a98b942ff47c66df5799d4d2c8a Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 21 Oct 2026 13:41:18 +0900 Subject: [PATCH 1/2] fix(B01): retain native card state and restore mode and confirmation styles --- ...pleted-fold-native-identity-regression.tsx | 99 +++++++++++++++++++ scripts/run-alignment-regression.mjs | 5 +- scripts/task-popconfirm-browser-fixture.tsx | 62 ++++++++++++ scripts/task-popconfirm-regression.tsx | 18 +++- scripts/test-mode-chip-native.mjs | 77 +++++++++++++++ scripts/test-mode-chip.mjs | 47 +++++++++ scripts/test-task-popconfirm-browser.mjs | 25 +++++ scripts/verify-task-menu.cjs | 27 ++++- .../src/components/TaskPopconfirm.tsx | 8 +- .../src/components/task-popconfirm.css | 15 +-- .../src/workbuddy/composer/ModeChip.tsx | 14 ++- .../src/workbuddy/composer/mode-chip.css | 11 ++- .../workbuddy/conversation/CompletedFold.tsx | 13 +-- .../workbuddy/conversation/completed-fold.ts | 23 +++-- 14 files changed, 407 insertions(+), 37 deletions(-) create mode 100644 scripts/completed-fold-native-identity-regression.tsx create mode 100644 scripts/task-popconfirm-browser-fixture.tsx create mode 100644 scripts/test-mode-chip-native.mjs create mode 100644 scripts/test-mode-chip.mjs create mode 100644 scripts/test-task-popconfirm-browser.mjs diff --git a/scripts/completed-fold-native-identity-regression.tsx b/scripts/completed-fold-native-identity-regression.tsx new file mode 100644 index 0000000..886b4ce --- /dev/null +++ b/scripts/completed-fold-native-identity-regression.tsx @@ -0,0 +1,99 @@ +/** F01 acceptance reproduction extended through the real reducer and MessageBubble. + * No Electron, model, SDK, or external service; DOM behavior, not visual parity. + */ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import type { ChatMessage, ChatEvent, MessagePart } from '../src/shared/types' +const { JSDOM } = createRequire(process.cwd() + '/package.json')(process.env.YCODE_TEST_DOM_MODULE || 'jsdom') +const dom = new JSDOM('
', { pretendToBeVisual: true, url: 'http://localhost/' }) +for (const key of ['window', 'document', 'Node', 'Element', 'HTMLElement', 'MouseEvent', 'KeyboardEvent', 'Event']) Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true }) +Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true }) +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) +const { act } = require('react') as typeof import('react') +const { createRoot } = require('react-dom/client') as typeof import('react-dom/client') +const MessageBubble = require('../src/renderer/src/components/MessageBubble').default as typeof import('../src/renderer/src/components/MessageBubble').default +const { applyAssistantEvent } = require('../src/shared/message-parts') as typeof import('../src/shared/message-parts') +const { completedFoldPartIdentity } = require('../src/renderer/src/workbuddy/conversation/completed-fold') as typeof import('../src/renderer/src/workbuddy/conversation/completed-fold') +const root = createRoot(document.getElementById('root')!) +const plan = '.cb-plan-header-left' +const task = '.cb-task-result-item-title' +const outer = '.cr-collapse--completed > .cr-collapse__header' +const nodes = (selector: string) => [...document.querySelectorAll(selector)] +const get = (selector: string, index = 0) => { const node = nodes(selector)[index]; assert.ok(node, `${selector}[${index}]`); return node } +const states = () => ({ plan: nodes(plan).map(node => node.getAttribute('aria-expanded')), task: nodes(task).map(node => node.getAttribute('aria-expanded')) }) +async function render(message: ChatMessage) { await act(async () => root.render()) } +async function click(selector: string, index = 0) { await act(async () => get(selector, index).dispatchEvent(new MouseEvent('click', { bubbles: true }))) } +type EventInput = ChatEvent extends infer E ? E extends ChatEvent ? Omit : never : never +const event = (value: EventInput) => ({ runId: 'native-identity', ...value }) as ChatEvent +const planPart = (id: string): Extract => ({ type: 'plan', plan: { id, title: `Plan ${id}`, steps: [] } }) +const taskPart = (id: string): Extract => ({ type: 'task', task: { id, title: `Task ${id}`, status: 'success', summary: `Result ${id}` } }) +async function main() { + // All native business IDs, including non-disclosure image/artifact parts, are stable. + const stableParts: MessagePart[] = [planPart('same'), taskPart('same'), { type: 'artifact', artifact: { id: 'same', name: 'File', path: '/file' } }, { type: 'image', id: 'same', url: '/image' }, { type: 'reasoning', id: 'same', text: 'Thought' }, { type: 'tool', toolId: 'same' }] + const identities = stableParts.map(part => completedFoldPartIdentity(part, 1)) + assert.equal(new Set(identities).size, stableParts.length, 'part types cannot share identity') + stableParts.forEach((part, i) => assert.equal(completedFoldPartIdentity(part, 99), identities[i], `${part.type} ignores position`)) + assert.notEqual(completedFoldPartIdentity(planPart('1'), 1), completedFoldPartIdentity({ type: 'plan', plan: {} }, 1), 'business IDs cannot collide with fallback indices') + assert.equal(completedFoldPartIdentity({ type: 'future', id: 'native' }, 1), completedFoldPartIdentity({ type: 'future', id: 'native' }, 2), 'unknown top-level IDs remain supported') + + let message: ChatMessage = { id: 'native-one', role: 'assistant', createdAt: 1, content: '', status: 'streaming', parts: [] } + // Exact acceptance event sequence: two text chunks separated by reasoning. + const streamingEvents: EventInput[] = [ + { type: 'text', delta: 'Draft one' }, + { type: 'part', part: { type: 'reasoning', id: 'r', text: 'Thought', finished: true } }, + { type: 'text', delta: 'Draft two' }, + { type: 'part', part: planPart('p') }, + { type: 'part', part: taskPart('x') } + ] + for (const value of streamingEvents) message = applyAssistantEvent(message, event(value)) + assert.deepEqual(message.parts?.map(part => part.type), ['text', 'reasoning', 'text', 'plan', 'task']) + await render(message) + assert.deepEqual(states(), { plan: ['true'], task: ['true'] }) + await click(plan); await click(task) + const collapsed = { plan: ['false'], task: ['false'] } + assert.deepEqual(states(), collapsed) + message = applyAssistantEvent(message, event({ type: 'done', content: 'Corrected final prose', tools: [], turnDurationMs: 1000 })) + assert.deepEqual(message.parts?.map(part => part.type), ['text', 'reasoning', 'plan', 'task'], 'real done reducer coalesces text') + await render(message) + assert.deepEqual(states(), collapsed, 'terminal correction preserves choices') + for (let repeat = 0; repeat < 2; repeat++) { + await click(outer); assert.deepEqual(states(), collapsed, 'outer expansion preserves choices') + await click(outer); assert.deepEqual(states(), collapsed, 'outer collapse preserves choices') + } + // Insert new same-kind cards before originals, then reorder. New cards must not inherit state. + const prefix = message.parts!.filter(part => part.type !== 'plan' && part.type !== 'task') + message = { ...message, parts: [...prefix, planPart('p2'), taskPart('x2'), planPart('p'), taskPart('x')] } + await render(message) + assert.deepEqual(states(), { plan: ['true', 'false'], task: ['true', 'false'] }, 'insertions retain original identity only') + await click(plan, 1) // explicitly open original; keep task closed + await click(plan, 0) // close new plan; leave new task open + message = { ...message, parts: [taskPart('x'), planPart('p'), ...prefix, taskPart('x2'), planPart('p2')] } + await render(message) + assert.deepEqual(states(), { plan: ['true', 'false'], task: ['false', 'true'] }, 'reorder retains independent choices') + await click(task, 1) + await click(outer); await click(outer) + assert.deepEqual(states(), { plan: ['true', 'false'], task: ['false', 'false'] }, 'reordered choices survive outer reparenting') + await render({ ...message, id: 'native-two' }) + assert.deepEqual(states(), { plan: ['true', 'true'], task: ['true', 'true'] }, 'same part IDs in another message are isolated') + assert.equal(get(outer).getAttribute('aria-expanded'), 'false') + + // Legacy persisted payloads may predate required ID fields. Keep their positional fallback, + // without promising identity across arbitrary insertions when no business identity exists. + const legacyParts = [planPart(''), taskPart('')].map(part => { + const copy = structuredClone(part) as any + delete copy[copy.type].id + return copy + }) as MessagePart[] + const legacy = { ...message, id: 'legacy', parts: [...prefix, ...legacyParts] } + await render(legacy) + await click(plan); await click(task) + await render(structuredClone(legacy)) + await click(outer); await click(outer) + assert.deepEqual(states(), collapsed, 'no-ID old history retains choice at the same position') + assert.notEqual(completedFoldPartIdentity(legacyParts[0], 1), completedFoldPartIdentity(legacyParts[0], 2), 'no-ID fallback remains positional') + await render({ ...legacy, id: 'legacy-other' }) + assert.deepEqual(states(), { plan: ['true'], task: ['true'] }, 'legacy fallback remains message-isolated') + await act(async () => root.unmount()); dom.window.close() + console.log('completed-fold-native-identity: real reducer done correction, native IDs, insertion/reorder, repeated outer toggles, message isolation and legacy fallback passed') +} +main().then(() => process.exit(0)).catch(error => { console.error(error); dom.window.close(); process.exit(1) }) diff --git a/scripts/run-alignment-regression.mjs b/scripts/run-alignment-regression.mjs index ea0af70..1ec50cc 100644 --- a/scripts/run-alignment-regression.mjs +++ b/scripts/run-alignment-regression.mjs @@ -24,11 +24,14 @@ for (const file of ['scripts/build-runtime-regressions.mjs', '.tmp/claude-approv // Optional DOM tests use separately installed jsdom, not Electron/CLI or an upstream model. if (process.env.YCODE_TEST_DOM_MODULE) { - for (const file of ['task-popconfirm-regression', 'question-interaction-regression', 'question-identity-regression', 'completed-fold-nested-regression', 'permission-intercept-dom-regression']) { + for (const file of ['task-popconfirm-regression', 'question-interaction-regression', 'question-identity-regression', 'completed-fold-nested-regression', 'completed-fold-native-identity-regression', 'permission-intercept-dom-regression']) { const outfile = `.tmp/alignment-tests/${file}.cjs` await build({ entryPoints: [`scripts/${file}.tsx`], outfile, bundle: true, platform: 'node', format: 'cjs', jsx: 'automatic', loader: { '.css': 'empty' }, logLevel: 'warning' }) const result = spawnSync(process.execPath, [outfile], { stdio: 'inherit', timeout: 120000 }) if (result.error) throw result.error if (result.status !== 0) process.exit(result.status ?? 1) } + const modeChip = spawnSync(process.execPath, ['scripts/test-mode-chip.mjs'], { stdio: 'inherit', timeout: 120000 }) + if (modeChip.error) throw modeChip.error + if (modeChip.status !== 0) process.exit(modeChip.status ?? 1) } diff --git a/scripts/task-popconfirm-browser-fixture.tsx b/scripts/task-popconfirm-browser-fixture.tsx new file mode 100644 index 0000000..6debf6d --- /dev/null +++ b/scripts/task-popconfirm-browser-fixture.tsx @@ -0,0 +1,62 @@ +/** Real Chromium fixture, isolated callbacks only: no IPC, sessions, models, or original app. */ +import React from 'react' +import { createRoot } from 'react-dom/client' +import { flushSync } from 'react-dom' +import '../src/renderer/src/workbuddy/typography/tokens.css' +import '../src/renderer/src/styles/tokens.css' +import '../src/renderer/src/ui/tokens.css' +import '../src/renderer/src/styles/app.css' +import '../src/renderer/src/styles/sidebar.css' +import '../src/renderer/src/workbuddy/conversation/source-styles.css' +import '../src/renderer/src/workbuddy/conversation/source-adapters.css' +import '../src/renderer/src/components/workspace-source.css' +import '../src/renderer/src/components/source-select/source-select-recovered.css' +import '../src/renderer/src/components/source-select/source-select.css' +import { TaskPopconfirm } from '../src/renderer/src/components/TaskPopconfirm' +const root = createRoot(document.getElementById('root')!) +const anchor = document.getElementById('anchor')! +const results: unknown[] = [] +const pause = () => new Promise(resolve => setTimeout(resolve, 200)) +function check(actual: unknown, expected: unknown, label: string) { + results.push({label, actual, expected}) + if (JSON.stringify(actual) !== JSON.stringify(expected)) throw Error(label + ': ' + JSON.stringify(actual)) +} +const button = (name: string) => document.querySelector('.task-popconfirm__' + name)! +async function run() { + for (const theme of ['light', 'dark']) for (const kind of ['archive', 'delete'] as const) for (const running of (kind === 'delete' ? [false, true] : [false])) { + document.documentElement.dataset.theme = theme + let calls = 0, closes = 0, resolve!: () => void, reject!: (cause: Error) => void + const close = () => { closes++; flushSync(() => root.render(null)) } + flushSync(() => root.render( { calls++; return new Promise((yes, no) => { resolve = yes; reject = no }) }} />)) + await pause() + const label = `${theme}/${kind}/${running}` + const dark = theme === 'dark' + const confirm = button('confirm'), cancel = button('cancel') + for (const node of [confirm, cancel]) { + const s = getComputedStyle(node) + check([s.minWidth, s.paddingLeft, s.paddingRight, s.height, s.borderRadius, s.fontSize], ['66px', '19px', '19px', '28px', '6px', '12px'], label + ' geometry') + } + const primary = getComputedStyle(confirm), secondary = getComputedStyle(cancel) + check([primary.backgroundColor, primary.color], kind === 'delete' ? [dark ? 'rgb(175, 25, 26)' : 'rgb(232, 92, 92)', 'rgb(255, 255, 255)'] : dark ? ['rgba(255, 255, 255, 0.9)', 'rgba(0, 0, 0, 0.9)'] : ['rgba(0, 0, 0, 0.9)', 'rgb(255, 255, 255)'], label + ' primary/danger colors') + check([secondary.backgroundColor, secondary.borderTopWidth, secondary.borderTopStyle, secondary.borderTopColor], [dark ? 'rgb(38, 38, 38)' : 'rgb(255, 255, 255)', '1px', 'solid', dark ? 'rgb(61, 61, 61)' : 'rgb(222, 222, 222)'], label + ' secondary colors/border') + const popup = document.querySelector('.task-popconfirm')! + check([getComputedStyle(popup).width, getComputedStyle(popup).borderRadius, getComputedStyle(document.querySelector('.task-popconfirm__title')!).fontWeight], ['240px', '16px', '600'], label + ' shell') + check(getComputedStyle(popup).backgroundColor, dark ? 'rgb(36, 36, 36)' : 'rgb(255, 255, 255)', label + ' shell background') + check(document.activeElement === confirm, true, label + ' initial focus') + if (running) check(confirm.textContent, '停止并删除', label + ' running delete semantic') + confirm.click(); confirm.click(); await pause() + check([calls, confirm.disabled, cancel.disabled, confirm.textContent], [1, true, true, '处理中…'], label + ' busy prevents duplicates') + document.dispatchEvent(new KeyboardEvent('keydown', {key:'Escape', bubbles:true})); cancel.click() + check(closes, 0, label + ' busy cannot dismiss') + if (kind === 'delete') check(getComputedStyle(confirm).backgroundColor, dark ? 'rgb(128, 56, 56)' : 'rgb(245, 185, 185)', label + ' danger busy background') + reject(new Error('fixture failure')); await pause() + check(document.querySelector('[role="alert"]')?.textContent, 'fixture failure', label + ' failure retained') + check(confirm.disabled, false, label + ' retry enabled') + confirm.click(); await pause(); resolve(); await pause() + check([calls, closes, !!document.querySelector('.task-popconfirm')], [2, 1, false], label + ' retry success') + } +} +run().then(() => finish(true), error => finish(false, String(error))) +function finish(pass: boolean, error?: string) { + const output = document.createElement('pre'); output.id = 'result'; output.textContent = JSON.stringify({pass, error, results}); document.body.append(output) +} diff --git a/scripts/task-popconfirm-regression.tsx b/scripts/task-popconfirm-regression.tsx index ac6d8b8..414d045 100644 --- a/scripts/task-popconfirm-regression.tsx +++ b/scripts/task-popconfirm-regression.tsx @@ -41,7 +41,7 @@ async function main() { check(document.querySelector('[role="dialog"]')?.getAttribute('aria-labelledby'), 'accessible title') check(document.activeElement === document.querySelector('.task-popconfirm__confirm'), 'confirmation auto-focuses') check(taskConfirmationCopy('delete', true).confirm === '停止并删除', 'running delete copy') - check(document.querySelector('.task-popconfirm__compatibility')?.textContent?.includes('YCode 暂无此设置入口'), 'archive destination mismatch explicit') + check(!document.querySelector('.task-popconfirm__compatibility'), 'archive preserves selected original copy without extra explanation') const confirm = document.querySelector('.task-popconfirm__confirm')! await act(async () => { click(confirm); click(confirm) }) check(calls === 1, 'duplicate confirmation guarded synchronously') @@ -56,6 +56,22 @@ async function main() { await act(async () => resolve!()) check(closes === 1 && !document.querySelector('.task-popconfirm'), 'success closes') await render('delete') + await act(async () => root.render(null)) + await act(async () => root.render()); await settle() + const runningConfirm = document.querySelector('.task-popconfirm__confirm')! + const runningCancel = document.querySelector('.task-popconfirm__cancel')! + check(runningConfirm.textContent === '停止并删除', 'running delete uses stop-and-delete action') + const beforeRunning = calls, beforeRunningClose = closes + await act(async () => { click(runningConfirm); click(runningConfirm); click(runningCancel) }) + check(calls === beforeRunning + 1 && closes === beforeRunningClose, 'running delete is single-flight and cancellation locked') + check(runningConfirm.disabled && runningCancel.disabled && runningConfirm.textContent === '处理中…', 'running delete busy buttons remain readable and disabled') + await act(async () => reject!(new Error('stop failed'))) + check(document.querySelector('[role="alert"]')?.textContent === 'stop failed', 'stop-and-delete failure retained') + check(!runningConfirm.disabled && runningConfirm.textContent === '停止并删除', 'stop-and-delete failure restores action') + await act(async () => click(runningConfirm)) + await act(async () => resolve!()) + check(calls === beforeRunning + 2 && closes === beforeRunningClose + 1, 'stop-and-delete retry succeeds once') + await render('delete') let underlyingClicks = 0 other.addEventListener('click', () => underlyingClicks++) await act(async () => { diff --git a/scripts/test-mode-chip-native.mjs b/scripts/test-mode-chip-native.mjs new file mode 100644 index 0000000..11d4ca9 --- /dev/null +++ b/scripts/test-mode-chip-native.mjs @@ -0,0 +1,77 @@ +/** Own-YCode isolated component fixture. Run on a permitted Electron desktop: + * node scripts/test-mode-chip-native.mjs + * No original app, model, SDK, user data, or production session access. + */ +import fs from 'node:fs' +import path from 'node:path' +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' +const require = createRequire(import.meta.url), module = require('electron') +const base = path.resolve('.tmp') +fs.mkdirSync(base, { recursive: true }) +const file = 'src/renderer/src/workbuddy/composer/ModeChip.tsx' +async function prepareFixture(output) { + const src = relative => JSON.stringify(path.resolve(relative).replaceAll('\\', '/')) + fs.writeFileSync(path.join(output, 'fixture.tsx'), ` +import React,{useState} from 'react'; import {createRoot} from 'react-dom/client'; +import {ModeChip} from ${src(file)}; +import ${src('src/renderer/src/workbuddy/typography/tokens.css')}; +import ${src('src/renderer/src/styles/tokens.css')}; +import ${src('src/renderer/src/ui/tokens.css')}; +import ${src('src/renderer/src/styles/app.css')}; +import ${src('src/renderer/src/workbuddy/conversation/source-styles.css')}; +const q=(window as any).qa={clears:0}; +function Fixture(){const [mode,setMode]=useState('plan'),[disabled,setDisabled]=useState(false);Object.assign(q,{setMode,setDisabled});return
{q.clears++;setMode('craft')}}/>
};createRoot(document.getElementById('root')!).render();`) + await require('esbuild').build({ entryPoints: [path.join(output, 'fixture.tsx')], bundle: true, platform: 'browser', jsx: 'automatic', outfile: path.join(output, 'fixture.js'), logLevel: 'warning' }) + fs.writeFileSync(path.join(output, 'index.html'), '
') +} +if (!process.versions.electron) { + const output = fs.mkdtempSync(path.join(base, 'mode-chip-native-')), env = { ...process.env } + delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL + if (process.argv.includes('--build-only')) { + await prepareFixture(output); console.log(JSON.stringify({ output, fixtureBuilt: true, nativeExecuted: false })) + } else { + const child = spawn(module, [path.resolve('scripts/test-mode-chip-native.mjs'), output], { env, windowsHide: true, stdio: 'inherit' }) + const timeout = setTimeout(() => child.kill(), 60000) + child.on('error', error => { clearTimeout(timeout); console.error(error); process.exitCode = 1 }) + child.on('exit', code => { clearTimeout(timeout); process.exitCode = code ?? 1; console.log(JSON.stringify({ output, exitCode: code })) }) + } +} else { + const { app, BrowserWindow } = module, output = path.resolve(process.argv.at(-1)) + assert.ok(path.dirname(output) === base && path.basename(output).startsWith('mode-chip-native-')) + fs.mkdirSync(path.join(output, 'data')); app.setPath('userData', path.join(output, 'data')); app.disableHardwareAcceleration() + let win; const checks = [], samples = [], errors = [] + try { + await prepareFixture(output) + await app.whenReady(); win = new BrowserWindow({ width: 800, height: 300, show: false, webPreferences: { contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } }) + win.webContents.on('console-message', event => { if (event.level === 'error' || event.level >= 3) errors.push(event.message) }) + await win.loadFile(path.join(output, 'index.html')); win.showInactive() + const read = code => win.webContents.executeJavaScript(code), pause = () => new Promise(resolve => setTimeout(resolve, 80)) + const check = (label, actual, expected = true) => { assert.deepEqual(actual, expected, label); checks.push(label) } + const key = async keyCode => { win.webContents.sendInputEvent({ type: 'keyDown', keyCode }); win.webContents.sendInputEvent({ type: 'keyUp', keyCode }); await pause() } + const sample = () => read(`(()=>{const c=document.querySelector('.yc-mode-chip'),i=c.querySelector('.yc-mode-chip__mode-icon'),b=c.querySelector('button'),s=getComputedStyle(c),r=e=>{const x=e.getBoundingClientRect();return{x:x.x,y:x.y,width:x.width,height:x.height}};return{chip:r(c),icon:r(i),button:b?r(b):null,background:s.backgroundColor,radius:s.borderRadius,color:s.color,expectedColor:s.getPropertyValue('--cr-text-secondary').trim(),font:s.fontSize,weight:s.fontWeight,line:s.lineHeight,padding:s.paddingLeft,iconOpacity:getComputedStyle(i).opacity,clearOpacity:b?getComputedStyle(b).opacity:null}})()`) + await pause() + for (const theme of ['light', 'dark']) for (const mode of ['plan', 'ask']) { + await read(`document.documentElement.dataset.theme='${theme}'; document.querySelector('.chat-pane').dataset.crTheme='${theme}'; qa.setMode('${mode}');qa.setDisabled(false);document.querySelector('#before').focus()`) + win.webContents.sendInputEvent({ type: 'mouseMove', x: 1, y: 1 }); await pause() + const normal = await sample(); samples.push({ theme, mode, state: 'normal', ...normal }) + check('transparent', normal.background, 'rgba(0, 0, 0, 0)'); check('pill', parseFloat(normal.radius) >= normal.chip.height / 2) + check('height', normal.chip.height, 32); check('font', normal.font, '13px'); check('weight', normal.weight, '500'); check('line', normal.line, '20px'); check('padding', normal.padding, '8px') + check('same slot', normal.icon, normal.button); check('icon width', normal.icon.width, 16); check('default icon', normal.iconOpacity, '1'); check('default close', normal.clearOpacity, '0') + check('secondary color', await read(`(()=>{const c=document.querySelector('.yc-mode-chip'),p=document.createElement('span');p.style.color='var(--cr-text-secondary)';c.append(p);const ok=getComputedStyle(p).color===getComputedStyle(c).color;p.remove();return ok})()`)) + win.webContents.sendInputEvent({ type: 'mouseMove', x: Math.round(normal.chip.x + normal.chip.width / 2), y: Math.round(normal.chip.y + normal.chip.height / 2) }); await pause() + const hover = await sample(); samples.push({ theme, mode, state: 'hover', ...hover }); check('hover icon hidden', hover.iconOpacity, '0'); check('hover X', hover.clearOpacity, '1'); check('no hover layout shift', hover.chip, normal.chip) + win.webContents.sendInputEvent({ type: 'mouseMove', x: 1, y: 1 }); await read(`document.querySelector('.yc-mode-chip').focus()`); await pause() + const focus = await sample(); check('focus icon hidden', focus.iconOpacity, '0'); check('focus X', focus.clearOpacity, '1'); check('no focus layout shift', focus.chip, normal.chip) + await fs.promises.writeFile(path.join(output, `${theme}-${mode}-focus.png`), (await win.webContents.capturePage()).toPNG()) + await key('Tab'); check('Tab reaches native clear', await read(`document.activeElement.matches('.yc-mode-chip__clear')`)) + const before = await read('qa.clears'); await key(mode === 'plan' ? 'Enter' : 'Space'); check('native keyboard exit', await read('qa.clears'), before + 1); check('craft hidden', await read(`!document.querySelector('.yc-mode-chip')`)) + await read(`qa.setMode('${mode}');qa.setDisabled(true)`); await pause(); check('disabled has no clear', await read(`!document.querySelector('.yc-mode-chip button')`)); check('disabled not tabbable', await read(`document.querySelector('.yc-mode-chip').tabIndex`), -1) + } + win.setSize(320, 300); await read(`qa.setMode('ask');qa.setDisabled(false)`); await pause(); const narrow = await sample(); check('narrow slot preserved', narrow.icon, narrow.button); check('narrow no overflow', narrow.chip.x + narrow.chip.width <= 320) + await fs.promises.writeFile(path.join(output, 'narrow.png'), (await win.webContents.capturePage()).toPNG()); check('no renderer errors', errors, []) + fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify({ checks, samples, errors, sourceSha256: createHash('sha256').update(fs.readFileSync(file)).digest('hex'), scope: 'Own-YCode isolated component browser test, not full app/store integration or original-app pixel acceptance' }, null, 2)); app.exit(0) + } catch (error) { fs.writeFileSync(path.join(output, 'failure.txt'), String(error.stack || error)); console.error(error); app.exit(1) } +} diff --git a/scripts/test-mode-chip.mjs b/scripts/test-mode-chip.mjs new file mode 100644 index 0000000..2868a45 --- /dev/null +++ b/scripts/test-mode-chip.mjs @@ -0,0 +1,47 @@ +/** Isolated DOM regression; not a browser layout/pixel or original-app acceptance test. */ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { createRequire } from 'node:module' +import { build } from 'esbuild' +const require = createRequire(import.meta.url) +const { JSDOM } = require(process.env.YCODE_TEST_DOM_MODULE || process.env.YCODE_JSDOM_PATH || 'jsdom') +const output = fs.mkdtempSync(path.resolve('.tmp/mode-chip-dom-')) +await build({ entryPoints: ['src/renderer/src/workbuddy/composer/ModeChip.tsx'], bundle: true, platform: 'node', format: 'cjs', jsx: 'automatic', packages: 'external', loader: { '.css': 'empty' }, outfile: path.join(output, 'component.cjs'), logLevel: 'error' }) +const dom = new JSDOM('
', { url: 'http://localhost' }) +Object.assign(globalThis, { window: dom.window, document: dom.window.document, HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true }) +const React = require('react'), { createRoot } = require('react-dom/client') +const { ModeChip } = require(path.join(output, 'component.cjs')) +const css = fs.readFileSync('src/renderer/src/workbuddy/composer/mode-chip.css', 'utf8') +const style = document.createElement('style'); style.textContent = css; document.head.append(style) +const root = createRoot(document.getElementById('root')); let clears = 0, checks = 0 +const check = (actual, expected, message) => { assert.deepEqual(actual, expected, message); checks++ } +const render = async (mode, disabled = false) => React.act(async () => root.render(React.createElement(ModeChip, { mode, disabled, onClear: () => { clears++ } }))) +for (const mode of ['plan', 'ask']) { + await render(mode) + const chip = document.querySelector('.yc-mode-chip'), slot = chip.querySelector('.yc-mode-chip__icon-slot'), button = slot.querySelector('button'), icon = slot.querySelector('.yc-mode-chip__mode-icon') + check(chip.textContent, mode === 'plan' ? '计划' : '仅问答', 'mode label has no extra X text') + check(chip.getAttribute('role'), 'group', 'chip remains a group, not a menu') + check(button.type, 'button', 'clear cannot submit composer') + check(button.getAttribute('aria-label'), `退出${chip.textContent}模式`, 'accessible exit name') + check(icon.parentElement === button.parentElement, true, 'icon and close share one slot') + check(dom.window.getComputedStyle(slot).display, 'grid', 'shared grid slot') + check(dom.window.getComputedStyle(button).gridArea, '1 / 1', 'button occupies original icon cell') + check(dom.window.getComputedStyle(icon).gridArea, '1 / 1', 'mode occupies same original cell') + check(dom.window.getComputedStyle(button).position, 'static', 'no floating absolute close') + check(dom.window.getComputedStyle(chip).backgroundColor, 'rgba(0, 0, 0, 0)', 'transparent background') + await React.act(async () => button.focus()) + check(document.activeElement === button, true, 'native button receives keyboard focus') + const before = clears + await React.act(async () => button.click()) + check(clears, before + 1, 'activation calls existing host clear exactly once') + await render(mode, true) + check(document.querySelector('button'), null, 'disabled mode exposes no clear action') + check(document.querySelector('.yc-mode-chip').getAttribute('aria-disabled'), 'true', 'disabled state is announced') + check(document.querySelector('.yc-mode-chip').tabIndex, -1, 'disabled mode not in keyboard tab order') +} +await render('craft'); check(document.querySelector('.yc-mode-chip'), null, 'craft stays hidden') +// CSS contracts complement DOM checks; test-mode-chip-native.mjs is an unexecuted native hover/focus/layout probe. +for (const contract of ['border-radius:var(--yc-radius-full)', 'color:var(--cr-text-secondary)', ':hover .yc-mode-chip__mode-icon', ':focus-within .yc-mode-chip__mode-icon', ':hover .yc-mode-chip__clear', ':focus-within .yc-mode-chip__clear']) check(css.includes(contract), true, contract) +await React.act(async () => root.unmount()); dom.window.close() +console.log(JSON.stringify({ checks, passed: true, scope: 'Isolated React DOM and CSS contracts; no native keyboard/layout/pixel acceptance' })) diff --git a/scripts/test-task-popconfirm-browser.mjs b/scripts/test-task-popconfirm-browser.mjs new file mode 100644 index 0000000..92b07a6 --- /dev/null +++ b/scripts/test-task-popconfirm-browser.mjs @@ -0,0 +1,25 @@ +/** Build and run real-browser computed-style checks; no jsdom style substitute. + * CHROMIUM_PATH=/path/to/chrome node scripts/test-task-popconfirm-browser.mjs + * --build-only creates a portable HTML fixture for an available browser. + * Chromium sandbox remains enabled. Runtime failure is NOT a passing test. + */ +import { build } from 'esbuild' +import { mkdtemp, writeFile, readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { spawnSync } from 'node:child_process' +const dir = await mkdtemp(resolve('.tmp/task-popconfirm-browser-')) +await build({ entryPoints: ['scripts/task-popconfirm-browser-fixture.tsx'], bundle: true, platform: 'browser', format: 'iife', jsx: 'automatic', outfile: dir + '/fixture.js', loader: {'.svg':'dataurl', '.woff2':'dataurl', '.woff':'dataurl'}, logLevel: 'warning' }) +const js = await readFile(dir + '/fixture.js', 'utf8'), css = await readFile(dir + '/fixture.css', 'utf8') +await writeFile(dir + '/index.html', `
`) +console.log('Browser fixture: ' + dir + '/index.html') +if (!process.argv.includes('--build-only')) { + const proc = spawnSync(process.env.CHROMIUM_PATH || 'chromium', ['--headless', '--disable-gpu', '--no-first-run', '--user-data-dir=' + dir + '/profile', '--virtual-time-budget=20000', '--dump-dom', pathToFileURL(dir + '/index.html').href], { encoding:'utf8', timeout:60000, maxBuffer:20*1024*1024 }) + await writeFile(dir + '/browser.log', (proc.stderr || '') + '\n' + (proc.error || '')) + const match = proc.stdout?.match(/
([\s\S]*?)<\/pre>/)
+  if (proc.status !== 0 || !match) throw Error(`Browser did not finish; status=${proc.status}; see ${dir}/browser.log`)
+  const report = JSON.parse(match[1].replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>').replaceAll('&', '&'))
+  await writeFile(dir + '/report.json', JSON.stringify(report, null, 2))
+  if (!report.pass) throw Error(report.error)
+  console.log(`Task confirmation real Chromium: ${report.results.length} assertions passed; ${dir}/report.json`)
+}
diff --git a/scripts/verify-task-menu.cjs b/scripts/verify-task-menu.cjs
index 9620f73..2513072 100644
--- a/scripts/verify-task-menu.cjs
+++ b/scripts/verify-task-menu.cjs
@@ -66,6 +66,27 @@ if (!process.versions.electron) {
     const capture = await main.webContents.debugger.sendCommand('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false })
     await fs.writeFile(path.join(directory, name + '.png'), Buffer.from(capture.data, 'base64'))
   }
+  // Assert styles in the real body portal, not just variant class names.
+  async function checkConfirmationStyles(kind) {
+    const previous = await read('document.documentElement.dataset.theme || "light"')
+    const setTheme = async theme => {
+      await read(`(()=>{localStorage.setItem('ycode:theme',${JSON.stringify(theme)});window.dispatchEvent(new StorageEvent('storage',{key:'ycode:theme',newValue:${JSON.stringify(theme)}}))})()`)
+      await wait(`document.documentElement.dataset.theme === ${JSON.stringify(theme)}`, 'application theme synchronization')
+      await pause(400)
+    }
+    for (const theme of ['light', 'dark']) {
+      await setTheme(theme)
+      const values = await read(`(()=>{const style=name=>{const s=getComputedStyle(document.querySelector('.task-popconfirm__'+name));return {background:s.backgroundColor,color:s.color,border:[s.borderTopWidth,s.borderTopStyle,s.borderTopColor],geometry:[s.minWidth,s.paddingLeft,s.paddingRight,s.height,s.borderRadius,s.fontSize]}};return {cancel:style('cancel'),confirm:style('confirm'),titleWeight:getComputedStyle(document.querySelector('.task-popconfirm__title')).fontWeight}})()`)
+      const dark = theme === 'dark'
+      check(kind+' '+theme+' secondary background/border', [values.cancel.background, values.cancel.border], [dark ? 'rgb(38, 38, 38)' : 'rgb(255, 255, 255)', ['1px', 'solid', dark ? 'rgb(61, 61, 61)' : 'rgb(222, 222, 222)']])
+      check(kind+' '+theme+' primary/danger background', values.confirm.background, kind === 'delete' ? (dark ? 'rgb(175, 25, 26)' : 'rgb(232, 92, 92)') : (dark ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.9)'))
+      check(kind+' '+theme+' confirmation foreground', values.confirm.color, kind === 'archive' && dark ? 'rgba(0, 0, 0, 0.9)' : 'rgb(255, 255, 255)')
+      for (const action of ['confirm', 'cancel']) check(kind+' '+theme+' '+action+' geometry', values[action].geometry, ['66px', '19px', '19px', '28px', '6px', '12px'])
+      check(kind+' '+theme+' title weight', values.titleWeight, '600')
+      await shot(kind+'-confirm-'+theme)
+    }
+    await setTheme(previous)
+  }
   // 真实用户路径:先悬停行让操作按钮显形,再点击按钮。
   const openMenu = `(()=>{const row=document.querySelector('[data-session-id]');if(!row)return null;row.classList.add('sidebar__item--menu-open');const button=row.querySelector('.agent-card-more-button');if(!button)return null;button.click();return true})()`
   const menuSource = `(()=>{const menu=document.querySelector('.conversation-context-menu');if(!menu)return null;const style=getComputedStyle(menu);const rect=menu.getBoundingClientRect();return {rect:[rect.x,rect.y,rect.width,rect.height],background:style.backgroundColor,borderRadius:style.borderRadius,padding:style.padding,items:[...menu.querySelectorAll('.cb-context-menu-item')].map(button=>{const box=button.getBoundingClientRect();const icon=button.querySelector('.cb-context-menu-item-icon svg');const label=button.querySelector('.cb-context-menu-item-label');const itemStyle=getComputedStyle(button);return {label:label?label.textContent:'',width:box.width,height:box.height,fontSize:itemStyle.fontSize,lineHeight:itemStyle.lineHeight,borderRadius:itemStyle.borderRadius,color:itemStyle.color,icon:icon?[icon.getBoundingClientRect().width,icon.getBoundingClientRect().height]:null}}),dividers:[...menu.querySelectorAll('.cb-context-menu-divider')].map(divider=>{const box=divider.getBoundingClientRect();const dividerStyle=getComputedStyle(divider);return {width:box.width,height:box.height,margin:dividerStyle.margin}})}})()`
@@ -167,9 +188,10 @@ if (!process.versions.electron) {
     const archiveConfirm = await read(`(()=>{const dialog=document.querySelector('.task-popconfirm');const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};const style=getComputedStyle(dialog);return {box:box(dialog),title:dialog.querySelector('.task-popconfirm__title')?.textContent,desc:dialog.querySelector('.task-popconfirm__description')?.textContent,cancel:dialog.querySelector('.task-popconfirm__cancel')?.textContent,confirm:dialog.querySelector('.task-popconfirm__confirm')?.textContent,padding:style.padding,borderRadius:style.borderRadius}})()`)
     check('archive opens a confirmation before changing the task', archiveConfirm.title, '归档任务')
     check('archive restores the documented original destination', archiveConfirm.desc, '确认将该任务归档吗?归档后可在“系统设置-数据管理”中查看。')
-    check('archive explains the unavailable destination and working fallback', await read(`document.querySelector('.task-popconfirm__compatibility').textContent.includes('侧栏“已归档”')`), true)
+    check('archive does not append unrequested compatibility copy', await read(`!!document.querySelector('.task-popconfirm__compatibility')`), false)
     check('archive confirmation actions use the recovered labels', [archiveConfirm.cancel, archiveConfirm.confirm], ['取消', '确认归档'])
-    check('archive confirmation uses the plan-defined 240px popconfirm', [Math.round(archiveConfirm.box[2]), archiveConfirm.padding, archiveConfirm.borderRadius], [240, '16px', '12px'])
+    check('archive confirmation uses the plan-defined 240px popconfirm', [Math.round(archiveConfirm.box[2]), archiveConfirm.padding, archiveConfirm.borderRadius], [240, '16px', '16px'])
+    await checkConfirmationStyles('archive')
     await shot('archive-confirm')
     await read(`document.querySelector('.task-popconfirm__cancel').click()`)
     await wait(`!document.querySelector('.task-popconfirm')`, 'archive cancellation closes')
@@ -195,6 +217,7 @@ if (!process.versions.electron) {
     check('delete uses the idle confirmation description', deleteConfirm.desc, '删除后,这些任务将从你的所有设备中移除,且无法恢复。')
     check('delete confirmation actions use the recovered idle labels', [deleteConfirm.cancel, deleteConfirm.confirm], ['取消', '确认'])
     check('idle delete is not marked as running', deleteConfirm.running, null)
+    await checkConfirmationStyles('delete')
     await shot('delete-confirm')
     await read(`document.querySelector('.task-popconfirm__confirm').click()`)
     await wait(`window.ycode.session.listArchived().then(list=>!list.some(item=>item.id===${JSON.stringify(remainingId)}))`, 'delete completes after confirmation')
diff --git a/src/renderer/src/components/TaskPopconfirm.tsx b/src/renderer/src/components/TaskPopconfirm.tsx
index 685e2a4..10be730 100644
--- a/src/renderer/src/components/TaskPopconfirm.tsx
+++ b/src/renderer/src/components/TaskPopconfirm.tsx
@@ -1,4 +1,5 @@
 import { useEffect, useId, useRef, useState } from 'react'
+import { Button } from '../ui/Button'
 import { Popover } from '../ui/Popover'
 import './task-popconfirm.css'
 
@@ -49,12 +50,11 @@ export function TaskPopconfirm({ kind, running, anchor, onConfirm, onClose }: Ta
     

{copy.title}

{copy.description}

- {kind === 'archive' &&

YCode 暂无此设置入口,请从侧栏“已归档”查看和恢复。

} {error && }
- - + +
diff --git a/src/renderer/src/components/task-popconfirm.css b/src/renderer/src/components/task-popconfirm.css index af0e8a7..4da600f 100644 --- a/src/renderer/src/components/task-popconfirm.css +++ b/src/renderer/src/components/task-popconfirm.css @@ -4,7 +4,7 @@ width: calc(var(--yc-space-10) * 5); max-width: calc(100vw - var(--yc-space-5)); padding: var(--yc-space-5); - border-radius: var(--yc-radius-xl); + border-radius: var(--yc-radius-2xl); overflow: visible; color: var(--wb-color-text-primary); background: var(--wb-bg-popover); @@ -14,17 +14,20 @@ margin: 0 0 var(--yc-space-3); font-size: var(--yc-font-size-5); line-height: var(--yc-line-height-5-2); - font-weight: 500; + font-weight: var(--yc-font-weight-600); } -.task-popconfirm__description, -.task-popconfirm__compatibility { +.task-popconfirm__description { margin: 0; color: var(--wb-color-text-secondary); font-size: var(--yc-font-size-4); line-height: var(--yc-line-height-5); overflow-wrap: anywhere; } -.task-popconfirm__compatibility { margin-top: var(--yc-space-3); color: var(--wb-color-text-tertiary); } .task-popconfirm__error { margin-top: var(--yc-space-3); color: var(--wb-color-text-error-default); font-size: var(--yc-font-size-4); line-height: var(--yc-line-height-5); } .task-popconfirm__actions { display: flex; justify-content: flex-end; gap: var(--yc-space-3); margin-top: var(--yc-space-5); } -.task-popconfirm-source .task-popconfirm__actions .wb-button { min-width: calc(var(--yc-space-10) + var(--yc-space-2)); height: var(--yc-line-height-7); padding: 0 var(--yc-space-3); } +.task-popconfirm-source .task-popconfirm__actions .yc-button { + /* Recorded minimum width and inset, composed from existing tokens. */ + min-width: calc(var(--yc-size-17) - var(--yc-border-width) * 2); + height: var(--yc-line-height-7); + padding: 0 calc(var(--yc-space-6) - var(--yc-border-width)); +} diff --git a/src/renderer/src/workbuddy/composer/ModeChip.tsx b/src/renderer/src/workbuddy/composer/ModeChip.tsx index 36435f6..f21782a 100644 --- a/src/renderer/src/workbuddy/composer/ModeChip.tsx +++ b/src/renderer/src/workbuddy/composer/ModeChip.tsx @@ -5,10 +5,14 @@ import './mode-chip.css' export function ModeChip({ mode, disabled, onClear }: { mode: AgentMode; disabled?: boolean; onClear(): void | Promise }): React.JSX.Element | null { if (mode === 'craft') return null const label = mode === 'plan' ? '计划' : '仅问答' - return - {label} - {!disabled && } + return + + + {!disabled && } + {label} } diff --git a/src/renderer/src/workbuddy/composer/mode-chip.css b/src/renderer/src/workbuddy/composer/mode-chip.css index 838c12e..6f2432c 100644 --- a/src/renderer/src/workbuddy/composer/mode-chip.css +++ b/src/renderer/src/workbuddy/composer/mode-chip.css @@ -1,6 +1,9 @@ -.yc-mode-chip { position:relative; display:inline-flex; flex-shrink:0; align-items:center; gap:var(--yc-space-2); height:var(--yc-space-8); padding:0 var(--yc-space-3); border-radius:var(--yc-space-3); background:var(--cr-bg-tertiary); color:var(--cr-text-primary); font-size:var(--yc-font-size-4); line-height:var(--yc-line-height-5); font-weight:500; } -.yc-mode-chip > svg { flex-shrink:0; width:var(--yc-space-5); height:var(--yc-space-5); } +.yc-mode-chip { display:inline-flex; flex-shrink:0; align-items:center; gap:var(--yc-space-2); height:var(--yc-space-8); padding:0 var(--yc-space-3); border-radius:var(--yc-radius-full); background:transparent; color:var(--cr-text-secondary); font-size:var(--yc-font-size-4); line-height:var(--yc-line-height-5); font-weight:500; } +.yc-mode-chip__icon-slot { display:grid; flex-shrink:0; width:var(--yc-space-5); height:var(--yc-space-5); } +.yc-mode-chip__mode-icon, .yc-mode-chip__clear { grid-area:1 / 1; width:var(--yc-space-5); height:var(--yc-space-5); } +.yc-mode-chip__clear > svg { width:100%; height:100%; } .yc-mode-chip:focus-visible { outline:var(--yc-space-1) solid var(--cr-text-secondary); outline-offset:var(--yc-space-1); } -.yc-mode-chip__clear { display:inline-flex; align-items:center; justify-content:center; width:var(--yc-space-5); height:var(--yc-space-5); padding:0; border:0; border-radius:50%; background:var(--cr-bg-tertiary); color:inherit; font-size:var(--yc-space-5); cursor:pointer; opacity:0; position:absolute; right:calc(-1 * var(--wb-spacing-2-5)); top:calc(-1.5 * var(--yc-space-1)); } -.yc-mode-chip:hover .yc-mode-chip__clear, .yc-mode-chip:focus-within .yc-mode-chip__clear { opacity:1; } +.yc-mode-chip__clear { display:inline-flex; align-items:center; justify-content:center; padding:0; border:0; border-radius:var(--yc-radius-full); background:transparent; color:inherit; cursor:pointer; opacity:0; } +.yc-mode-chip:not([aria-disabled="true"]):hover .yc-mode-chip__mode-icon, .yc-mode-chip:not([aria-disabled="true"]):focus-within .yc-mode-chip__mode-icon { opacity:0; } +.yc-mode-chip:not([aria-disabled="true"]):hover .yc-mode-chip__clear, .yc-mode-chip:not([aria-disabled="true"]):focus-within .yc-mode-chip__clear { opacity:1; } .yc-mode-chip__clear:focus-visible { outline:var(--yc-space-1) solid var(--cr-text-secondary); } diff --git a/src/renderer/src/workbuddy/conversation/CompletedFold.tsx b/src/renderer/src/workbuddy/conversation/CompletedFold.tsx index 50f1252..0568e63 100644 --- a/src/renderer/src/workbuddy/conversation/CompletedFold.tsx +++ b/src/renderer/src/workbuddy/conversation/CompletedFold.tsx @@ -4,6 +4,7 @@ import { SourceFoldChevronIcon } from '../message-actions/SourceActionIcons' import { buildCompletedFoldModel, classifyCompletedFoldPart, + completedFoldPartIdentity, type CompletedFoldOptions, type CompletedFoldPart, type CompletedFoldSegment, @@ -26,7 +27,7 @@ function renderPart( summaryGroup?: CompletedFoldSummaryGroup ): ReactNode { const classification = unit.classification ?? classifyCompletedFoldPart(unit.part, unit.index) - return {render(unit.part, unit.index, { + return {render(unit.part, unit.index, { kind: summaryGroup ? 'summary-member' : 'part', classification, presentationGroupId: classification.presentationGroupId, @@ -42,7 +43,7 @@ function renderPresentationUnit( if (unit.kind === 'part') return renderPart(unit.unit, render) const children = unit.group.units.map(member => renderPart(member, render, unit.group)) return ( - + {renderGroup ? renderGroup(unit.group, children) : <>{children}} ) @@ -146,10 +147,6 @@ export function LiveEventDisclosure({ ) } -function partDisclosureId(part: CompletedFoldPart, index: number): string { - const id = 'id' in part && typeof part.id === 'string' ? part.id : part.type === 'tool' && typeof part.toolId === 'string' ? part.toolId : index - return JSON.stringify([part.type, id]) -} export function CompletedFold(props: CompletedFoldProps) { return @@ -183,7 +180,7 @@ function CompletedFoldContent({ if (!model) return <>{parts.map((part, index) => { const classification = classifyCompletedFoldPart(part, index, classifyToolCall) if (!classification.hasRootBlock) return null - return {render(part, index, { + return {render(part, index, { kind: 'part', classification, presentationGroupId: classification.presentationGroupId @@ -208,7 +205,7 @@ function CompletedFoldContent({ ) : parts.map((part, index) => { const classification = classifyCompletedFoldPart(part, index, classifyToolCall) if (!classification.hasRootBlock) return null - return {render(part, index, { kind: 'part', classification })} + return {render(part, index, { kind: 'part', classification })} }) })()} diff --git a/src/renderer/src/workbuddy/conversation/completed-fold.ts b/src/renderer/src/workbuddy/conversation/completed-fold.ts index 65e8336..8218432 100644 --- a/src/renderer/src/workbuddy/conversation/completed-fold.ts +++ b/src/renderer/src/workbuddy/conversation/completed-fold.ts @@ -241,13 +241,24 @@ export function formatCompletedFoldDuration(durationMs: number): string { return `${seconds}s` } +/** Business identity survives terminal text coalescing and presentation reordering. + * Old histories without an ID intentionally retain positional fallback semantics. + */ +export function completedFoldPartIdentity(part: CompletedFoldPart, index: number): string { + let id: unknown = 'id' in part ? part.id : undefined + if (part.type === 'tool') id = part.toolId + let content: unknown + if (part.type === 'plan') content = part.plan + if (part.type === 'task') content = part.task + if (part.type === 'artifact') content = part.artifact + if (content && typeof content === 'object' && 'id' in content) id = content.id + return typeof id === 'string' && id.length > 0 + ? JSON.stringify([part.type, 'id', id]) + : JSON.stringify([part.type, 'index', index]) +} + function makeUnit(part: CompletedFoldPart, index: number): CompletedFoldUnit { - const partId = 'id' in part && typeof part.id === 'string' - ? part.id - : part.type === 'tool' && typeof part.toolId === 'string' - ? part.toolId - : String(index) - return { key: `${part.type}:${partId}:${index}`, index, part } + return { key: completedFoldPartIdentity(part, index), index, part } } function buildSourceSegments( From 72bf7afd0a95212cb22e469d743bed902878c8aa Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 21 Oct 2026 13:44:53 +0900 Subject: [PATCH 2/2] docs(B01): hand off F01 and mode confirmation fixes with test evidence --- docs/协作/B01-交付-02.md | 71 ++++++ .../evidence/B01-02/browser-environment.txt | 8 + docs/协作/evidence/B01-02/verification.txt | 202 ++++++++++++++++++ 3 files changed, 281 insertions(+) create mode 100644 docs/协作/B01-交付-02.md create mode 100644 docs/协作/evidence/B01-02/browser-environment.txt create mode 100644 docs/协作/evidence/B01-02/verification.txt diff --git a/docs/协作/B01-交付-02.md b/docs/协作/B01-交付-02.md new file mode 100644 index 0000000..a706cd2 --- /dev/null +++ b/docs/协作/B01-交付-02.md @@ -0,0 +1,71 @@ +# B01 交付 02:F01 与模式、确认按钮修复 + +日期:2026-10-21(Asia/Shanghai)。开发端接续验收退回与补充反馈,状态:**源码已实现、离线自测通过,真实 Windows 复测待验**。不关闭整个 B01。 + +- 基线:`ddd677bae3c43a5514eb40f7a1d9749fdf389115`。 +- **受测代码 SHA:`1ed71b63b5af9a98b942ff47c66df5799d4d2c8a`**。 +- 需求:[验收 01](B01-验收-01.md) 的 F01;[补充反馈 01](B01-补充反馈-01.md) 的 FB01/A06、FB02/A05;沿用 [B01 需求](B01-需求.md) 的 AC1.3、AC3.1~3.3。 +- 范围:YC-001 的 F01 修复,以及 YC-003 的 A05/A06 部分交付。A01/A04 已有验收证据不推倒重做;YC-002、YC-004、A07/A08 未在本轮实施。**没有恢复用户撤回的骨架补丁。** + +## 1. 实现 + +### F01:原生 part 稳定身份 + +`completed-fold.ts` 统一生成展示身份,识别原生 `plan.id`、`task.id`、`artifact.id`,以及 tool、reasoning、image 的稳定 ID;React key 与 disclosure scope 共用同一身份。类型与 ID/位置域分开,消息级 store 不共享。 + +真实 `applyAssistantEvent → MessageBubble` 回归覆盖原验收的两个 text 被 done 校正合并、原生 plan/task、插入重排、相反展开选择、外层重复开关、同 ID 跨消息隔离。无 ID 旧历史保留位置 fallback;**不承诺没有稳定 ID 时任意重排仍可识别同一项**。未改 reducer 或历史源对象。 + +### FB02/A05:ModeChip + +恢复透明默认背景、pill 圆角、次级文字;模式图标与 X 共用同一 16px grid 槽,hover/focus-within 原位替换,无右上角额外 X。保留 32px 高、13px/500/20px 文字、8px padding、原生退出按钮、craft 隐藏和禁用语义。继续调用原宿主 clear 回调,不重写草稿或权限偏好。 + +图标仍为本地几何,源码保留待同版本 REA SVG/像素验收的标注。 + +### FB01/A06:TaskPopconfirm + +改用自带 CSS 的 `ui/Button`,明确 primary/secondary/danger,避免依赖 portal 外旧 `.wb-button` 类作用域。恢复 66px 最小宽度、19px 水平 padding、16px 浮层圆角、600 标题,保留 240px 右侧锚定、箭头、焦点、点击吞掉、忙态重复提交锁及失败重试。 + +按已确认要求保留原版归档说明,移除附加兼容解释。**YCode 当前仍没有“系统设置-数据管理”归档入口,实际查看/恢复仍在侧栏“已归档”;本轮仅恢复指定文案,不声称已实现该设置页。** + +## 2. 验证结果 + +证据:[离线日志](evidence/B01-02/verification.txt)、[浏览器环境失败记录](evidence/B01-02/browser-environment.txt)。Linux x64、Node 24.19.0、npm 11.9.0、jsdom 30.1.3;复用已安装依赖,未改 package/lock。 + +| 检查 | 结果与边界 | +| --- | --- | +| `npm run build` | 退出 0,含两个 tsconfig 类型检查与 main/preload/renderer 构建;中间发现的 TS narrowing 错误已修正后重跑 | +| `YCODE_TEST_DOM_MODULE= npm run test:alignment` | 退出 0;包含原生身份新回归、ModeChip 37 项、TaskPopconfirm 34 项(含运行中停止并删除、busy、失败重试)、原有问答/授权 DOM | +| `test:conversation`、`test:workbuddy-import`、`test:artifacts` | 均退出 0 | +| F01 基线负对照 | 以基线两个 CompletedFold 模块替换后准确复现 false→true,预期退出 1;新产品路径通过 | +| `test:fold-summary` | 本 Linux LF 检出退出 0;不等于 Windows CRLF 问题已修复,本轮未改摘要/manifest/行尾策略 | +| `check:tokens` | 退出 1,基线与最终均为相同 6 项既存失败,无新增失败、未刷新 baseline | +| 两个新 UI fixture 的 `--build-only` | 退出 0,仅证明夹具打包成功,不是计算样式/截图通过 | +| 独立复审 | 重跑原生身份、37 项模式、34 项确认 DOM;未发现生产代码阻塞性回归;补齐 fixture CSS、主题同步与安全默认参数 | + +本轮无模型请求、无真实 SDK/CLI 执行、无原版访问。当前环境 Chromium 在页面加载前因 socket 权限失败,未取得新截图和真实计算样式;没有绕过限制。实际 Electron 全应用、Windows、窄窗、像素与辅助技术验收仍待执行,不能从 jsdom/构建结果推导通过。 + +## 3. 验收复跑 + +按协作流程在固定代码 SHA 的独立检出安装构建。完整应用测试必须使用新临时 `YCODE_USER_DATA_DIR`,不能启动默认正式数据目录。不要复用开发机 node_modules 或旧 out。 + +```powershell +npm ci --legacy-peer-deps +npm run build +npm install --prefix .tmp/b01-dom --ignore-scripts --no-audit --no-fund jsdom@30.1.3 +$env:YCODE_TEST_DOM_MODULE = Join-Path $PWD '.tmp/b01-dom/node_modules/jsdom' +npm run test:alignment +npm run test:conversation +npm run test:workbuddy-import +npm run test:artifacts +node scripts/test-mode-chip-native.mjs +node scripts/verify-task-menu.cjs +``` + +- `test-mode-chip-native.mjs` 自建隔离 Electron 组件窗口/userData,覆盖浅深、hover/focus、原生 Tab/Enter/Space、禁用、窄窗,输出截图与 report。不是完整宿主草稿/焦点验收;不访问原版或模型。 +- `verify-task-menu.cjs` 保留真实隔离 main/preload/IPC/本地落库测试,新增 portal 两主题计算样式;通过 `ycode:theme` storage 通路同步应用主题,不仅改根节点。必须在可用 Electron 环境执行后再判断通过。 +- `node scripts/test-task-popconfirm-browser.mjs --build-only` 生成独立可移植 HTML;去掉该参数并设置 `CHROMIUM_PATH` 到本机 Chromium/Chrome 可执行文件可运行真实计算样式断言。浅深、主次危险、busy/error/retry 有断言;这个组件 fixture 不代替真实 IPC/落库。 +- 两个新浏览器脚本默认保留 sandbox;无浏览器时不把 build-only 当作验收完成。 + +重点复测 F01 原验收事件序列,A05 两模式默认/悬停/焦点/退出、权限及草稿保留,A06 取消/确认、运行中停止并删除、两主题样式、焦点和失败重试。保留旧失败记录,另写新验收记录。 + +交付记录可位于受测代码后的文档提交;只有远端推送与 SHA 核对成功后才算 Git 交接完成。本轮交付完成后推送窗口交回验收端。 diff --git a/docs/协作/evidence/B01-02/browser-environment.txt b/docs/协作/evidence/B01-02/browser-environment.txt new file mode 100644 index 0000000..82e1aa6 --- /dev/null +++ b/docs/协作/evidence/B01-02/browser-environment.txt @@ -0,0 +1,8 @@ +Observed during environment capability check, 2026-10-21 UTC; copied from tool output (not a successful fixture run). +Target: local Chromium headless with explicit repository .tmp user-data-dir. +No page loaded and no computed-style result was obtained. +[6:6:1021/133654.542575:ERROR:third_party/crashpad/crashpad/util/file/filesystem_posix.cc:63] mkdir : No such file or directory (2) +chrome_crashpad_handler: --database is required +Try 'chrome_crashpad_handler --help' for more information. +[6:6:1021/133654.579509:FATAL:chrome/browser/process_singleton_posix.cc:297] Check failed: . socket() failed: Operation not permitted (1) +Stopped after this error. Checked-in browser runner retains the Chromium sandbox; only --build-only completed in this environment. diff --git a/docs/协作/evidence/B01-02/verification.txt b/docs/协作/evidence/B01-02/verification.txt new file mode 100644 index 0000000..18c3dc5 --- /dev/null +++ b/docs/协作/evidence/B01-02/verification.txt @@ -0,0 +1,202 @@ +B01 delivery 02 offline evidence +Code SHA: 1ed71b63b5af9a98b942ff47c66df5799d4d2c8a +Base: ddd677bae3c43a5514eb40f7a1d9749fdf389115 +Linux x64; Node 24.19.0; npm 11.9.0; jsdom 30.1.3 +No model/SDK process or original app was started. Native style/layout acceptance is not claimed. +Token failure sets before and after are equal (6 pre-existing failures); baseline unchanged. +First intermediate build found a TS narrowing error; fixed before successful final build. +Independent review repeated DOM tests and found no blocking production regression. + +===== alignment.txt: exit 0 ===== +npm warn Unknown env config "http-proxy". This will stop working in the next major version of npm. + +> ycode@0.0.7 test:alignment +> node scripts/run-alignment-regression.mjs + +PASS model alignment: fresh/legacy migration, new/edit/default/delete/reload, valid selection (offline host fixtures) +PASS model settings: deferred owner switch, rename mapping, addition selection, empty deletion, immutable history +PASS session factory: first-send preference, mode preservation, invalid permission rejection (isolated ports) +{"historyMessages":10000,"bytes":747781,"parseMs":3.751840999999999,"storeHydrationMs":0.13198400000000277,"note":"Node parsing/store benchmark only, not browser rendering or Windows IPC performance"} +session-composer-alignment: loading/cache/retry/races/model/permissions/modes/SSR passed +alignment-conversation: local groups, original expanded order, question projection and truthful status passed +A01 native MessageBubble identity aliases, immutable inputs and ordinary raw output passed +A01 imported native card, linked dedup and unknown-position compatibility passed +question-answer-shell: structural and recovered CSS contracts passed +question-answer-shell: DOM hierarchy, controls linkage, click, Enter, Space and inert contracts passed +Runtime progress, session-grant scope and approval lifecycle regressions passed +Permission intercept static rendering passed; interactive keyboard/focus remains UI acceptance +Claude host approval policy unit checks passed (no SDK process/application/matching verification) +Mock SDK replay lifecycle passed: fail closed, retry, session isolation, independent mode guard +task popconfirm DOM regression: 34 checks passed (jsdom, not pixel validation) +question-interaction: 24 DOM behavioral checks passed +question-identity: 30 A01 DOM checks passed (not Electron/pixel/SDK acceptance) +completed-fold-nested: real question/tool/thinking/local-summary remount, event, keyboard and identity contracts passed +completed-fold-native-identity: real reducer done correction, native IDs, insertion/reorder, repeated outer toggles, message isolation and legacy fallback passed +Permission intercept DOM: 14 checks passed (not pixel/SDK verification) +{"checks":37,"passed":true,"scope":"Isolated React DOM and CSS contracts; no native keyboard/layout/pixel acceptance"} +npm notice +npm notice New major version of npm available! 11.9.0 -> 12.2.0 +npm notice Changelog: https://github.com/npm/cli/releases/tag/v12.2.0 +npm notice To update run: npm install -g npm@12.2.0 +npm notice + +===== conversation.txt: exit 0 ===== +npm warn Unknown env config "http-proxy". This will stop working in the next major version of npm. + +> ycode@0.0.7 test:conversation +> esbuild scripts/conversation-renderer-regression.tsx scripts/composer-regression.tsx scripts/completed-fold-regression.tsx scripts/streaming-footer-regression.tsx --bundle --platform=node --format=cjs --jsx=automatic --loader:.css=text --out-extension:.js=.cjs --outdir=.tmp/conversation-tests --log-level=error && node .tmp/conversation-tests/conversation-renderer-regression.cjs && node .tmp/conversation-tests/composer-regression.cjs && node .tmp/conversation-tests/streaming-footer-regression.cjs && node .tmp/conversation-tests/completed-fold-regression.cjs + +Conversation specialized renderer SSR regression checks passed +Composer/message action regression cases passed +streaming-footer-regression: persistent stream status, delayed/paused rotation, brand/locale mapping, source CSS, and completed model footer passed +Completed-fold model and SSR regression checks passed + +===== import.txt: exit 0 ===== +npm warn Unknown env config "http-proxy". This will stop working in the next major version of npm. + +> ycode@0.0.7 test:workbuddy-import +> esbuild scripts/workbuddy-import-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/workbuddy-import-regression.mjs --log-level=warning && node .tmp/workbuddy-import-regression.mjs + +WorkBuddy import regression passed + +===== artifacts.txt: exit 0 ===== +npm warn Unknown env config "http-proxy". This will stop working in the next major version of npm. + +> ycode@0.0.7 test:artifacts +> esbuild scripts/artifact-diff-regression.ts scripts/artifact-renderer-regression.tsx scripts/specialized-block-regression.tsx --bundle --platform=node --format=cjs --jsx=automatic --loader:.css=empty --out-extension:.js=.cjs --outdir=.tmp/artifact-tests --log-level=warning && node .tmp/artifact-tests/artifact-diff-regression.cjs && node .tmp/artifact-tests/artifact-renderer-regression.cjs && node .tmp/artifact-tests/specialized-block-regression.cjs + +Artifact diff recovery regression checks passed +Artifact routing, content loading, full write projection and renderer regressions passed +Specialized block icon and label SSR regression checks passed + +===== build.txt: exit 0 ===== +[Tail of successful full build; includes prior typecheck in command] +out/renderer/assets/vue-vine-DXhYlaTf-DTauGW8s.js 201.27 kB +out/renderer/assets/SourceSelect-DrNU8YnZ.js 203.20 kB +out/renderer/assets/chunk-4HAMMTFA-BEFC_AJa.js 210.32 kB +out/renderer/assets/swimlanes-42K2YHIH-Bp72q37Z.js 265.51 kB +out/renderer/assets/wolfram-BVoTES0L-6ikJLWGF.js 267.14 kB +out/renderer/assets/chunk-DU6HZSFF-DPu5LxXf.js 271.07 kB +out/renderer/assets/architectureDiagram-5GKGNRK7-DAeFXfK3.js 318.23 kB +out/renderer/assets/katex-Y9ra-ykP.js 497.35 kB +out/renderer/assets/texmath-Bkeg3ZLn.js 594.25 kB +out/renderer/assets/wasm-CVjZL0jK-2Bi6p3c3.js 622.62 kB +out/renderer/assets/cpp-9jHBA231-CtHklR8a.js 626.73 kB +out/renderer/assets/app-BYh1ERuc.js 675.57 kB +out/renderer/assets/emacs-lisp-DjtOxg4j-CUUa7aOE.js 780.06 kB +out/renderer/assets/cytoscape.esm-bVGYt-iW.js 869.69 kB +out/renderer/assets/ArtifactPierreDiff-D5xhRc5y.js 1,119.58 kB +out/renderer/assets/chunk-FOHPRMQF-BCllzKJD.js 1,154.18 kB +out/renderer/assets/index-lmA4yj_k.js 3,760.12 kB +out/renderer/assets/editor.api2-dPwET-7w-BYIQ9bT3.js 4,889.23 kB + +✓ built in 2.08s +npm notice +npm notice New major version of npm available! 11.9.0 -> 12.2.0 +npm notice Changelog: https://github.com/npm/cli/releases/tag/v12.2.0 +npm notice To update run: npm install -g npm@12.2.0 +npm notice +===== fold-before.txt: exit 0 ===== +npm warn Unknown env config "http-proxy". This will stop working in the next major version of npm. + +> ycode@0.0.7 test:fold-summary +> node scripts/rea/recovered-fold-summary-regression.mjs + +Recovered fold-summary closure regression passed (descriptor aliases, object selection, states, topics and labels). + +===== tokens-before.txt: exit 1 ===== +npm warn Unknown env config "http-proxy". This will stop working in the next major version of npm. + +> ycode@0.0.7 check:tokens +> node scripts/check-design-tokens.mjs + +设计 token 漂移检查(静态) + 扫描:src/renderer/src 共 367 个文件(用时 49 ms) + baseline:scripts/design-token-baseline.json(334 个文件) + 门禁分类:adapter 8 | core 6 | local 279(合计 293 个文件) + 合计:裸 hex=611 裸 px=2682 !important=6 --wb-* 定义=525 var(--yc-*) 使用=1642 var(--wb-*) 使用=1203 + 非门禁分类:vendor 2 | recovered 22 | source 47 | tokenSource 3(合计 74 个文件,仅记录不阻塞) + 合计:裸 hex=10642 裸 px=13642 !important=920 --wb-* 定义=17966 var(--yc-*) 使用=77 var(--wb-*) 使用=19638 + 非门禁相对 baseline:文件 +0;裸 hex +78 裸 px +64 !important +0 --wb-* 定义 +0 var(--yc-*) 使用 +22 var(--wb-*) 使用 -120 + +结果:失败,共 6 项: + [新增门禁文件] src/renderer/src/capabilities/recommended-flow.css(local):裸 px=22 + [新增门禁文件] src/renderer/src/capabilities/skill-detail-shell.css(local):裸 px=21 + [回归] src/renderer/src/capabilities/skills-host.css(local):裸 px 25 → 46(+21) + [新增门禁文件] src/renderer/src/workbuddy/composer/capability-picker.css(local):裸 hex=26,裸 px=48 + [回归] src/renderer/src/workbuddy/composer/composer.css(local):裸 hex 23 → 38(+15);裸 px 83 → 139(+56) + [新增门禁文件] src/renderer/src/workbuddy/composer/prompt-queue.css(local):裸 hex=8,裸 px=35 + +提示:确认变更符合预期后运行 `node scripts/check-design-tokens.mjs --update-baseline` 刷新 baseline; + 否则请改用 --yc-* token、复用现有值,或去掉重复的 --wb-* 定义。 +提示(不失败,仅记录):3 个门禁文件 token 使用数上升: + src/renderer/src/capabilities/skills-host.css(local):var(--wb-*) 使用 12 → 19(+7) + src/renderer/src/workbuddy/composer/composer.css(local):var(--yc-*) 使用 35 → 47(+12);var(--wb-*) 使用 0 → 1(+1) + src/renderer/src/workbuddy/conversation/conversation.css(local):var(--yc-*) 使用 0 → 4(+4) +提示(不失败):baseline 中 8 个文件已不存在:src/renderer/src/components/ReportedRunUsage.tsx;src/renderer/src/workbuddy/subagents/SourceMemberIcon.tsx;src/renderer/src/workbuddy/subagents/SubagentCollaboration.tsx;src/renderer/src/workbuddy/subagents/SubagentDetailPanel.tsx;src/renderer/src/workbuddy/subagents/adapters.css;src/renderer/src/workbuddy/subagents/member-view.ts;src/renderer/src/workbuddy/subagents/source.css;src/renderer/src/workbuddy/subagents/theme.css +提示:新增门禁文件 36 个且无受控计数(允许):src/renderer/src/capabilities/CapabilityMaintenance.tsx(local);src/renderer/src/capabilities/RecommendedSkills.tsx(local);src/renderer/src/capabilities/SkillAvatar.tsx(local);src/renderer/src/capabilities/SkillDetailShell.tsx(local);src/renderer/src/capabilities/SkillModalPortal.tsx(local);src/renderer/src/capabilities/skill-modal-focus.ts(local);src/renderer/src/components/SessionLoading.tsx(local);src/renderer/src/components/TaskPopconfirm.tsx(local);src/renderer/src/components/session-loading.css(local);src/renderer/src/components/task-popconfirm.css(local);src/renderer/src/components/updates/UpdateSidebarCard.tsx(local);src/renderer/src/components/updates/update-ui-model.ts(local);src/renderer/src/components/updates/update-ui-store.ts(local);src/renderer/src/components/updates/updates-ui.css(local);src/renderer/src/ui/Button.tsx(local);src/renderer/src/ui/IconButton.tsx(local);src/renderer/src/ui/Input.tsx(local);src/renderer/src/ui/Popover.tsx(local);src/renderer/src/ui/Select.tsx(local);src/renderer/src/ui/Switch.tsx(local);…(还有 16 项,详见 --json) + +===== tokens-after.txt: exit 1 ===== +npm warn Unknown env config "http-proxy". This will stop working in the next major version of npm. + +> ycode@0.0.7 check:tokens +> node scripts/check-design-tokens.mjs + +设计 token 漂移检查(静态) + 扫描:src/renderer/src 共 367 个文件(用时 48 ms) + baseline:scripts/design-token-baseline.json(334 个文件) + 门禁分类:adapter 8 | core 6 | local 279(合计 293 个文件) + 合计:裸 hex=611 裸 px=2682 !important=6 --wb-* 定义=525 var(--yc-*) 使用=1642 var(--wb-*) 使用=1201 + 非门禁分类:vendor 2 | recovered 22 | source 47 | tokenSource 3(合计 74 个文件,仅记录不阻塞) + 合计:裸 hex=10642 裸 px=13642 !important=920 --wb-* 定义=17966 var(--yc-*) 使用=77 var(--wb-*) 使用=19638 + 非门禁相对 baseline:文件 +0;裸 hex +78 裸 px +64 !important +0 --wb-* 定义 +0 var(--yc-*) 使用 +22 var(--wb-*) 使用 -120 + +结果:失败,共 6 项: + [新增门禁文件] src/renderer/src/capabilities/recommended-flow.css(local):裸 px=22 + [新增门禁文件] src/renderer/src/capabilities/skill-detail-shell.css(local):裸 px=21 + [回归] src/renderer/src/capabilities/skills-host.css(local):裸 px 25 → 46(+21) + [新增门禁文件] src/renderer/src/workbuddy/composer/capability-picker.css(local):裸 hex=26,裸 px=48 + [回归] src/renderer/src/workbuddy/composer/composer.css(local):裸 hex 23 → 38(+15);裸 px 83 → 139(+56) + [新增门禁文件] src/renderer/src/workbuddy/composer/prompt-queue.css(local):裸 hex=8,裸 px=35 + +提示:确认变更符合预期后运行 `node scripts/check-design-tokens.mjs --update-baseline` 刷新 baseline; + 否则请改用 --yc-* token、复用现有值,或去掉重复的 --wb-* 定义。 +提示(不失败,仅记录):3 个门禁文件 token 使用数上升: + src/renderer/src/capabilities/skills-host.css(local):var(--wb-*) 使用 12 → 19(+7) + src/renderer/src/workbuddy/composer/composer.css(local):var(--yc-*) 使用 35 → 47(+12);var(--wb-*) 使用 0 → 1(+1) + src/renderer/src/workbuddy/conversation/conversation.css(local):var(--yc-*) 使用 0 → 4(+4) +提示(不失败):baseline 中 8 个文件已不存在:src/renderer/src/components/ReportedRunUsage.tsx;src/renderer/src/workbuddy/subagents/SourceMemberIcon.tsx;src/renderer/src/workbuddy/subagents/SubagentCollaboration.tsx;src/renderer/src/workbuddy/subagents/SubagentDetailPanel.tsx;src/renderer/src/workbuddy/subagents/adapters.css;src/renderer/src/workbuddy/subagents/member-view.ts;src/renderer/src/workbuddy/subagents/source.css;src/renderer/src/workbuddy/subagents/theme.css +提示:新增门禁文件 36 个且无受控计数(允许):src/renderer/src/capabilities/CapabilityMaintenance.tsx(local);src/renderer/src/capabilities/RecommendedSkills.tsx(local);src/renderer/src/capabilities/SkillAvatar.tsx(local);src/renderer/src/capabilities/SkillDetailShell.tsx(local);src/renderer/src/capabilities/SkillModalPortal.tsx(local);src/renderer/src/capabilities/skill-modal-focus.ts(local);src/renderer/src/components/SessionLoading.tsx(local);src/renderer/src/components/TaskPopconfirm.tsx(local);src/renderer/src/components/session-loading.css(local);src/renderer/src/components/task-popconfirm.css(local);src/renderer/src/components/updates/UpdateSidebarCard.tsx(local);src/renderer/src/components/updates/update-ui-model.ts(local);src/renderer/src/components/updates/update-ui-store.ts(local);src/renderer/src/components/updates/updates-ui.css(local);src/renderer/src/ui/Button.tsx(local);src/renderer/src/ui/IconButton.tsx(local);src/renderer/src/ui/Input.tsx(local);src/renderer/src/ui/Popover.tsx(local);src/renderer/src/ui/Select.tsx(local);src/renderer/src/ui/Switch.tsx(local);…(还有 16 项,详见 --json) + +===== mode-fixture-build.txt: exit 0 ===== +{"output":"/workspace/scratch/0aaa9774aecc/single-package-staging/ycode/.tmp/mode-chip-native-ZJMFra","fixtureBuilt":true,"nativeExecuted":false} + +===== popconfirm-fixture-build.txt: exit 0 ===== +Browser fixture: /workspace/scratch/0aaa9774aecc/single-package-staging/ycode/.tmp/task-popconfirm-browser-RoVW4H/index.html + +===== identity-baseline-negative-control.txt: exit 1 ===== + AssertionError [ERR_ASSERTION]: terminal correction preserves choices ++ actual - expected + + { + plan: [ ++ 'true' +- 'false' + ], + task: [ ++ 'true' +- 'false' + ] + } + + at main2 (/workspace/scratch/0aaa9774aecc/single-package-staging/ycode/.tmp/f01-baseline-check.cjs:293667:25) { + generatedMessage: false, + code: 'ERR_ASSERTION', + actual: [Object], + expected: [Object], + operator: 'deepStrictEqual', + diff: 'simple' +} + +Baseline negative control reproduced exact terminal correction failure (expected exit 1). +